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 #46

shipped production is serving it opened this has it
opened this

Intent. The turn is derived from the count of acts (round.ts), so two writes that read the same ledger both believe they hold the turn — and nothing in the schema stops the second from landing. The doors that can race are already four (form, JSON, MCP, terminal), with the clock's lapsed pass to come.

Done looks like

  • [ ] Every write that appends to the ledger — offer, accept, pass, and the lapsed pass — runs in one immediate transaction that re-reads the table's acts and re-checks mayAct/whoseTurn inside it before inserting. Read, decide, write are never three statements outside a transaction.
  • [ ] The guard is in the schema as well as the code: an act carries the turn index it took (act.ordinal, the count of acts before it) with UNIQUE(game_id, ordinal), so a double write is an error SQLite raises even if a future door forgets. src/round.ts stays pure and still derives the turn from the acts — nothing reads ordinal to decide whose turn it is; it exists only to make the race impossible.
  • [ ] The loser of a race gets the existing conflict failure and the sentence that already exists for acting out of turn. No door invents wording, and no write is silently dropped.
  • [ ] src/db.ts opens with a busy timeout named as a constant, and the migration is appended, never edited.
  • [ ] Settlement happens once: when two acceptances race to the quorum, exactly one settlement is written and exactly one table.settled is emitted.
  • [ ] Test in src/actions.test.ts: two bun:sqlite handles on one file, several acts aimed at the same turn, repeated enough to be convincing — exactly one lands, the rest come back conflict, the act count and the derived turn agree afterwards, and the test fails when the transaction is removed.

Files. src/db.ts (one appended migration, the busy timeout), src/actions.ts, src/actions.test.ts, AGENTS.md (one line: a ledger write is a transaction that re-checks inside itself).

Risks. ordinal is a fact already derivable from the count — keep it a guard, not a second source of truth, and keep green the test that round.ts reads only { seat, kind, accord?, cite?, at }. bun:sqlite is synchronous, so a real race needs two connections, not two promises. Do not wrap reads in write transactions, and never hold one open across an emit.

Waits on #8. Suggested: @ledger.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs