Address boards by a stable key instead of their database id #213

Merged
Grandiras merged 1 commit from issue-197-stable-board-keys into main 2026-09-22 20:47:55 +00:00
Owner

Closes #197.

Boards were addressed by database id (/board/3). That's one Postgres sequence's answer: meaningless once a board is a record (#198), and it let anybody count a deployment's boards from the address bar. A board now carries a TID, the record key atproto uses. It's minted when the board is made, and every address names it.

What changes

  • Board.Key (Services/BoardKey.cs): 13 characters of base32-sortable, following the TID spec (zero is 2222222222222, the first character is limited to 234567abcdefghij). New boards get a random clock id; boards that predate keys get one from their own CreatedAt, with the id as the clock.
  • Everything that writes or reads a URL now uses the key: ShareLink, the board, calendar and settings routes, NavHeader, the home tiles, the for-you strip, search results, link previews, the move-card dialog's share link, and CalendarScope for new subscriptions. The id stays the primary key, and services, the notifier and the palette still take ints.
  • Old links keep working. /board/3, /board/3/settings/tags, /calendar/board/3, /admin/columns/3 and ?card= all resolve. For a board the reader may see, the page moves the address bar on to the key (BoardAddress.MoveToKey), as an HTTP redirect during prerender. For a board they may not see, the URL stays put, so a redirect never hands out a key. The two .ics endpoints read an old id unchanged rather than redirecting, because calendar apps are never told about a redirect.
  • Last-page resume checks any board-scoped path, key or id.

Migration

AddBoardKeys adds a nullable, uniquely indexed column, with no SQL backfill. DatabaseInitializer.EnsureBoardKeysAsync fills missing keys on every boot. That keeps one implementation of what a key is, and it means the previous release, which inserts boards without a key, can still be rolled back to. A later release can make the column NOT NULL.

Also here

  • The follow-up on #212's review: the default board is now seeded only into a deployment that has never had a board, which the Boards id sequence answers exactly. That also covers a first boot that failed partway through.

Tests

  • BoardKeyTests covers the format, sorting, legacy ids and the clock.
  • ShareLinkTests covers keys, legacy ids and WithBoardKey.
  • CalendarScopeTests covers keys and ids, mixed and normalised.
  • LastPageTests covers settings paths by key.
  • e2e: specs read keys (a shared BOARD_KEY regex). There's a new test walking every old address shape, and access.spec pins that a refused old link stays put.
  • Full suite: 159/159 locally.

🤖 Generated with Claude Code

Closes #197. Boards were addressed by database id (`/board/3`). That's one Postgres sequence's answer: meaningless once a board is a record (#198), and it let anybody count a deployment's boards from the address bar. A board now carries a **TID**, the record key atproto uses. It's minted when the board is made, and every address names it. ## What changes - `Board.Key` (`Services/BoardKey.cs`): 13 characters of base32-sortable, following the TID spec (zero is `2222222222222`, the first character is limited to `234567abcdefghij`). New boards get a random clock id; boards that predate keys get one from their own `CreatedAt`, with the id as the clock. - Everything that writes or reads a URL now uses the key: `ShareLink`, the board, calendar and settings routes, `NavHeader`, the home tiles, the for-you strip, search results, link previews, the move-card dialog's share link, and `CalendarScope` for new subscriptions. The id stays the primary key, and services, the notifier and the palette still take ints. - **Old links keep working.** `/board/3`, `/board/3/settings/tags`, `/calendar/board/3`, `/admin/columns/3` and `?card=` all resolve. For a board the reader may see, the page moves the address bar on to the key (`BoardAddress.MoveToKey`), as an HTTP redirect during prerender. **For a board they may not see, the URL stays put**, so a redirect never hands out a key. The two `.ics` endpoints read an old id unchanged rather than redirecting, because calendar apps are never told about a redirect. - Last-page resume checks any board-scoped path, key or id. ## Migration `AddBoardKeys` adds a nullable, uniquely indexed column, with no SQL backfill. `DatabaseInitializer.EnsureBoardKeysAsync` fills missing keys on every boot. That keeps one implementation of what a key is, and it means the previous release, which inserts boards without a key, can still be rolled back to. A later release can make the column `NOT NULL`. ## Also here - The follow-up on #212's review: the default board is now seeded only into a deployment that has *never* had a board, which the `Boards` id sequence answers exactly. That also covers a first boot that failed partway through. ## Tests - `BoardKeyTests` covers the format, sorting, legacy ids and the clock. - `ShareLinkTests` covers keys, legacy ids and `WithBoardKey`. - `CalendarScopeTests` covers keys and ids, mixed and normalised. - `LastPageTests` covers settings paths by key. - e2e: specs read keys (a shared `BOARD_KEY` regex). There's a new test walking every old address shape, and `access.spec` pins that a refused old link stays put. - Full suite: 159/159 locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address boards by a stable key instead of their database id
All checks were successful
CI / build (pull_request) Successful in 47s
d3ad6b98cb
Boards were /board/3: one Postgres sequence's answer, meaningless the
day a board is a record, and a count of the deployment's boards in the
address bar. A board now carries a TID (BoardKey) minted when it is
made, and every URL names it: the board, its calendar, its settings,
share links, link previews, search results, the for-you strip and new
.ics subscriptions. The id stays the primary key; only the places that
write or read a URL deal in keys.

Every old link keeps working. A page reached by an id resolves it and,
for a board the reader may see, moves the address bar on to the key
(BoardAddress, ShareLink.WithBoardKey); a board they may not see stays
at the id, so the key is never handed out by a redirect. Both .ics
endpoints read old ids unchanged, because calendar apps are never told
about a redirect.

Migration AddBoardKeys adds a nullable, uniquely indexed column; boot
fills in missing keys from each board's creation time (so the previous
release, which inserts boards without one, can still be rolled back to).

Also: the default board is seeded only into a deployment that has
never had one — the Boards id sequence says so exactly — which covers
a first boot that failed half way as well as a fresh database.

Fixes #197

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer approved these changes 2026-09-22 20:03:14 +00:00
Dismissed
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Solid, well-reasoned change. The parts that could have gone wrong are all right:

  • TID encoding is spec-correct. BoardKey.From masks µs to 53 bits, shifts left 10, ORs a 10-bit clock, and emits 13 base32-sortable chars with the first char carrying four bits — zero is 2222222222222, and BoardKeyTests pins exact values including the one-microsecond step. IsWellFormed mirrors the spec's first-character limit; IsLegacyId (≤9 digits, invariant parse, positive) can never be confused with a 13-char key.
  • The access rule holds. MoveToKey only navigates when the board resolved Found; denied/missing boards keep the id in the address bar, and access.spec.js pins exactly that. The .ics endpoints read ids without redirecting, as calendar apps require. ResolveManyAsync handles mixed key/id lists, normalises 007, and dedups a board named both ways.
  • The ?card= interplay is handled. When MoveToKey returns true, OpenSharedCard is skipped for that parameter set and the keyed navigation's parameter set opens it via the early-return branch — the comment describes exactly what the code does, and WithBoardKey preserves the query.
  • Migration and rollback story is sound. Nullable column + unique index (Postgres allows many NULLs), no SQL backfill, EnsureBoardKeysAsync on every boot before anything serves. The new SeedDefaultBoardAsync rule is correct: pg_get_serial_sequence does return the implicit sequence for EF's identity columns (verified against the PostgreSQL docs), pg_sequence_last_value is NULL until the first insert, and the AddBoards migration inserts without an explicit id so it advances the sequence too; the || AnyAsync guard covers explicit-id inserts.
  • Coverage is complete. Every URL writer/reader moved to keys; data-board-id → data-board-key with no JS reading the old attribute; SearchDocument.BoardKey/BoardNameKey rename avoids the name collision and PassesFilters uses the right one.

Minor notes, none blocking:

  • BoardKey.New()'s random clock id collides 1/1024 per same-microsecond pair; the unique index turns that into a failed CreateAsync (generic error). Documented as deliberate and negligible.
  • The backfill uses board.Id & 0x3FF as the clock, so two boards created in the same microsecond with ids 1024 apart would collide — the unique index catches it as a boot error. Astronomically unlikely; the doc comment's "still differ" claim has this one congruence exception.
  • ForYouService's board?.Key ?? "" yields a broken /board/?card=N link if the board vanishes between the two passes, where the old id link at least reached the empty state. Vanishingly rare edge.
  • Dropping the :int route constraint means /board/abc now renders the "missing" empty state (200) rather than a 404 — consistent with the design's "Missing without a query" and with the existing behaviour for absent ids, just noting the change.


⏱ 20m 38s · 15 steps · $0.1475 · tokens: 660.6k in / 61.5k out (+587.1k cache-read) · Go: 5h 16% · wk 6% · mo 36%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Solid, well-reasoned change. The parts that could have gone wrong are all right: - **TID encoding is spec-correct.** `BoardKey.From` masks µs to 53 bits, shifts left 10, ORs a 10-bit clock, and emits 13 base32-sortable chars with the first char carrying four bits — zero is `2222222222222`, and `BoardKeyTests` pins exact values including the one-microsecond step. `IsWellFormed` mirrors the spec's first-character limit; `IsLegacyId` (≤9 digits, invariant parse, positive) can never be confused with a 13-char key. - **The access rule holds.** `MoveToKey` only navigates when the board resolved `Found`; denied/missing boards keep the id in the address bar, and `access.spec.js` pins exactly that. The `.ics` endpoints read ids without redirecting, as calendar apps require. `ResolveManyAsync` handles mixed key/id lists, normalises `007`, and dedups a board named both ways. - **The `?card=` interplay is handled.** When `MoveToKey` returns true, `OpenSharedCard` is skipped for that parameter set and the keyed navigation's parameter set opens it via the early-return branch — the comment describes exactly what the code does, and `WithBoardKey` preserves the query. - **Migration and rollback story is sound.** Nullable column + unique index (Postgres allows many NULLs), no SQL backfill, `EnsureBoardKeysAsync` on every boot before anything serves. The new `SeedDefaultBoardAsync` rule is correct: `pg_get_serial_sequence` does return the implicit sequence for EF's identity columns (verified against the PostgreSQL docs), `pg_sequence_last_value` is NULL until the first insert, and the `AddBoards` migration inserts without an explicit id so it advances the sequence too; the `|| AnyAsync` guard covers explicit-id inserts. - **Coverage is complete.** Every URL writer/reader moved to keys; `data-board-id` → `data-board-key` with no JS reading the old attribute; `SearchDocument.BoardKey`/`BoardNameKey` rename avoids the name collision and `PassesFilters` uses the right one. Minor notes, none blocking: - `BoardKey.New()`'s random clock id collides 1/1024 per same-microsecond pair; the unique index turns that into a failed `CreateAsync` (generic error). Documented as deliberate and negligible. - The backfill uses `board.Id & 0x3FF` as the clock, so two boards created in the same microsecond with ids 1024 apart would collide — the unique index catches it as a boot error. Astronomically unlikely; the doc comment's "still differ" claim has this one congruence exception. - `ForYouService`'s `board?.Key ?? ""` yields a broken `/board/?card=N` link if the board vanishes between the two passes, where the old id link at least reached the empty state. Vanishingly rare edge. - Dropping the `:int` route constraint means `/board/abc` now renders the "missing" empty state (200) rather than a 404 — consistent with the design's "Missing without a query" and with the existing behaviour for absent ids, just noting the change. --- --- ⏱ 20m 38s · 15 steps · $0.1475 · tokens: 660.6k in / 61.5k out (+587.1k cache-read) · Go: 5h 16% · wk 6% · mo 36% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-213/run-2026-09-22T19-42-37-081Z.md) <!-- opencode-reviewed-head:d3ad6b98cb58f8aafdce052ae7e759e010cd43b2 --> <!-- opencode-verdict:approve -->
Grandiras force-pushed issue-197-stable-board-keys from d3ad6b98cb
All checks were successful
CI / build (pull_request) Successful in 47s
to f48f8620b7
All checks were successful
CI / build (pull_request) Successful in 41s
2026-09-22 20:18:57 +00:00
Compare
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
Author
Owner

@opencode-bot /review — the review started at 20:19 UTC for the rebased head (f48f862) never posted a verdict.

@opencode-bot /review — the review started at 20:19 UTC for the rebased head (f48f862) never posted a verdict.
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review of the updated head: everything I verified last pass still holds, and the new commits only tighten naming (ShareTarget.BoardRef) and test coverage — no new issues found.

Still correct:

  • TID encoding is spec-faithful. BoardKey.From masks µs to 53 bits, shifts 10, ORs a 10-bit clock; 13 base32-sortable chars, first char limited to the first sixteen letters; zero is 2222222222222. BoardKeyTests pins exact values including the one-microsecond step and rejects sign/separator/overflow id shapes.
  • The access rule holds. MoveToKey navigates only for a resolved Found board; denied/missing keep the id in the address bar (pinned by access.spec.js), and the .ics endpoints read ids without redirecting. ResolveManyAsync handles mixed key/id lists, normalises 007, and dedups a board named both ways.
  • The ?card= interplay is right: when MoveToKey returns true the shared-card open is left to the keyed navigation's parameter set, and WithBoardKey preserves the rest of the path and query.
  • Migration/rollback story is sound: nullable unique-indexed column (Postgres allows many NULLs), no SQL backfill, EnsureBoardKeysAsync on every boot before anything serves; SeedDefaultBoardAsync's "never had a board" rule via pg_sequence_last_value is correct for EF identity columns.

Remaining minor notes, all documented in-code and none blocking: BoardKey.New()'s random clock id collides 1/1024 per same-microsecond pair (unique index turns it into a failed create); the backfill's id & 0x3FF clock has a congruence exception to the "still differ" claim; ForYouService's board?.Key ?? "" yields a broken link if the board vanishes between passes; dropping the :int route constraint means /board/abc renders the missing empty state (200) rather than a 404, consistent with the design.



⏱ 1m 27s · 13 steps · $0.0434 · tokens: 192.9k in / 2.1k out (+447.5k cache-read) · Go: 5h 26% · wk 17% · mo 41%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review of the updated head: everything I verified last pass still holds, and the new commits only tighten naming (`ShareTarget.BoardRef`) and test coverage — no new issues found. Still correct: - **TID encoding is spec-faithful.** `BoardKey.From` masks µs to 53 bits, shifts 10, ORs a 10-bit clock; 13 base32-sortable chars, first char limited to the first sixteen letters; zero is `2222222222222`. `BoardKeyTests` pins exact values including the one-microsecond step and rejects sign/separator/overflow id shapes. - **The access rule holds.** `MoveToKey` navigates only for a resolved `Found` board; denied/missing keep the id in the address bar (pinned by `access.spec.js`), and the `.ics` endpoints read ids without redirecting. `ResolveManyAsync` handles mixed key/id lists, normalises `007`, and dedups a board named both ways. - **The `?card=` interplay is right**: when `MoveToKey` returns true the shared-card open is left to the keyed navigation's parameter set, and `WithBoardKey` preserves the rest of the path and query. - **Migration/rollback story is sound**: nullable unique-indexed column (Postgres allows many NULLs), no SQL backfill, `EnsureBoardKeysAsync` on every boot before anything serves; `SeedDefaultBoardAsync`'s "never had a board" rule via `pg_sequence_last_value` is correct for EF identity columns. Remaining minor notes, all documented in-code and none blocking: `BoardKey.New()`'s random clock id collides 1/1024 per same-microsecond pair (unique index turns it into a failed create); the backfill's `id & 0x3FF` clock has a congruence exception to the "still differ" claim; `ForYouService`'s `board?.Key ?? ""` yields a broken link if the board vanishes between passes; dropping the `:int` route constraint means `/board/abc` renders the missing empty state (200) rather than a 404, consistent with the design. --- --- ⏱ 1m 27s · 13 steps · $0.0434 · tokens: 192.9k in / 2.1k out (+447.5k cache-read) · Go: 5h 26% · wk 17% · mo 41% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-213/run-2026-09-22T20-45-24-863Z.md) <!-- opencode-reviewed-head:f48f8620b79ac0576e5f52e8b3cf2d83107c377e --> <!-- opencode-verdict:approve -->
Grandiras deleted branch issue-197-stable-board-keys 2026-09-22 20:47:55 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!213
No description provided.