v0.91.0 — Slugs and stable links
Shipped 2026-09-19 — merged to main as 62c761b7, tagged v0.91.0, pushed. Built on version/0.91.0, seven commits, one per task. The second of the
fifteen releases ROADMAP.md sequences from the August
2026 heuristic evaluation; the roadmap page is
slugs-and-stable-links and the six
findings it answers are on
the evaluation itself.
Slugs have existed since v0.29.0 — campaigns.slug, maps.slug (per
campaign), characters.slug (per owner) — and the app never linked with them.
Every generated link carried the id. This release makes the slug the URL, and
makes changing a slug safe.
Links
- Every link prefers the slug (S.1).
campaignHref,mapHref,characterHrefandchatHrefare the only authors of those paths; the 96 hand-built ones across 41 files are swept onto them and a source-text guard fails on the next. Behind that: everything downstream of a route is keyed by id, so a slug in the URL becomes an id once —GET /api/resolve, under each entity's own access rule, feeding a client-side index of every slug seen — before any of the ~95 route readers see it. An id URL never round-trips; an unknown slug shows a not-found page instead of a wrong one. - An id URL rewrites to the slug (S.2) the moment the entity loads —
replace, one history entry, nothing refetched. Sub-pages keep their tail. A slug renamed from the page you are on moves the URL with it, and keeps the side panels open across the move.
Slug history
slug_history(S.3): every slug an entity has carried and when it stopped, both dialects, pg migration 0029. One column beyond the plan —scope— because uniqueness stays per scope (decision S-D1) and the historical check has to ask within it. A first-ever change still records the old slug as released, though it predates the table. A soft delete keeps the slug with the entity; only a purge releases it (S-D2).- A stale slug offers the way back (S.4). The not-found page names the entities that carried the slug, as they are now — a live one as a link that lands on its current slug, a trashed one as a restore.
Collisions
- A live collision fails with alternatives (S.5): a 409 with up to three
slugs free in the same scope —
my-cool-pc-1,-2,<handle>-my-cool-pc— rendered as one-click chips under the field. The check counts the trash, which the old one did not: a trashed entity keeps its unique-index row, so setting a slug the trash held used to pass the check and die as a 500. A generated slug avoids the trash and the scope's history too. - A historical collision asks (S.6). A slug nobody holds but something
released is set only by someone who may edit every releaser — the campaign
owner, the map's campaign owner,
editon the character — and only after a "Take over this slug?" that names them. Anyone else gets the alternatives. - Editing an existing slug warns (S.7), before the save: links break, keeping it is the safe choice, old slugs are remembered so an old link will probably find its way back — with no guarantee.
Found on the way
- The map's slug has not been editable since v0.38.0. The autosave rewrite of the Edit Map panel dropped the field the v0.29.0 modal had, and that modal survives only in an unreachable tree. The panel has the field again, off the undo stack.
- The character sheet keeps its v0.45.0 resolve-then-save for a taken slug; the ask and the warning apply to it like the others.