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

mason/parley:mason/46-two-acts-cannot-take-one-turn-the-ledger → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/46-two-acts-cannot-take-one-turn-the-ledger was merged into main as 09d260d.

Review

Sign in to review this change.
6 files changed +297 −36
AGENTS.md +6 −1
@@ -54,7 +54,12 @@ whether it binds, what a seat may do and what the settlement is out — so an
5454 action that plays a turn is a transaction, not a rules engine. Rules of that
5555 kind go in a pure module an action calls; both speak the same named
5656 failures, from `src/result.ts`, and `src/example.ts` is the worked table of
57 `RULES.md` §10 that both are tested against.
57 `RULES.md` §10 that both are tested against. A ledger write is a
58 transaction that re-checks inside itself: it opens immediate, re-reads the
59 acts, asks the rules again about the ledger as it actually is, and only
60 then inserts — read, decide and write are never three statements, the row
61 carries the turn it took (`act.ordinal`, unique per table) so a door that
62 forgets is refused by SQLite, and `emit` is never called with one open.
5863
5964 Identity comes from the forge (`src/who.ts`): `anon`, `user` or `admin`,
6065 verified by signature, never by a cookie the app invents. An action that
README.md +7
@@ -129,6 +129,13 @@ and its acceptances are the `accept` acts after it. A new offer therefore
129129 clears every acceptance by existing, and there is no pointer or tally that
130130 can fall out of step with the record.
131131
132 Because the turn is a count of that ledger, two doors of one seat could
133 read the same count at the same instant and both believe they hold the
134 turn. So the append is one immediate transaction that re-reads the acts and
135 asks the rules again inside itself, and the row it writes carries the turn
136 it took, unique per table: the second writer of a turn is refused, told
137 plainly that it is not their turn, and nothing it sent is half written.
138
132139 A table today is opened, sat at, left, started and played. Scoring the
133140 accord that reaches quorum, the reveal and the clock that passes for a seat
134141 that never played are still to come; until then a table that binds stops
src/actions.test.ts +147 −2
@@ -1,6 +1,6 @@
11 import { expect, test } from "bun:test";
22 import type { Database } from "bun:sqlite";
3 import { mkdtempSync } from "node:fs";
3 import { mkdtempSync, writeFileSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import {
@@ -24,7 +24,8 @@ import { a1, a2, CHARTER, charges } from "./example.ts";
2424 import { subscribe } from "./live.ts";
2525 import type { Who } from "./who.ts";
2626
27 const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-")));
27 const dir = mkdtempSync(join(tmpdir(), "app-actions-"));
28 const db = openDb(dir);
2829 const anon: Who = { role: "anon" };
2930 const alice: Who = { role: "user", id: "u1", name: "alice" };
3031 const bob: Who = { role: "user", id: "u2", name: "bob" };
@@ -700,6 +701,150 @@ test("a citation is the seat's choice among what §6 allows, and nothing else",
700701 });
701702 });
702703
704 /**
705 * One door, in its own process, taking one turn per tick of a clock every
706 * other door is watching. `bun:sqlite` is synchronous, so two promises are
707 * not a race and two handles in one process are not either: only two
708 * processes on one file can read the same ledger at the same instant.
709 *
710 * It reports what came back, in the action's own words, and never what it
711 * decided itself.
712 */
713 const RACER = `
714 import { openDb } from ${JSON.stringify(join(import.meta.dir, "db.ts"))};
715 import { passTurn } from ${JSON.stringify(join(import.meta.dir, "actions.ts"))};
716
717 const [dir, table, id, name, from, rounds, spacing] = process.argv.slice(2);
718 const db = openDb(dir);
719 const who = { role: "user", id, name };
720 const said = [];
721 for (let round = 0; round < Number(rounds); round++) {
722 const tick = Number(from) + round * Number(spacing);
723 await Bun.sleep(Math.max(0, tick - Date.now()));
724 try {
725 const act = passTurn(db, who, { id: Number(table) });
726 said.push(act.ok ? "ok" : act.error + ": " + act.message);
727 } catch (error) {
728 said.push("threw: " + (error instanceof Error ? error.message : error));
729 }
730 }
731 console.log(JSON.stringify(said));
732 `;
733
734 test(
735 "two acts cannot take one turn: eight doors on one file, and one act a turn",
736 async () => {
737 const id = table10();
738 const seats = order(id);
739 // One attempt per door per round, and a door's ration (`allow`, a burst
740 // of 20 to a process) is spent on attempts whether they land or not: a
741 // round count past that would come back `rate_limited` and read like a
742 // race that never happened. More contention belongs in more doors.
743 const ROUNDS = 16;
744 const SPACING_MS = 20;
745 const script = join(dir, "racer.ts");
746 writeFileSync(script, RACER);
747
748 // Two processes per seat — one person at two doors at once, which is the
749 // only way two writes can both believe they hold the same turn — all
750 // firing on the same tick, sixteen times over.
751 const doors = [alice, alice, bob, bob, carol, carol, dave, dave];
752 const from = Date.now() + 700;
753 const outcomes = await Promise.all(
754 doors.map(async (player) => {
755 if (player.role === "anon") throw new Error("a racer holds a seat");
756 const child = Bun.spawn(
757 [
758 process.execPath,
759 "run",
760 script,
761 dir,
762 String(id),
763 player.id,
764 player.name,
765 String(from),
766 String(ROUNDS),
767 String(SPACING_MS),
768 ],
769 { stdout: "pipe", stderr: "pipe" },
770 );
771 const [out, err] = await Promise.all([
772 new Response(child.stdout).text(),
773 new Response(child.stderr).text(),
774 ]);
775 await child.exited;
776 if (child.exitCode !== 0)
777 throw new Error(`the ${player.name} door died: ${err}`);
778 return JSON.parse(out) as string[];
779 }),
780 );
781
782 // Every attempt was answered, and the loser of a race is told the one
783 // thing there is to tell it: the turn was not theirs. No door invents
784 // wording, nothing throws, and nothing comes back unanswered.
785 const said = outcomes.flat();
786 expect(said.length).toBe(doors.length * ROUNDS);
787 expect(new Set(said)).toEqual(
788 new Set(["ok", "conflict: It is not your turn."]),
789 );
790 const landed = said.filter((one) => one === "ok").length;
791
792 // The ledger agrees, act for act: every write that said it landed is a
793 // row, every row took the turn it was told it held, and the turn each
794 // took is the seat the arithmetic says had it.
795 const rows = db
796 .query<
797 { ordinal: number | null; seat_id: number },
798 [number]
799 >("SELECT ordinal, seat_id FROM act WHERE game_id = ?1 ORDER BY id")
800 .all(id);
801 expect(rows.length).toBe(landed);
802 expect(landed).toBeGreaterThan(3);
803 expect(rows.map((row) => row.ordinal)).toEqual(rows.map((_, turn) => turn));
804 expect(rows.map((row) => row.seat_id)).toEqual(
805 rows.map((_, turn) => seats[turn % seats.length]!),
806 );
807 const after = at(id, anon);
808 expect(after.acts.length).toBe(rows.length);
809 expect(after.turn).toBe(seats[rows.length % seats.length]!);
810 },
811 20_000,
812 );
813
814 test("a turn already taken is a turn the schema refuses", () => {
815 const id = table10();
816 expect(passTurn(db, alice, { id }).ok).toBe(true);
817 const taken = db
818 .query<{ seat_id: number }, [number]>(
819 "SELECT seat_id FROM act WHERE game_id = ?1",
820 )
821 .get(id)!;
822 // The guard under the transaction: a door that one day reads, decides and
823 // writes without one still cannot put two acts on turn 0.
824 expect(() =>
825 db
826 .query(
827 "INSERT INTO act (game_id, seat_id, kind, ordinal) VALUES (?1, ?2, 'pass', 0)",
828 )
829 .run(id, taken.seat_id),
830 ).toThrow();
831 // It is the turn that is unique, and only for a turn: the acts a table
832 // makes for itself take none and are not counted.
833 expect(() =>
834 db
835 .query("INSERT INTO act (game_id, kind, ordinal) VALUES (?1, 'deal', NULL)")
836 .run(id),
837 ).not.toThrow();
838 expect(() =>
839 db
840 .query("INSERT INTO act (game_id, kind, ordinal) VALUES (?1, 'deal', NULL)")
841 .run(id),
842 ).not.toThrow();
843 // And nothing reads it: the turn is still the count of the acts that are
844 // turns, which the two seatless rows are not.
845 expect(at(id, anon).turn).toBe(order(id)[1]!);
846 });
847
703848 test("the schema keeps the promises the actions rest on", () => {
704849 const table = open(alice, { seats_max: 4 });
705850 const seats = (db as Database)
src/actions.ts +102 −29
@@ -323,12 +323,8 @@ export function leaveTable(
323323 if (game === undefined) return fail("not_found", "No such table.");
324324 if (game.status !== "open")
325325 return fail("conflict", "A seat is fixed once the table starts.");
326 const seat = db
327 .query<SeatRow, [number, string]>(
328 "SELECT * FROM seat WHERE game_id = ?1 AND player_id = ?2",
329 )
330 .get(game.id, who.id);
331 if (seat === null)
326 const seat = seatOf(db, game.id, who.id);
327 if (seat === undefined)
332328 return fail("not_found", "You do not hold a seat at this table.");
333329 db.query("DELETE FROM seat WHERE id = ?1").run(seat.id);
334330 const state = view(db, game, who);
@@ -504,22 +500,19 @@ interface Taking {
504500 * seat of your own, a rate you could have taken by hand, and a charge to
505501 * play it with. What happens next is `src/round.ts`'s to say, and `may`
506502 * carries its answer and its words.
503 *
504 * What it answers is true of the instant it read, and the instant is gone by
505 * the time the caller has shaped an accord: it is the door's judgement, not
506 * the ledger's. The judgement that decides is `judged`, again, inside the
507 * transaction that writes.
507508 */
508509 function taking(db: Database, who: Who, id: unknown): Result<Taking> {
509510 if (who.role === "anon") return fail("signin_required", "Sign in to play.");
510511 const game = find(db, id);
511512 if (game === undefined) return fail("not_found", "No such table.");
512 if (game.status !== "playing")
513 return fail(
514 "conflict",
515 game.status === "open"
516 ? "This table has not started."
517 : game.status === "settled"
518 ? "This table has settled."
519 : "This table was impounded.",
520 );
521 const seats = seatRows(db, game.id);
522 const seat = seats.find((one) => one.player_id === who.id);
513 const stopped = notPlaying(game);
514 if (stopped !== null) return fail("conflict", stopped);
515 const seat = seatOf(db, game.id, who.id);
523516 if (seat === undefined)
524517 return fail("forbidden", "You hold no seat at this table.");
525518 // Rationed per seat per table, here rather than at one door: the terminal
@@ -527,11 +520,23 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> {
527520 // agent on its turn should not be able to spend it faster than it thought.
528521 if (!allow(`act:${game.id}:${who.id}`))
529522 return fail("rate_limited", "That was too fast. Take your turn again in a moment.");
523 return judged(db, game, seat);
524 }
525
526 /**
527 * What the rules say about this seat, from the ledger as it stands at this
528 * instant: the acts re-read, the seat's own charge, and §5 and §6's answer.
529 *
530 * It reads and decides and writes nothing, so it is safe to ask twice — and
531 * it is asked twice: once at the door, and once more inside the write.
532 */
533 function judged(db: Database, game: GameRow, seat: SeatRow): Result<Taking> {
530534 const held = parse(seat.charge_json) as Charge | null;
531535 if (held === null || !Array.isArray(held.aims))
532536 return fail("conflict", "This table has not been dealt.");
533537 // The seat's own charge, under its own ordinal: §6 reads nobody else's.
534538 const charge: Charge = { seat: seat.ordinal, aims: held.aims };
539 const seats = seatRows(db, game.id);
535540 const round = roundOf(game, seats, actRows(db, game.id));
536541 return ok({
537542 game,
@@ -541,7 +546,40 @@ function taking(db: Database, who: Who, id: unknown): Result<Taking> {
541546 });
542547 }
543548
544 /** One act, one row, and the two things every open page is told about it. */
549 /** Why this table cannot be played right now, in its own words, or null. */
550 const notPlaying = (game: GameRow): string | null =>
551 game.status === "playing"
552 ? null
553 : game.status === "open"
554 ? "This table has not started."
555 : game.status === "settled"
556 ? "This table has settled."
557 : "This table was impounded.";
558
559 /**
560 * One act, one row — appended in the one transaction that may.
561 *
562 * The turn is the count of acts (`src/round.ts`), so a decision made outside
563 * a write lock is a decision about a ledger that can grow under it: two
564 * doors of one seat, reading the same count, would both believe they hold
565 * the turn. So read, decide and write are one statement here. The
566 * transaction is *immediate* — the write lock is taken before the first
567 * read, not upgraded after it — the acts are re-read inside it, and §5 and
568 * §6 are asked again about the ledger as it actually is. The loser of the
569 * race finds the turn moved on and gets `round.ts`'s own sentence for it;
570 * nothing it sent is silently dropped, and nothing it sent is written.
571 *
572 * The row carries the turn it took (`act.ordinal`), which the schema holds
573 * unique per table: the same refusal again, in SQLite's hand, for a door
574 * that one day forgets this one. Nothing reads it back.
575 *
576 * Nothing that is not the write happens in here — the state a door answers
577 * with is read after the commit, and `emit` is never called with a
578 * transaction open. A write that follows from an act (the settlement an
579 * acceptance reaches the quorum with, when there is one) belongs in here
580 * too, after this row: one act lands, so it happens once and is announced
581 * once.
582 */
545583 function written(
546584 db: Database,
547585 game: GameRow,
@@ -551,17 +589,40 @@ function written(
551589 accord: Accord | null,
552590 cite: Cite | null,
553591 ): Result<TableView> {
554 db.query<ActRow, [number, number, string, string | null, string | null]>(
555 `INSERT INTO act (game_id, seat_id, kind, accord_json, cite_json)
556 VALUES (?1, ?2, ?3, ?4, ?5)`,
557 ).run(
558 game.id,
559 seat.id,
560 kind,
561 accord === null ? null : JSON.stringify(accord),
562 cite === null ? null : JSON.stringify(cite),
563 );
564 const state = view(db, game, who);
592 const append = db.transaction((): Result<GameRow> => {
593 const fresh = find(db, game.id);
594 if (fresh === undefined) return fail("not_found", "No such table.");
595 const stopped = notPlaying(fresh);
596 if (stopped !== null) return fail("conflict", stopped);
597 const still = seatOf(db, fresh.id, who.id);
598 if (still === undefined)
599 return fail("forbidden", "You hold no seat at this table.");
600 const again = judged(db, fresh, still);
601 if (!again.ok) return again;
602 const { may, round } = again.value;
603 if (!may[kind].may) return fail("conflict", may[kind].refusal!);
604 db.query<
605 ActRow,
606 [number, number, string, string | null, string | null, number]
607 >(
608 `INSERT INTO act (game_id, seat_id, kind, accord_json, cite_json, ordinal)
609 VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
610 ).run(
611 fresh.id,
612 still.id,
613 kind,
614 accord === null ? null : JSON.stringify(accord),
615 cite === null ? null : JSON.stringify(cite),
616 round.acts.length,
617 );
618 return ok(fresh);
619 });
620 const appended = append.immediate();

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 and ordinal written 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 seatless deal row in the ledger, then opened it with this branch. The 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. 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 test is green at the head (123 pass, 2.1s). The branch is behind main (based on 948abc0, before #73), so I built the merge with main as 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, vary naming both forge headers). Writes are gated at every door I could reach as a stranger: POST /api/tables and POST /api/tables/1/pass are signin_required, an x-forge-identity header I wrote myself buys nothing, and tools/call open_table over /mcp refuses 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.ts is untouched and nothing reads ordinal back, so ordinal stays 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.

621 if (!appended.ok) return appended;
622 // The table as it was when the row went in, read back after the commit:
623 // a door answers with the state its own write made, not with the row it
624 // was holding before it.
625 const state = view(db, appended.value, who);
565626 // Both events name the table and the seat and say nothing else: what the
566627 // act was, and whose turn it is now, are the viewer's to refetch — and the
567628 // turn is nobody's once an accord binds, which is not an address at all.
@@ -748,6 +809,18 @@ const actRows = (db: Database, gameId: number): ActRow[] =>
748809 .query<ActRow, [number]>("SELECT * FROM act WHERE game_id = ?1 ORDER BY id")
749810 .all(gameId);
750811
812 /** The seat one person holds at one table, or nothing. */
813 const seatOf = (
814 db: Database,
815 gameId: number,
816 playerId: string,
817 ): SeatRow | undefined =>
818 db
819 .query<SeatRow, [number, string]>(
820 "SELECT * FROM seat WHERE game_id = ?1 AND player_id = ?2",
821 )
822 .get(gameId, playerId) ?? undefined;
823
751824 const seatRows = (db: Database, gameId: number): SeatRow[] =>
752825 db
753826 .query<SeatRow, [number]>(
src/db.test.ts +7 −1
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { check, migrate, migrations, openDb } from "./db.ts";
5 import { BUSY_TIMEOUT_MS, check, migrate, migrations, openDb } from "./db.ts";
66
77 test("migrations apply once, in order, and the file checks out", () => {
88 const dir = mkdtempSync(join(tmpdir(), "app-db-"));
@@ -20,4 +20,10 @@ test("migrations apply once, in order, and the file checks out", () => {
2020 db.query<{ foreign_keys: number }, []>("PRAGMA foreign_keys").get()
2121 ?.foreign_keys,
2222 ).toBe(1);
23 // A second writer waits for the writer ahead of it rather than failing:
24 // the terminal is another process on this file, and a ledger write takes
25 // the lock before it reads.
26 expect(
27 db.query<{ timeout: number }, []>("PRAGMA busy_timeout").get()?.timeout,
28 ).toBe(BUSY_TIMEOUT_MS);
2329 });
src/db.ts +28 −3
@@ -1,8 +1,9 @@
11 /**
22 * The database: one SQLite file under FORGE_APP_DATA, so production and
33 * every preview each have their own. WAL so readers never wait on the
4 * writer (the terminal is a second process on the same file), foreign keys
5 * on, and a forward-only list of migrations applied once, in a
4 * writer (the terminal is a second process on the same file), a busy
5 * timeout so a writer waits for the writer ahead of it instead of failing,
6 * foreign keys on, and a forward-only list of migrations applied once, in a
67 * transaction, when the file is opened.
78 *
89 * bun run src/db.ts --check the schema version and an integrity check
@@ -91,14 +92,38 @@ export const migrations: string[] = [
9192 // verified against the accepting seat's charge before the row was written,
9293 // and public forever after. Null on an offer and on a pass.
9394 `ALTER TABLE act ADD COLUMN cite_json TEXT`,
95
96 // The turn this act took: the count of turns before it, which is exactly
97 // the number `src/round.ts` divides by the seat count to say whose turn it
98 // is. Two acts cannot take one turn, and now that is a rule SQLite keeps
99 // rather than a rule the code remembers — the second writer of a turn gets
100 // an error even if some future door forgets the transaction. It is a guard
101 // and not a second source of truth: nothing reads it to decide anything,
102 // and the turn is still derived from the acts. Null on an act the table
103 // made for itself (a deal, an impound), which is not a turn.
104 `ALTER TABLE act ADD COLUMN ordinal INTEGER`,
105 `UPDATE act SET ordinal = (
106 SELECT count(*) FROM act AS before
107 WHERE before.game_id = act.game_id AND before.id < act.id
108 AND before.seat_id IS NOT NULL AND before.kind IN ('offer', 'accept', 'pass')
109 ) WHERE seat_id IS NOT NULL AND kind IN ('offer', 'accept', 'pass')`,
110 `CREATE UNIQUE INDEX act_turn ON act (game_id, ordinal) WHERE ordinal IS NOT NULL`,
94111 ];
95112
113 /**
114 * How long a connection waits for the writer ahead of it before it gives up
115 * (`SQLITE_BUSY`). A ledger write is one short immediate transaction, so a
116 * wait here is microseconds of queueing; five seconds is the outside edge of
117 * a stalled machine, not a budget anything spends.
118 */
119 export const BUSY_TIMEOUT_MS = 5_000;
120
96121 export function openDb(dir = dataDir): Database {
97122 mkdirSync(dir, { recursive: true });
98123 const db = new Database(join(dir, "app.db"));
99124 db.exec("PRAGMA journal_mode = WAL");
100125 db.exec("PRAGMA foreign_keys = ON");
101 db.exec("PRAGMA busy_timeout = 5000");
126 db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
102127 migrate(db);
103128 return db;
104129 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs