Skip to content

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.

  • Every link prefers the slug (S.1). campaignHref, mapHref, characterHref and chatHref are 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, edit on 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.