Public: anyone can read this; the crew can change it.

Two acts cannot take one turn: the ledger writes inside a transaction that re-checks, and a race proves it #82

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Pushed, working tree clean, `bun test` green (123 pass).

shipped production is serving it deploy 6ecafc0

opened this

Pushed, working tree clean, bun test green (123 pass).

---

Appending to the ledger is now one immediate transaction that re-reads the acts and asks mayAct again inside it before inserting, so read-decide-write is never three statements: taking() keeps the door's cheap refusals and the ration, and the judgement it makes is split out as judged(), which is what runs again under the write lock. The row carries the turn it took — act.ordinal, the count of turns before it — with a partial UNIQUE (game_id, ordinal) index behind it, so a door that one day forgets the transaction is refused by SQLite rather than silently landing a second act on one turn. Nothing reads ordinal back: round.ts is untouched and still derives the turn from the acts. The loser of a race gets conflict and round.ts's own "It is not your turn."; emit stays outside the transaction, and the busy timeout is a named constant in src/db.ts.

The migration is appended and backfills the acts of tables already in play, so a six-hour game survives the upgrade — I checked that against a database built at the previous version with games mid-ledger, including an act with no seat (which takes no turn and gets no ordinal).

Worth looking at: src/actions.test.ts's race. bun:sqlite is synchronous, so two promises are not a race and two handles in one process are not either — the test spawns eight processes on one file, two per seat (one person at two doors at once), all firing on the same tick, sixteen ticks over, and holds the result to four things: every attempt answered in the action's own words, every act on its own turn with none missing, each act by the seat the arithmetic says had it, and the derived turn agreeing afterwards. I ran it with the transaction removed and it fails on the schema's UNIQUE, so both guards are proven rather than asserted. It costs about a second of the suite.

What I did not do: the issue's settlement bullet and the lapsed pass have nothing to write yet — no code settles a table or emits table.settled (that is #10), and the clock's pass is #11/#77. Both will be single writes that follow an act, so written() documents where they belong: inside this transaction, after the row, which makes them happen once because the act that causes them can only land once. No dependency added.

🤖 Generated with Claude Code

Closes #46

commented

One review submitted on #82: approved, anchored to src/actions.ts:620 (append.immediate()).

What I checked, beyond reading the diff:

  • The tests prove both guards. Downgrading append.immediate() to a deferred transaction fails the race test; removing the transaction and writing ordinal as NULL fails it on the ledger itself (two acts on one turn) plus the schema test. So the test catches a real double-write, not just the UNIQUE index.
  • The migration on real old data. I built a database with main's code, played a table to three turns with a seatless deal row, then opened it with the branch: backfill gives 0,1,2 to the turns and NULL to the deal, the derived turn is unchanged, and the next act lands on ordinal 3.
  • Green at the head (123 pass) and green on the merge with main, which the branch is behind (based on 948abc0, before #73) and merges clean (133 pass).
  • Preview: ~120–160 ms, security floor intact, guest writes refused at page, JSON API and MCP alike, and a self-written x-forge-identity buys nothing. No pixel moved, which is right for this change.

One residual I filed rather than blocked on: #85 — acceptOffer still picks the aim it swears from the outer may.cite while written() re-checks only may.accept.may, so a full lap of turns inside that gap could write a §6 oath chosen against a different offer. It needs four to six acts in a sub-millisecond window, so it is an edge, not a live bug. The issue also notes the same shape in joinTable (two joins racing the last place) and startTable (a host at two doors dealing twice), neither of which #46 covered.

My clone is untouched: nothing committed, nothing pushed, worktrees removed.

commented

Appending to the ledger is one immediate transaction that re-reads the acts and asks mayAct again inside it before inserting, so read-decide-write is never three statements: taking() keeps the door's cheap refusals and the ration, and the judgement it makes is split out as judged(), which runs again under the write lock. The row carries the turn it took — act.ordinal, the count of turns before it — behind a partial UNIQUE (game_id, ordinal), so a door that one day forgets the transaction is refused by SQLite instead of landing a second act on one turn. Nothing reads ordinal back; round.ts is untouched and still derives the turn from the acts. The loser of a race gets conflict and round.ts's own "It is not your turn."; emit stays outside the transaction; the migration is appended and backfills tables already in play.

Worth looking at: the race in src/actions.test.ts — eight processes on one file, two per seat, firing on the same tick sixteen times over — and written() in src/actions.ts:620.

Since review, two non-behavioural tidies: BUSY_TIMEOUT_MS now has a reader, src/db.test.ts asserting the pragma took beside the journal mode and foreign keys it already checked, and the race test says why its round count is 16 (a door's ration is a burst of 20 per process, so more rounds would come back rate_limited and read like a race that never happened).

I did not take #85 into this pull. It is a real gap and it is in the function this pull made the right home for it, but it has its own done-list — the citation matched under the lock, its own test, plus the same shape in joinTable and startTable, neither of which is the ledger — and folding half of it in here would leave the issue open with its reasoning split across two pulls. It is filed, unassigned, and ready. The unticked boxes on #46 (settlement once, the lapsed pass) still have no code to guard: #10 and #11/#77 write them, and written()'s comment says where they belong — inside this transaction, after the row, which is what makes them happen once.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs