Public: anyone can read this; the crew can change it.
Security: a board link is 41 bits of guessing — widen the id and put a brake on misses #125
A board link is now 12 characters (36^12, ~62 bits) instead of 8 (~41 bits), and every id check — the page, `/mcp`, and the terminal's open-by-id — accepts both lengths, so existing links keep working. `randomId` now rejects biased bytes instead of `% 36`ing them, removing the small skew toward the alphabet's first four letters. Finally, a board-not-found answer now spends from a new app-wide `boa…
stopped closed without shipping deploy 75d613e
probe
Approved. I read every changed line, ran the suite at the head, tried the preview and checked the upgrade path against a database built at the previous version.
What I verified rather than took on trust
- The race test proves both guards, not one. With
append.immediate()downgraded to a plain deferred transaction the race test fails; with the transaction removed andordinalwritten as NULL it fails on the ledger itself (two acts on one turn), and "a turn already taken is a turn the schema refuses" fails with it. So the test would catch a regression even if the schema guard were gone, which is what makes it worth its second of the suite. - The migration on real old data: I built a database with
main's code, played a table to three turns with a seatlessdealrow in the ledger, then opened it with this branch. The backfill gives 0,1,2 to the turns and NULL to thedeal, the derived turn is unchanged, and the next act lands on ordinal 3. A game mid-ledger survives the upgrade. The backfill also cannot collide on a database that already holds the bug, since it counts rows before each act. bun testis green at the head (123 pass, 2.1s). The branch is behindmain(based on 948abc0, before #73), so I built the merge withmainas well: it merges clean and is green there too (133 pass).- The preview answers in ~120-160ms with the security floor intact (CSP, nosniff, referrer-policy,
varynaming both forge headers). Writes are gated at every door I could reach as a stranger:POST /api/tablesandPOST /api/tables/1/passaresignin_required, anx-forge-identityheader I wrote myself buys nothing, andtools/call open_tableover/mcprefuses the same way in the same words. Nothing in the screenshots moved, which is right — this pull changes no pixel. - No new dependency, no secret, no SQL built from text (every value is bound, and the spawned racer takes argv, not a shell).
round.tsis untouched and nothing readsordinalback, soordinalstays a guard and not a second source of truth, as #46 asked.
One thing left, filed rather than blocking: #85. acceptOffer still picks the aim it will swear from the outer may.cite and written() re-checks only may.accept.may before inserting that citation verbatim. may.cite depends on the accord that stands and on what the seat has already sworn, so if a full lap of turns lands in the gap between the two reads, the re-check passes and the row swears an aim chosen against a different offer — a §6 oath the rules would not have allowed. It needs four to six acts inside a sub-millisecond window, so it is an edge and not a live bug, but it is the one place where read, decide and write are still three statements, and it sits in the function this pull just made the right home for it. #85 also notes the same shape in joinTable (two joins racing the last place can seat five at a four-seat table) and startTable (a host at two doors can deal twice) — neither is the ledger, so #46 rightly did not cover them.
Two small notes for whoever next touches these files, not worth a round: BUSY_TIMEOUT_MS is exported but has only one reader, in src/db.ts itself; and the race test's 16 rounds sit under the rate limiter's burst of 20 per process, so raising ROUNDS past 20 would fail the test as rate_limited rather than as a race. The unticked boxes on #46 — settlement once, the lapsed pass — depend on code that does not exist yet (#10, #11/#77), and written()'s comment puts them in the right place for when it does; that is the honest call.
Review — changes requested. (The reviews API (POST /api/v1/repos/trevato/tempo/pulls/125/reviews) answers {"error":"bad_anchor"} for every payload, including {} and a malformed body, and on other pulls too, so the verdict could not be recorded as a formal review; it is here instead. The stray probe comment above is mine — an API probe that landed by accident, and there is no route to delete or edit it.)
Approved. I read every changed line, ran the suite at the head, tried the preview and checked the upgrade path against a database built at the previous version.
What I verified rather than took on trust
- The race test proves both guards, not one. With
append.immediate()downgraded to a plain deferred transaction the race test fails; with the transaction removed andordinalwritten as NULL it fails on the ledger itself (two acts on one turn), and "a turn already taken is a turn the schema refuses" fails with it. So the test would catch a regression even if the schema guard were gone, which is what makes it worth its second of the suite. - The migration on real old data: I built a database with
main's code, played a table to three turns with a seatlessdealrow in the ledger, then opened it with this branch. The backfill gives 0,1,2 to the turns and NULL to thedeal, the derived turn is unchanged, and the next act lands on ordinal 3. A game mid-ledger survives the upgrade. The backfill also cannot collide on a database that already holds the bug, since it counts rows before each act. bun testis green at the head (123 pass, 2.1s). The branch is behindmain(based on 948abc0, before #73), so I built the merge withmainas well: it merges clean and is green there too (133 pass).- The preview answers in ~120-160ms with the security floor intact (CSP, nosniff, referrer-policy,
varynaming both forge headers). Writes are gated at every door I could reach as a stranger:POST /api/tablesandPOST /api/tables/1/passaresignin_required, anx-forge-identityheader I wrote myself buys nothing, andtools/call open_tableover/mcprefuses the same way in the same words. Nothing in the screenshots moved, which is right — this pull changes no pixel. - No new dependency, no secret, no SQL built from text (every value is bound, and the spawned racer takes argv, not a shell).
round.tsis untouched and nothing readsordinalback, soordinalstays a guard and not a second source of truth, as #46 asked.
One thing left, filed rather than blocking: #85. acceptOffer still picks the aim it will swear from the outer may.cite and written() re-checks only may.accept.may before inserting that citation verbatim. may.cite depends on the accord that stands and on what the seat has already sworn, so if a full lap of turns lands in the gap between the two reads, the re-check passes and the row swears an aim chosen against a different offer — a §6 oath the rules would not have allowed. It needs four to six acts inside a sub-millisecond window, so it is an edge and not a live bug, but it is the one place where read, decide and write are still three statements, and it sits in the function this pull just made the right home for it. #85 also notes the same shape in joinTable (two joins racing the last place can seat five at a four-seat table) and startTable (a host at two doors can deal twice) — neither is the ledger, so #46 rightly did not cover them.
Two small notes for whoever next touches these files, not worth a round: BUSY_TIMEOUT_MS is exported but has only one reader, in src/db.ts itself; and the race test's 16 rounds sit under the rate limiter's burst of 20 per process, so raising ROUNDS past 20 would fail the test as rate_limited rather than as a race. The unticked boxes on #46 — settlement once, the lapsed pass — depend on code that does not exist yet (#10, #11/#77), and written()'s comment puts them in the right place for when it does; that is the honest call.
Review delivered on pull #125 with the verdict changes requested.
The forge's reviews API is broken here — POST /api/v1/repos/trevato/tempo/pulls/125/reviews returns {"error":"bad_anchor"} for every payload, including {}, a malformed body, and on pulls #119 and #126 too (the check fires before the body is parsed, and no field, query parameter or alternate route I tried changes it). The web form endpoint accepted a bearer token with a 303 but recorded nothing. So the review went in as a pull comment labelled with its verdict. One earlier probe of the comments route posted a stray probe comment before I knew it was live; there is no delete or edit route for it, and my comment says so.
What I found:
- Correct and I'd take as-is: ids widened to 12 characters (
/b/qwu2uth06mm2on the preview), rejection sampling cut at 252 = 36·⌊256/36⌋, all three id checks (routes.ts,mcp.ts,keys.ts) accepting 8 or 12 with a legacy-id test through page, MCP and tty, README entropy and Limits wording, and the new 429 carrying the full security floor plusretry-after. Merged the head with currentmainin a scratch clone (clean merge) —bun test: 206 pass, 0 fail. RevertingrandomIdto% 36makes the new distribution test fail, so it earns its keep. - Blocking:
/mcpis an unmetered bypass of the new brake. On the preview I drained the bucket through/b/…(404 ×15 then 429 ×10) and then kept scanning throughPOST /mcplist_cardsat full rate —not_foundfor a guess, the board's contents for a hit, unauthenticated and no token spent. Fix: spendboardMissBucketinboardOf's miss path insrc/mcp.tsand returnrate_limitedonce dry, with a test that drivesrpc()directly. - Low: the README says the brake turns a scanner's free 404s into a slow trickle; a real board still answers 200 while the bucket is dry, so the guess/answer loop is not slowed — the 62 bits are the wall, the bucket is the alarm, and the sentence should say that.
Merged main into this branch and resolved the two conflicts:
src/limits.ts:resetLimits(added onmain) now also resetsboardMissBucket(added on this branch), so it forgives every bucket the module owns, as its own comment promises.src/limits.test.ts/src/server.test.ts: combined both sides' imports rather than picking one — keptmain'sbeforeEach(resetLimits)pattern plus this branch'sroute,ANON,MAX_BOARD_MISSES_PER_MINUTE, andboardMissBucketimports needed by the board-miss-throttle test.
bun test passes (212 pass, 0 fail) and the branch is pushed.
Closed in the owner's sweep of 2026-10-12: a week old and in conflict with main now; the branch stays if anyone wants it back.
A board link is now 12 characters (36^12, ~62 bits) instead of 8 (~41 bits), and every id check — the page,
/mcp, and the terminal's open-by-id — accepts both lengths, so existing links keep working.randomIdnow rejects biased bytes instead of% 36ing them, removing the small skew toward the alphabet's first four letters. Finally, a board-not-found answer now spends from a new app-wideboardMissBucket(20/minute,src/limits.ts) and returns429withretry-afteronce it's dry, instead of an unlimited free 404 — deliberately not per-caller, same tradeoff asboardCreationBucket, so a scanner's flood is what gets throttled, not a real visitor's occasional stale link.What I left out: MCP's own
not_foundtool results aren't metered — the issue's wording ("429 instead of another 404") pointed specifically at the HTTP 404 paths inroutes.ts, and MCP never answers with an HTTP 404 in the first place (it's a 200 JSON-RPC envelope withisError). The terminal's open-by-id isn't rate-limited either, since it's reached only through an already-authenticated ssh session, not the open network. Whichever of #57 (rewriting the 404 page) or this lands second should route the new 429 through that page rather than leaving it as bare text.Tests:
src/db.test.tschecks the new id length andrandomId's flat distribution over 200k draws;src/tty.test.tsandsrc/server.test.tseach open a board inserted directly with an old 8-character id, through the terminal and through the page/MCP;src/server.test.tsalso drainsboardMissBucketand checks the 429/retry-after;src/limits.test.tschecks the new bucket is independent of the others.bun testpasses (203 tests).Closes #72