From 704fdd27e60b7dab1a932060032aa6b9ad45e922 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 10:58:40 +0000 Subject: [PATCH 01/13] Declare the impossible combinations of the payment machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row machine, the refund authority, and the delivery phase are each checked on their own; this seam says which combinations of the three may exist in one database at one moment. The declaration is an illegal list, each entry naming the invariant it breaks, and it stays short on purpose: a combination is listed only when no flow can produce it, because every crash window's intermediate state must stay legal for a redelivery to finish from it. The two declared entries catch the dangerous class — an armed provider send on a row nobody holds, and a held claim over references with no charge. The phase folds into the row fact, since any stored row state means the failure slot is set, so only a free row splits by reserved and finalized. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- src/shared/payment/joint-state.ts | 134 ++++++++++++++++++++++++ test/shared/payment/joint-state.test.ts | 114 ++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/shared/payment/joint-state.ts create mode 100644 test/shared/payment/joint-state.test.ts diff --git a/src/shared/payment/joint-state.ts b/src/shared/payment/joint-state.ts new file mode 100644 index 0000000000..fa097ca7da --- /dev/null +++ b/src/shared/payment/joint-state.ts @@ -0,0 +1,134 @@ +/** + * The legal combinations of one payment row's machines. A row's stored state + * (the row machine), its charges' refund authority (the refund machine), and + * its delivery phase are three tables checked one at a time — this module is + * the seam between them: which combinations may exist in one database at one + * moment. + * + * The declaration is an ILLEGAL list, each entry naming the invariant it + * breaks. A combination is only listed when no flow can produce it — every + * crash window's intermediate state is a legal combination by design, because + * a redelivery must be able to finish from it. Anything not listed is legal, + * so a new flow never trips this seam by surprise; the witness checks in the + * crash tests are what tighten the list over time. + * + * This module is pure. The phase collapses into the row fact: any stored row + * state means `failure_data` is set, so only a free row splits by phase + * (reserved in flight, finalized booked). + */ + +import { type ROW_NODES, rowNodeOf } from "#shared/payment/row-machine-spec.ts"; +import type { PaymentRowState } from "#shared/payment/row-state.ts"; + +/** One charge's refund authority as this seam sees it: its state name, or + * "absent" when the row's references have no charge at all. */ +export type AuthorityFact = + | "absent" + | "ready" + | "send_armed" + | "observing" + | "completed_due" + | "completed_recorded" + | "needs_owner_choice" + | "needs_provider_check"; + +/** One row as this seam sees it: its machine node, with the free node split + * by delivery phase — the only split the phase adds. */ +export type JointRowFact = + | "free_reserved" + | "free_finalized" + | Exclude<(typeof ROW_NODES)[number]["id"], "free">; + +/** Why a combination can never exist. Each reason is one invariant a flow + * relies on; the entry makes it checkable instead of implicit. */ +export interface IllegalJointState { + readonly authority: AuthorityFact; + readonly reason: string; + readonly rows: readonly JointRowFact[]; +} + +const NO_CLAIM_ROWS: readonly JointRowFact[] = [ + "free_reserved", + "free_finalized", + "review", + "unrecorded", + "review_unrecorded", + "settled", +]; + +const CLAIM_ROWS: readonly JointRowFact[] = [ + "claim", + "claim_review", + "claim_unrecorded", + "claim_review_unrecorded", +]; + +/** + * The declared impossible combinations. Kept short on purpose: every entry + * must be provable from the flows, because the verifier reports each match + * to an operator as data needing repair. + */ +export const ILLEGAL_JOINT_STATES: readonly IllegalJointState[] = [ + { + authority: "send_armed", + reason: + "A provider send is armed only under a held claim, and the claim is " + + "released only after the send completes — an armed charge on a row " + + "nobody holds has no flow that finishes it.", + rows: NO_CLAIM_ROWS, + }, + { + authority: "absent", + reason: + "A claim is admitted only over references that carry a charge, so a " + + "held row whose references have no charge cannot have been claimed.", + rows: CLAIM_ROWS, + }, +]; + +/** The row fact for one parsed row state, split by phase when free. + * `finalized` is the phase axis: true once the session booked (attendee + * set), false while the reservation is in flight. */ +export const jointRowFactOf = ( + state: PaymentRowState, + finalized: boolean, +): JointRowFact => { + const node = rowNodeOf(state); + if (node !== "free") return node; + return finalized ? "free_finalized" : "free_reserved"; +}; + +/** The declared reason `row × authority` cannot exist, or null when the + * combination is legal. */ +export const illegalJointReasonOrNull = ( + row: JointRowFact, + authority: AuthorityFact, +): string | null => { + for (const entry of ILLEGAL_JOINT_STATES) { + if (entry.authority === authority && entry.rows.includes(row)) { + return entry.reason; + } + } + return null; +}; + +/** + * Throw when a row and any of its charges' authorities form a declared + * impossible combination. Callers pass every authority fact the row's + * references carry ("absent" when they carry none), with `context` naming + * the flow for the error. + */ +export const assertJointStateLegal = ( + row: JointRowFact, + authorities: Iterable, + context: string, +): void => { + for (const authority of authorities) { + const reason = illegalJointReasonOrNull(row, authority); + if (reason !== null) { + throw new Error( + `${context}: row ${row} cannot carry a ${authority} charge — ${reason}`, + ); + } + } +}; diff --git a/test/shared/payment/joint-state.test.ts b/test/shared/payment/joint-state.test.ts new file mode 100644 index 0000000000..fe113ce61a --- /dev/null +++ b/test/shared/payment/joint-state.test.ts @@ -0,0 +1,114 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + type AuthorityFact, + assertJointStateLegal, + ILLEGAL_JOINT_STATES, + illegalJointReasonOrNull, + type JointRowFact, + jointRowFactOf, +} from "#shared/payment/joint-state.ts"; +import type { PaymentRowState } from "#shared/payment/row-state.ts"; + +const HELD: PaymentRowState = { + claim: { + attendeeIds: [4], + commandId: "cmd_joint", + phase: "checking", + scope: "attendee_set", + writtenAt: "2026-08-17T10:00:00.000Z", + }, +}; + +const SETTLED: PaymentRowState = { outcome: { error: "kept" } }; + +const ROW_FACTS: readonly JointRowFact[] = [ + "free_reserved", + "free_finalized", + "claim", + "review", + "unrecorded", + "claim_review", + "claim_unrecorded", + "review_unrecorded", + "claim_review_unrecorded", + "settled", +]; + +const AUTHORITY_FACTS: readonly AuthorityFact[] = [ + "absent", + "ready", + "send_armed", + "observing", + "completed_due", + "completed_recorded", + "needs_owner_choice", + "needs_provider_check", +]; + +describe("payment joint state", () => { + test("every declared entry names known facts, once each", () => { + const seen = new Set(); + for (const entry of ILLEGAL_JOINT_STATES) { + expect(AUTHORITY_FACTS).toContain(entry.authority); + expect(entry.reason.length).toBeGreaterThan(0); + for (const row of entry.rows) { + expect(ROW_FACTS).toContain(row); + const key = `${row}×${entry.authority}`; + expect(seen.has(key), key).toBe(false); + seen.add(key); + } + } + }); + + test("splits a free row by phase and maps stored states to their node", () => { + expect(jointRowFactOf({}, false)).toBe("free_reserved"); + expect(jointRowFactOf({}, true)).toBe("free_finalized"); + expect(jointRowFactOf(HELD, false)).toBe("claim"); + expect(jointRowFactOf(SETTLED, true)).toBe("settled"); + }); + + test("an armed send is illegal on every row without a held claim", () => { + for (const row of ROW_FACTS) { + const reason = illegalJointReasonOrNull(row, "send_armed"); + if (row.startsWith("claim")) expect(reason, row).toBeNull(); + else expect(reason, row).toContain("armed"); + } + }); + + test("a held claim is illegal over references with no charge", () => { + expect(illegalJointReasonOrNull("claim", "absent")).toContain("claim"); + expect(illegalJointReasonOrNull("free_finalized", "absent")).toBeNull(); + expect(illegalJointReasonOrNull("settled", "absent")).toBeNull(); + }); + + test("answers every combination without throwing", () => { + for (const row of ROW_FACTS) { + for (const authority of AUTHORITY_FACTS) { + const reason = illegalJointReasonOrNull(row, authority); + expect( + reason === null || reason.length > 0, + `${row}×${authority}`, + ).toBe(true); + } + } + }); + + test("the assertion names the flow, the facts, and the broken invariant", () => { + expect(() => + assertJointStateLegal( + "settled", + ["completed_recorded", "send_armed"], + "resume", + ), + ).toThrow( + /resume: row settled cannot carry a send_armed charge — A provider send/, + ); + assertJointStateLegal( + "claim", + AUTHORITY_FACTS.filter((f) => f !== "absent"), + "dispatch", + ); + assertJointStateLegal("free_finalized", [], "empty"); + }); +}); From 5d9688424fec89a13fa034dd468880c40065b905 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:07:24 +0000 Subject: [PATCH 02/13] Prove the joint state before a resume acts on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resume path navigates whatever combination of machines a crash left behind, so it now proves that combination is one a flow can produce — the seam's first production consumer, which also closes the export gate. The wiring taught the seam a stored truth: a row carries its pending outcome beside its live work through the whole crash window, so the row fact comes from the live work alone, and only a row holding nothing but an outcome is settled. The authority fact comes from the stored state name; an unknown name throws, since it would mean the refund machine grew a state this seam has never heard of. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- .../payment-processing/placeholder-resume.ts | 19 +++++++ src/shared/payment/joint-state.ts | 51 ++++++++++++++++--- test/shared/payment/joint-state.test.ts | 16 +++++- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/features/api/payment-processing/placeholder-resume.ts b/src/features/api/payment-processing/placeholder-resume.ts index 7e920fc0c4..599e3e9a4d 100644 --- a/src/features/api/payment-processing/placeholder-resume.ts +++ b/src/features/api/payment-processing/placeholder-resume.ts @@ -43,6 +43,11 @@ import { import { paymentReferenceIndex } from "#shared/db/payment-reference-store.ts"; import { advanceSessionFailure } from "#shared/db/processed-payments.ts"; import { ErrorCode, logError } from "#shared/logger.ts"; +import { + assertJointStateLegal, + authorityFactOf, + jointRowFactOf, +} from "#shared/payment/joint-state.ts"; import { type PlaceholderRefund, placeholderRefund, @@ -252,6 +257,20 @@ export const resumePlaceholderSession = async ( paidPaymentReferenceOf(session), session.id, ); + // A resume navigates a combination of machines a crash left behind, so + // prove the combination is one a flow can produce before acting on it. + assertJointStateLegal( + jointRowFactOf( + search.held !== null + ? { claim: search.held.claim, outcome: stored } + : { outcome: stored }, + false, + ), + search.rows.length === 0 + ? ["absent"] + : search.rows.map((row) => authorityFactOf(row.refundStateName)), + `resume of session ${session.id}`, + ); if (search.held !== null) { const { claim, record } = search.held; return await finishPlaceholderRefund(session, { diff --git a/src/shared/payment/joint-state.ts b/src/shared/payment/joint-state.ts index fa097ca7da..6ff4832c9a 100644 --- a/src/shared/payment/joint-state.ts +++ b/src/shared/payment/joint-state.ts @@ -20,18 +20,40 @@ import { type ROW_NODES, rowNodeOf } from "#shared/payment/row-machine-spec.ts"; import type { PaymentRowState } from "#shared/payment/row-state.ts"; -/** One charge's refund authority as this seam sees it: its state name, or - * "absent" when the row's references have no charge at all. */ +/** One charge's refund authority as this seam sees it: the stored state + * name (whether its local recording is done is a separate column and a + * separate concern), or "absent" when the row's references have no charge + * at all. */ export type AuthorityFact = | "absent" | "ready" | "send_armed" | "observing" - | "completed_due" - | "completed_recorded" + | "completed" | "needs_owner_choice" | "needs_provider_check"; +const AUTHORITY_NAMES: readonly AuthorityFact[] = [ + "ready", + "send_armed", + "observing", + "completed", + "needs_owner_choice", + "needs_provider_check", +]; + +/** The authority fact for one stored state name — null means the reference + * carries no charge. An unknown name throws: it would mean the authority + * machine grew a state this seam has never heard of. */ +export const authorityFactOf = (name: string | null): AuthorityFact => { + if (name === null) return "absent"; + const known = AUTHORITY_NAMES.find((candidate) => candidate === name); + if (known === undefined) { + throw new Error(`Unknown refund authority state name: ${name}`); + } + return known; +}; + /** One row as this seam sees it: its machine node, with the free node split * by delivery phase — the only split the phase adds. */ export type JointRowFact = @@ -88,12 +110,29 @@ export const ILLEGAL_JOINT_STATES: readonly IllegalJointState[] = [ /** The row fact for one parsed row state, split by phase when free. * `finalized` is the phase axis: true once the session booked (attendee - * set), false while the reservation is in flight. */ + * set), false while the reservation is in flight. A stored row carries its + * pending outcome beside its live work through the whole crash window, so + * the fact comes from the live work alone — only a row holding nothing but + * an outcome is settled. */ export const jointRowFactOf = ( state: PaymentRowState, finalized: boolean, ): JointRowFact => { - const node = rowNodeOf(state); + const hasLiveWork = + state.claim !== undefined || + state.review !== undefined || + state.unrecorded !== undefined; + const node = rowNodeOf( + hasLiveWork + ? { + ...(state.claim === undefined ? {} : { claim: state.claim }), + ...(state.review === undefined ? {} : { review: state.review }), + ...(state.unrecorded === undefined + ? {} + : { unrecorded: state.unrecorded }), + } + : state, + ); if (node !== "free") return node; return finalized ? "free_finalized" : "free_reserved"; }; diff --git a/test/shared/payment/joint-state.test.ts b/test/shared/payment/joint-state.test.ts index fe113ce61a..5171ebb89e 100644 --- a/test/shared/payment/joint-state.test.ts +++ b/test/shared/payment/joint-state.test.ts @@ -3,6 +3,7 @@ import { describe, it as test } from "@std/testing/bdd"; import { type AuthorityFact, assertJointStateLegal, + authorityFactOf, ILLEGAL_JOINT_STATES, illegalJointReasonOrNull, type JointRowFact, @@ -40,8 +41,7 @@ const AUTHORITY_FACTS: readonly AuthorityFact[] = [ "ready", "send_armed", "observing", - "completed_due", - "completed_recorded", + "completed", "needs_owner_choice", "needs_provider_check", ]; @@ -66,6 +66,9 @@ describe("payment joint state", () => { expect(jointRowFactOf({}, true)).toBe("free_finalized"); expect(jointRowFactOf(HELD, false)).toBe("claim"); expect(jointRowFactOf(SETTLED, true)).toBe("settled"); + // A stored row keeps its pending outcome beside the claim through the + // whole crash window — the live work names the fact, not the outcome. + expect(jointRowFactOf({ ...HELD, ...SETTLED }, false)).toBe("claim"); }); test("an armed send is illegal on every row without a held claim", () => { @@ -94,6 +97,15 @@ describe("payment joint state", () => { } }); + test("maps stored authority names, and refuses one it has never heard of", () => { + expect(authorityFactOf(null)).toBe("absent"); + expect(authorityFactOf("completed")).toBe("completed"); + expect(authorityFactOf("send_armed")).toBe("send_armed"); + expect(() => authorityFactOf("half_done")).toThrow( + "Unknown refund authority state name: half_done", + ); + }); + test("the assertion names the flow, the facts, and the broken invariant", () => { expect(() => assertJointStateLegal( From 6b2b32f6bbc74b94d0edd36ef848153ff87d14b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:11:34 +0000 Subject: [PATCH 03/13] Witness the seam in the crash manufactures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new test helper loads every stored row one session touches — the session row and its anchor siblings — and proves each row's machine combination is one a flow can produce. The two crash-store helpers call it right after manufacturing their crash, so the exact intermediate states the resume tests rebuild from now also witness the seam: a crash state the illegal list calls impossible fails the test that made it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- .../placeholder-completion.test.ts | 7 +- .../store-refund-helpers.ts | 2 + test/shared/payment/joint-state.test.ts | 6 +- test/test-utils/joint-state.ts | 65 +++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 test/test-utils/joint-state.ts diff --git a/test/features/api/payment-processing/placeholder-completion.test.ts b/test/features/api/payment-processing/placeholder-completion.test.ts index 3b3823db7d..5be46e9f60 100644 --- a/test/features/api/payment-processing/placeholder-completion.test.ts +++ b/test/features/api/payment-processing/placeholder-completion.test.ts @@ -7,7 +7,6 @@ * every step held: one pair of legs, one note, one activity line, and an * authority that stays recorded. */ -/* jscpd:ignore-start -- imports */ import { expect } from "@std/expect"; import { it } from "@std/testing/bdd"; import { completePlaceholderMoney } from "#routes/api/payment-processing/placeholder-completion.ts"; @@ -29,6 +28,8 @@ import { describeWithEnv } from "#test-utils/db.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { setupTestEncryptionKey } from "#test-utils/env.ts"; import { singleItem } from "#test-utils/factories.ts"; +/* jscpd:ignore-start -- imports */ +import { expectLegalJointStates } from "#test-utils/joint-state.ts"; import { withRefundLedgerFault } from "#test-utils/refund-ledger-fault.ts"; import { expectOnePairOfLegs, @@ -101,6 +102,10 @@ describeWithEnv("placeholder money completion", { db: true }, () => { if (state.claim === undefined || state.unrecorded === undefined) { throw new Error("held anchor row lost its claim or return time"); } + await expectLegalJointStates( + rejection.sessionId, + "after a crashed rejected store", + ); return { attendeeId: held.attendee_id, claim: state.claim, diff --git a/test/features/api/payment-processing/store-refund-helpers.ts b/test/features/api/payment-processing/store-refund-helpers.ts index b6dc37bb10..aadc978bff 100644 --- a/test/features/api/payment-processing/store-refund-helpers.ts +++ b/test/features/api/payment-processing/store-refund-helpers.ts @@ -9,6 +9,7 @@ import { requirePublicStatusId } from "#shared/db/attendee-statuses.ts"; import { attendeesApi } from "#shared/db/attendees/api.ts"; import { reserveSession } from "#shared/db/processed-payments.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; +import { expectLegalJointStates } from "#test-utils/joint-state.ts"; import { bookingIntent, trustedPayment } from "./index/helpers.ts"; export const placeholderSpec = (detail: string) => @@ -65,5 +66,6 @@ export const crashedPlaceholderStore = async (sessionId: string) => { } finally { broken.restore(); } + await expectLegalJointStates(sessionId, "after a crashed placeholder store"); return placeholder; }; diff --git a/test/shared/payment/joint-state.test.ts b/test/shared/payment/joint-state.test.ts index 5171ebb89e..71c56dae33 100644 --- a/test/shared/payment/joint-state.test.ts +++ b/test/shared/payment/joint-state.test.ts @@ -108,11 +108,7 @@ describe("payment joint state", () => { test("the assertion names the flow, the facts, and the broken invariant", () => { expect(() => - assertJointStateLegal( - "settled", - ["completed_recorded", "send_armed"], - "resume", - ), + assertJointStateLegal("settled", ["completed", "send_armed"], "resume"), ).toThrow( /resume: row settled cannot carry a send_armed charge — A provider send/, ); diff --git a/test/test-utils/joint-state.ts b/test/test-utils/joint-state.ts new file mode 100644 index 0000000000..a8a26db043 --- /dev/null +++ b/test/test-utils/joint-state.ts @@ -0,0 +1,65 @@ +import { decrypt } from "#shared/crypto/encryption.ts"; +import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; +import { queryAll } from "#shared/db/client.ts"; +import { paymentClaimRowsSql } from "#shared/db/payment-claim.ts"; +import { + assertJointStateLegal, + authorityFactOf, + jointRowFactOf, +} from "#shared/payment/joint-state.ts"; +import { readRowState } from "#shared/payment/row-state.ts"; + +interface JointRow { + attendee_id: number | null; + failure_data: EnvKeyEncrypted | ""; + payment_session_id: string; + refund_state_name: string | null; +} + +/** The session's own row plus every row sharing its payment reference — a + * placeholder keeps its pending outcome on the session row and its claim on + * the anchor row, and both belong to the same crash picture. */ +const SESSION_AND_SIBLINGS = `payment.payment_session_id = ? + OR (payment.payment_reference_index != '' + AND payment.payment_reference_index IN ( + SELECT sibling.payment_reference_index + FROM processed_payments AS sibling + WHERE sibling.payment_session_id = ?))`; + +/** + * Load every stored machine one session touches and prove each row's + * combination is one a flow can produce. Crash tests call this right after + * manufacturing their crash, so every manufactured intermediate state also + * witnesses the seam between the machines — a crash state the seam calls + * impossible fails the test that made it. + */ +export const expectLegalJointStates = async ( + sessionId: string, + context: string, +): Promise => { + const rows = await queryAll( + paymentClaimRowsSql(SESSION_AND_SIBLINGS), + [sessionId, sessionId], + ); + if (rows.length === 0) { + throw new Error(`No payment rows to witness for ${context}`); + } + for (const [id, group] of Map.groupBy( + rows, + (row) => row.payment_session_id, + )) { + const first = group[0]!; + const state = + first.failure_data === "" + ? {} + : readRowState( + await decrypt(first.failure_data), + "processed_payments.failure_data", + ); + assertJointStateLegal( + jointRowFactOf(state, first.attendee_id !== null), + group.map((row) => authorityFactOf(row.refund_state_name)), + `${context} (row ${id})`, + ); + } +}; From db158a729b66fc45dd021b3629fcdb48e9f4ab51 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:15:33 +0000 Subject: [PATCH 04/13] Crash the completion tail at its two untested points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow sweep found the placeholder completion's two inner windows — after the ledger posts but before the authority's local recording, and after that recording but before the once-only confirmation — proven only by whole-tail replay, never by a crash at the exact point. Two tests now die there with real database faults: a trigger refuses the retirement update, another refuses the confirmation insert, and the redelivery finishes only what is missing — the legs never double, the words land exactly once, and each manufactured crash state is witnessed against the seam. The batch refund flow gains the twin the refresh flow already had: authority retirement fails after the ledger landed, and the released rows still say the books are recorded. The fault triggers share one installer, which the refund-ledger fault now also uses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- .../refunds/dispatch/write-order.test.ts | 21 ++++++ .../placeholder-completion.test.ts | 74 ++++++++++++++++++- test/test-utils/db-fault.ts | 55 ++++++++++++++ test/test-utils/refund-ledger-fault.ts | 14 +--- 4 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 test/test-utils/db-fault.ts diff --git a/test/features/admin/refunds/dispatch/write-order.test.ts b/test/features/admin/refunds/dispatch/write-order.test.ts index d7210c3f90..5207e87847 100644 --- a/test/features/admin/refunds/dispatch/write-order.test.ts +++ b/test/features/admin/refunds/dispatch/write-order.test.ts @@ -80,6 +80,27 @@ describeWithEnv( expect(counts.refundedCount).toBe(1); }); + test("keeps recorded row facts when authority retirement fails", async () => { + const reference = "pi_retire_crash"; + const claim = grantingRowClaim(new Map([[11, [`sess_${reference}`]]])); + const source = provider({ refunded: new Set([reference]) }); + + await expect( + processRefundBatchAt(source, [candidate([{ reference }], 11)], 7, { + claim, + record: recordEveryRefund, + recordAuthorities: () => + Promise.reject(new Error("authority unavailable")), + }), + ).rejects.toThrow("authority unavailable"); + + // The ledger landed before the throw, so the row facts say the books + // are recorded — the authority stays due, which the admission gate + // turns into refresh-owned work instead of a second send. + expect(claim.recorded).toEqual([[`sess_${reference}`]]); + expect(claim.released).toEqual([[`sess_${reference}`]]); + }); + test("a ledger throw preserves every returned row before propagating", async () => { const active = ["pi_active"]; const knownReturned = "pi_known_returned"; diff --git a/test/features/api/payment-processing/placeholder-completion.test.ts b/test/features/api/payment-processing/placeholder-completion.test.ts index 5be46e9f60..f94cb0f469 100644 --- a/test/features/api/payment-processing/placeholder-completion.test.ts +++ b/test/features/api/payment-processing/placeholder-completion.test.ts @@ -25,10 +25,14 @@ import { completedAtOf } from "#shared/payment/refund-authority-state.ts"; import { readRowState } from "#shared/payment/row-state.ts"; import { rejectedChargeReference } from "#shared/payment/validated-session.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +/* jscpd:ignore-start -- imports */ +import { + withAuthorityRetirementFault, + withRefundConfirmationFault, +} from "#test-utils/db-fault.ts"; import { createTestListing } from "#test-utils/db-helpers/listings.ts"; import { setupTestEncryptionKey } from "#test-utils/env.ts"; import { singleItem } from "#test-utils/factories.ts"; -/* jscpd:ignore-start -- imports */ import { expectLegalJointStates } from "#test-utils/joint-state.ts"; import { withRefundLedgerFault } from "#test-utils/refund-ledger-fault.ts"; import { @@ -285,4 +289,72 @@ describeWithEnv("placeholder money completion", { db: true }, () => { ), ).toEqual({ failure_data: "", protected_state: "" }); }); + + it("finishes the tail when the first delivery died at authority retirement", async () => { + const listing = await createTestListing({}); + const rejection = ourRejection("pi_retire_window", { + items: singleItem(listing.id, 1, 500), + }); + // The refund returns and the ledger posts, then the retirement write + // refuses — the delivery fails with the books already recorded. + await withAuthorityRetirementFault(() => + expect( + withSucceedingRefundFor(CAPTURED)(() => + settleRejectedCharge(rejection), + ), + ).rejects.toThrow("authority retirement unavailable"), + ); + await expectLegalJointStates( + rejection.sessionId, + "after a retirement crash", + ); + await expectOnePairOfLegs(rejection.sessionId); + expect(await storedNote()).toBeNull(); + + // The redelivery finishes only what is missing: retirement, the note and + // activity line, and the release — the legs never double. + await withSucceedingRefundFor(CAPTURED)(() => + settleRejectedCharge(rejection), + ); + await expectOnePairOfLegs(rejection.sessionId); + expect((await storedNote())?.total).toBe(1); + expect(await activityCount()).toBe(1); + const referenceIndex = await paymentReferenceIndex( + rejectedChargeReference(rejection), + ); + const after = await loadRefundAuthorityByReference(referenceIndex); + expect(after?.state.local.kind).toBe("recorded"); + }); + + it("finishes the tail when the first delivery died at the confirmation", async () => { + const listing = await createTestListing({}); + const rejection = ourRejection("pi_confirm_window", { + items: singleItem(listing.id, 1, 500), + }); + // Ledger and retirement land, then the once-only latch refuses — the + // note and activity line ride its transaction, so neither exists yet. + await withRefundConfirmationFault(() => + expect( + withSucceedingRefundFor(CAPTURED)(() => + settleRejectedCharge(rejection), + ), + ).rejects.toThrow("refund confirmation unavailable"), + ); + await expectLegalJointStates( + rejection.sessionId, + "after a confirmation crash", + ); + await expectOnePairOfLegs(rejection.sessionId); + expect(await storedNote()).toBeNull(); + expect(await activityCount()).toBe(0); + + // The redelivery tolerates the already-recorded authority and writes the + // words exactly once. + await withSucceedingRefundFor(CAPTURED)(() => + settleRejectedCharge(rejection), + ); + await expectOnePairOfLegs(rejection.sessionId); + expect((await storedNote())?.total).toBe(1); + expect(await activityCount()).toBe(1); + }); }); diff --git a/test/test-utils/db-fault.ts b/test/test-utils/db-fault.ts new file mode 100644 index 0000000000..e80314e58a --- /dev/null +++ b/test/test-utils/db-fault.ts @@ -0,0 +1,55 @@ +import { execute } from "#shared/db/client.ts"; + +/** + * Run `body` with one database fault installed — a trigger that makes a + * chosen write fail at SQLite's own boundary — then lift the fault. Real + * faults at the storage layer, not stubs: the failing statement rolls back + * exactly as a production write would. + */ +export const withDbFault = async ( + createTrigger: string, + name: string, + body: () => Promise, +): Promise => { + await execute(createTrigger); + try { + return await body(); + } finally { + await execute(`DROP TRIGGER IF EXISTS ${name}`); + } +}; + +const RETIREMENT_FAULT = "test_authority_retirement_fault"; + +/** The authority retirement — the update that marks a completed refund's + * local recording done — refuses, and every other write stays available. */ +export const withAuthorityRetirementFault = ( + body: () => Promise, +): Promise => + withDbFault( + `CREATE TRIGGER ${RETIREMENT_FAULT} + BEFORE UPDATE ON payment_charges + WHEN NEW.refund_local_state = 'recorded' + BEGIN + SELECT RAISE(ABORT, 'authority retirement unavailable'); + END`, + RETIREMENT_FAULT, + body, + ); + +const CONFIRMATION_FAULT = "test_refund_confirmation_fault"; + +/** The once-only confirmation latch refuses, so the note and activity line + * that ride its transaction never land on this run. */ +export const withRefundConfirmationFault = ( + body: () => Promise, +): Promise => + withDbFault( + `CREATE TRIGGER ${CONFIRMATION_FAULT} + BEFORE INSERT ON refund_confirmations + BEGIN + SELECT RAISE(ABORT, 'refund confirmation unavailable'); + END`, + CONFIRMATION_FAULT, + body, + ); diff --git a/test/test-utils/refund-ledger-fault.ts b/test/test-utils/refund-ledger-fault.ts index 7ccf727d8a..1cd2ebf737 100644 --- a/test/test-utils/refund-ledger-fault.ts +++ b/test/test-utils/refund-ledger-fault.ts @@ -2,7 +2,7 @@ * fail, at SQLite's own write boundary, so sales and reads stay available * while the atomic refund transfer group rolls back whole. */ -import { execute } from "#shared/db/client.ts"; +import { withDbFault } from "#test-utils/db-fault.ts"; /** The trigger DDL, shared with the recovery stories' fault installer. */ export const refundLedgerFaultTrigger = (name: string): string => @@ -16,13 +16,5 @@ export const refundLedgerFaultTrigger = (name: string): string => const FAULT = "test_refund_ledger_fault"; /** Run `body` with the refund ledger refusing writes, then lift the fault. */ -export const withRefundLedgerFault = async ( - body: () => Promise, -): Promise => { - await execute(refundLedgerFaultTrigger(FAULT)); - try { - return await body(); - } finally { - await execute(`DROP TRIGGER IF EXISTS ${FAULT}`); - } -}; +export const withRefundLedgerFault = (body: () => Promise): Promise => + withDbFault(refundLedgerFaultTrigger(FAULT), FAULT, body); From b82827a6d6107b82f76c120fc97dccaceb3928da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:18:57 +0000 Subject: [PATCH 05/13] Cover every arm of the seam and its witness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage gate found three gaps the targeted runs missed: the resume's empty-rows arm (removed — a reference with no charge already answers as an absent fact through its null name, and no rows at all means nothing to check), the seam's review and unrecorded spreads (now exercised beside a riding outcome, each kind), and the witness util's own refusal and bare-reservation paths (now tested directly — a bare reservation is the legal free row with no charges, asserted without decrypting). The crash-window manufacture also folds into one curried helper, which clears the duplication the previous push let through. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- .../payment-processing/placeholder-resume.ts | 6 +- .../placeholder-completion.test.ts | 71 +++++++++---------- test/shared/payment/joint-state.test.ts | 15 +++- test/test-utils/joint-state.test.ts | 20 ++++++ 4 files changed, 68 insertions(+), 44 deletions(-) create mode 100644 test/test-utils/joint-state.test.ts diff --git a/src/features/api/payment-processing/placeholder-resume.ts b/src/features/api/payment-processing/placeholder-resume.ts index 599e3e9a4d..674b6c4860 100644 --- a/src/features/api/payment-processing/placeholder-resume.ts +++ b/src/features/api/payment-processing/placeholder-resume.ts @@ -266,9 +266,9 @@ export const resumePlaceholderSession = async ( : { outcome: stored }, false, ), - search.rows.length === 0 - ? ["absent"] - : search.rows.map((row) => authorityFactOf(row.refundStateName)), + // A row whose reference carries no charge answers with a null name and + // becomes "absent" here; no rows at all means nothing to check. + search.rows.map((row) => authorityFactOf(row.refundStateName)), `resume of session ${session.id}`, ); if (search.held !== null) { diff --git a/test/features/api/payment-processing/placeholder-completion.test.ts b/test/features/api/payment-processing/placeholder-completion.test.ts index f94cb0f469..f569fcf2b6 100644 --- a/test/features/api/payment-processing/placeholder-completion.test.ts +++ b/test/features/api/payment-processing/placeholder-completion.test.ts @@ -290,35 +290,49 @@ describeWithEnv("placeholder money completion", { db: true }, () => { ).toEqual({ failure_data: "", protected_state: "" }); }); - it("finishes the tail when the first delivery died at authority retirement", async () => { + /** Die at one exact point of the first delivery under a real database + * fault, prove the crash state and the books, then redeliver clean and + * prove the words landed exactly once. Returns the rejection so a test + * can add its own point-specific checks. */ + const crashesThenFinishes = async ( + reference: string, + fault: (body: () => Promise) => Promise, + message: string, + ) => { const listing = await createTestListing({}); - const rejection = ourRejection("pi_retire_window", { + const rejection = ourRejection(reference, { items: singleItem(listing.id, 1, 500), }); - // The refund returns and the ledger posts, then the retirement write - // refuses — the delivery fails with the books already recorded. - await withAuthorityRetirementFault(() => + await fault(() => expect( withSucceedingRefundFor(CAPTURED)(() => settleRejectedCharge(rejection), ), - ).rejects.toThrow("authority retirement unavailable"), - ); - await expectLegalJointStates( - rejection.sessionId, - "after a retirement crash", + ).rejects.toThrow(message), ); + await expectLegalJointStates(rejection.sessionId, `after: ${message}`); await expectOnePairOfLegs(rejection.sessionId); expect(await storedNote()).toBeNull(); + expect(await activityCount()).toBe(0); - // The redelivery finishes only what is missing: retirement, the note and - // activity line, and the release — the legs never double. await withSucceedingRefundFor(CAPTURED)(() => settleRejectedCharge(rejection), ); await expectOnePairOfLegs(rejection.sessionId); expect((await storedNote())?.total).toBe(1); expect(await activityCount()).toBe(1); + return rejection; + }; + + it("finishes the tail when the first delivery died at authority retirement", async () => { + // The refund returns and the ledger posts, then the retirement write + // refuses — the redelivery finishes retirement, words, and release + // without doubling the legs. + const rejection = await crashesThenFinishes( + "pi_retire_window", + withAuthorityRetirementFault, + "authority retirement unavailable", + ); const referenceIndex = await paymentReferenceIndex( rejectedChargeReference(rejection), ); @@ -327,34 +341,13 @@ describeWithEnv("placeholder money completion", { db: true }, () => { }); it("finishes the tail when the first delivery died at the confirmation", async () => { - const listing = await createTestListing({}); - const rejection = ourRejection("pi_confirm_window", { - items: singleItem(listing.id, 1, 500), - }); // Ledger and retirement land, then the once-only latch refuses — the - // note and activity line ride its transaction, so neither exists yet. - await withRefundConfirmationFault(() => - expect( - withSucceedingRefundFor(CAPTURED)(() => - settleRejectedCharge(rejection), - ), - ).rejects.toThrow("refund confirmation unavailable"), - ); - await expectLegalJointStates( - rejection.sessionId, - "after a confirmation crash", - ); - await expectOnePairOfLegs(rejection.sessionId); - expect(await storedNote()).toBeNull(); - expect(await activityCount()).toBe(0); - - // The redelivery tolerates the already-recorded authority and writes the - // words exactly once. - await withSucceedingRefundFor(CAPTURED)(() => - settleRejectedCharge(rejection), + // note and activity line ride its transaction, so neither exists until + // the redelivery writes them exactly once. + await crashesThenFinishes( + "pi_confirm_window", + withRefundConfirmationFault, + "refund confirmation unavailable", ); - await expectOnePairOfLegs(rejection.sessionId); - expect((await storedNote())?.total).toBe(1); - expect(await activityCount()).toBe(1); }); }); diff --git a/test/shared/payment/joint-state.test.ts b/test/shared/payment/joint-state.test.ts index 71c56dae33..fb9634bd9b 100644 --- a/test/shared/payment/joint-state.test.ts +++ b/test/shared/payment/joint-state.test.ts @@ -9,6 +9,7 @@ import { type JointRowFact, jointRowFactOf, } from "#shared/payment/joint-state.ts"; +import { openPaymentReview } from "#shared/payment/review.ts"; import type { PaymentRowState } from "#shared/payment/row-state.ts"; const HELD: PaymentRowState = { @@ -66,9 +67,19 @@ describe("payment joint state", () => { expect(jointRowFactOf({}, true)).toBe("free_finalized"); expect(jointRowFactOf(HELD, false)).toBe("claim"); expect(jointRowFactOf(SETTLED, true)).toBe("settled"); - // A stored row keeps its pending outcome beside the claim through the - // whole crash window — the live work names the fact, not the outcome. + // A stored row keeps its pending outcome beside its live work through + // the whole crash window — the live work names the fact, whichever kind + // it is, and the outcome rides along. expect(jointRowFactOf({ ...HELD, ...SETTLED }, false)).toBe("claim"); + const review = openPaymentReview({ kind: "shared_reference" }); + expect(jointRowFactOf({ review, ...SETTLED }, false)).toBe("review"); + expect( + jointRowFactOf( + { unrecorded: { returnedAt: "2026-08-17T10:00:00.000Z" }, ...SETTLED }, + false, + ), + ).toBe("unrecorded"); + expect(jointRowFactOf({ ...HELD, review }, false)).toBe("claim_review"); }); test("an armed send is illegal on every row without a held claim", () => { diff --git a/test/test-utils/joint-state.test.ts b/test/test-utils/joint-state.test.ts new file mode 100644 index 0000000000..f0dafc53bb --- /dev/null +++ b/test/test-utils/joint-state.test.ts @@ -0,0 +1,20 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { reserveSession } from "#shared/db/processed-payments.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { expectLegalJointStates } from "#test-utils/joint-state.ts"; + +describeWithEnv("joint-state witness", { db: true }, () => { + test("a bare reservation witnesses as a free row with no charges", async () => { + await reserveSession("cs_witness_bare"); + // failure_data is empty and the reference joins to no charge — the + // legal free_reserved × absent combination, asserted without decrypting. + await expectLegalJointStates("cs_witness_bare", "bare reservation"); + }); + + test("refuses to witness a session that stored nothing", async () => { + await expect( + expectLegalJointStates("cs_witness_missing", "missing session"), + ).rejects.toThrow("No payment rows to witness for missing session"); + }); +}); From db6e62144c0c7512a52afa7dc229a08c13d9569a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:20:55 +0000 Subject: [PATCH 06/13] =?UTF-8?q?WIP:=20scan=20for=20stored=20impossible?= =?UTF-8?q?=20combinations=20=E2=80=94=20NOT=20WIRED,=20page=20and=20tests?= =?UTF-8?q?=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan module reads the two declared illegal combinations back out of the live database over plaintext mirror columns, one bounded batch, no decryption. Not yet consumed by the atlas page and not yet tested, so the export gate would fail this commit alone; the wiring lands next. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- src/shared/db/joint-state-scan.ts | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/shared/db/joint-state-scan.ts diff --git a/src/shared/db/joint-state-scan.ts b/src/shared/db/joint-state-scan.ts new file mode 100644 index 0000000000..c3bd81a3d1 --- /dev/null +++ b/src/shared/db/joint-state-scan.ts @@ -0,0 +1,83 @@ +/** + * Find stored rows whose machines form a declared impossible combination. + * Each query is tied to one {@link ILLEGAL_JOINT_STATES} entry, phrased over + * the plaintext mirror columns so the scan decrypts nothing: a row's live + * work shows through `protected_state` (every claim-holding node mirrors the + * one claim word), and a charge's state shows through `refund_state_name`. + * The scan is the operator's view of the seam — an impossible combination + * becomes a listed row instead of a debugging session. + */ + +import type { ResultSet } from "@libsql/client"; +import { queryBatch, resultRows } from "#shared/db/client.ts"; +import { CLAIM_MIRROR } from "#shared/payment/admit-move.ts"; +import { ILLEGAL_JOINT_STATES } from "#shared/payment/joint-state.ts"; + +/** Which declared entry a found row breaks, named for the catalog. */ +export type JointAnomalyKey = "armed_without_claim" | "claim_without_charge"; + +export interface JointAnomaly { + readonly key: JointAnomalyKey; + readonly sessionId: string; +} + +/** Enough rows to show the problem without an unbounded read — a healthy + * site returns none at all. */ +const SCAN_LIMIT = 25; + +/** The scan must cover exactly the declared entries: a new illegal + * combination fails this lookup until the scan learns its query. */ +const declaredEntry = ( + authority: string, +): (typeof ILLEGAL_JOINT_STATES)[number] => { + const entry = ILLEGAL_JOINT_STATES.find( + (candidate) => candidate.authority === authority, + ); + if (entry === undefined) { + throw new Error(`No declared illegal entry for ${authority}`); + } + return entry; +}; + +/** Scan the stored rows for every declared impossible combination. */ +export const scanJointAnomalies = async (): Promise => { + // Tie each query to its declaration, so a renamed or removed entry breaks + // the scan loudly instead of leaving a rule silently unchecked. + declaredEntry("send_armed"); + declaredEntry("absent"); + const [armed, unbacked] = await queryBatch([ + { + args: [CLAIM_MIRROR, SCAN_LIMIT], + sql: `SELECT payment.payment_session_id + FROM payment_charges AS charge + JOIN processed_payments AS payment + ON payment.payment_reference_index = charge.reference_index + WHERE charge.refund_state_name = 'send_armed' + AND payment.protected_state != ? + LIMIT ?`, + }, + { + args: [CLAIM_MIRROR, SCAN_LIMIT], + sql: `SELECT payment.payment_session_id + FROM processed_payments AS payment + WHERE payment.protected_state = ? + AND NOT EXISTS ( + SELECT 1 FROM payment_charges AS charge + WHERE charge.reference_index = payment.payment_reference_index + ) + LIMIT ?`, + }, + ]); + const found = ( + result: ResultSet | undefined, + key: JointAnomalyKey, + ): JointAnomaly[] => + resultRows<{ payment_session_id: string }>(result!).map((row) => ({ + key, + sessionId: row.payment_session_id, + })); + return [ + ...found(armed, "armed_without_claim"), + ...found(unbacked, "claim_without_charge"), + ]; +}; From a70c9252989e1d56c4dfdcd5fa4ad3be28f5881c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:39:17 +0000 Subject: [PATCH 07/13] Give the seam its operator view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema atlas page now ends with a live check: the two declared impossible combinations are read back out of the site's own records, and each match renders as plain words plus the record's id. A healthy site answers with one all-clear line. The scan keys its queries by the declaration table's own literal types, so declaring a third illegal combination refuses to compile until the scan learns how to look for it. Queries read only the plaintext mirror columns — nothing is decrypted to answer. The tests plant impossible rows with raw SQL, since no production writer can make them: an armed charge on an unheld row is reported, the same charge under a held claim is not, and a held row with no charge shows on the page with its id. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AqLNrpvmNdGSSqUtSafiMd --- src/features/admin/schema-atlas.ts | 3 +- src/locales/en/schema-atlas.json | 5 + src/shared/db/joint-state-scan.ts | 107 ++++++++---------- src/shared/payment/joint-state.ts | 8 +- src/ui/templates/admin/schema-atlas.tsx | 28 +++++ .../admin/schema-atlas/server.test.ts | 25 +++- test/shared/db/joint-state-scan.test.ts | 33 ++++++ test/test-utils/joint-state.ts | 49 +++++++- test/ui/templates/admin/schema-atlas.test.tsx | 18 ++- 9 files changed, 212 insertions(+), 64 deletions(-) create mode 100644 test/shared/db/joint-state-scan.test.ts diff --git a/src/features/admin/schema-atlas.ts b/src/features/admin/schema-atlas.ts index e1a6ee578c..7b057e1588 100644 --- a/src/features/admin/schema-atlas.ts +++ b/src/features/admin/schema-atlas.ts @@ -4,11 +4,12 @@ import { ownerPage } from "#routes/auth.ts"; import { defineRoutes } from "#routes/router.ts"; +import { scanJointAnomalies } from "#shared/db/joint-state-scan.ts"; import { settings } from "#shared/db/settings.ts"; import { adminSchemaAtlasPage } from "#templates/admin/schema-atlas.tsx"; const handleSchemaAtlasGet = ownerPage(async (session) => - adminSchemaAtlasPage(session, settings.theme), + adminSchemaAtlasPage(session, settings.theme, await scanJointAnomalies()), ); export const adminHandlers = defineRoutes({ diff --git a/src/locales/en/schema-atlas.json b/src/locales/en/schema-atlas.json index dd9d6915fa..7db6b80c21 100644 --- a/src/locales/en/schema-atlas.json +++ b/src/locales/en/schema-atlas.json @@ -14,6 +14,11 @@ "schema.actor.provider": "The payment provider", "schema.actor.owner": "You", "schema.widget.hint": "Choose a state to see its ways forward.", + "schema.check.heading": "Live check", + "schema.check.intro": "The machines above promise some combinations can never be stored. This check looks for them in this site's own records.", + "schema.check.none": "All stored payment records fit the rules.", + "schema.check.armed_without_claim": "A refund is set to send, but no job holds this row.", + "schema.check.claim_without_charge": "A job holds this row, but its payment has no charge record.", "schema.refund.title": "A refund at the payment provider", "schema.refund.intro": "Each refund the site owes a buyer lives in one record. The record says where the money is and who needs to act next. A refund always sits in exactly one of the states below.", "schema.refund.state.ready": "Ready to send", diff --git a/src/shared/db/joint-state-scan.ts b/src/shared/db/joint-state-scan.ts index c3bd81a3d1..00167d2e84 100644 --- a/src/shared/db/joint-state-scan.ts +++ b/src/shared/db/joint-state-scan.ts @@ -1,14 +1,14 @@ /** * Find stored rows whose machines form a declared impossible combination. - * Each query is tied to one {@link ILLEGAL_JOINT_STATES} entry, phrased over - * the plaintext mirror columns so the scan decrypts nothing: a row's live - * work shows through `protected_state` (every claim-holding node mirrors the - * one claim word), and a charge's state shows through `refund_state_name`. - * The scan is the operator's view of the seam — an impossible combination - * becomes a listed row instead of a debugging session. + * Each query is phrased over the plaintext mirror columns so the scan + * decrypts nothing: a row's live work shows through `protected_state` (every + * claim-holding node mirrors the one claim word), and a charge's state shows + * through `refund_state_name`. The scan is the operator's view of the seam — + * an impossible combination becomes a listed row instead of a debugging + * session. */ -import type { ResultSet } from "@libsql/client"; +import { uniqueBy } from "#fp"; import { queryBatch, resultRows } from "#shared/db/client.ts"; import { CLAIM_MIRROR } from "#shared/payment/admit-move.ts"; import { ILLEGAL_JOINT_STATES } from "#shared/payment/joint-state.ts"; @@ -25,59 +25,52 @@ export interface JointAnomaly { * site returns none at all. */ const SCAN_LIMIT = 25; -/** The scan must cover exactly the declared entries: a new illegal - * combination fails this lookup until the scan learns its query. */ -const declaredEntry = ( - authority: string, -): (typeof ILLEGAL_JOINT_STATES)[number] => { - const entry = ILLEGAL_JOINT_STATES.find( - (candidate) => candidate.authority === authority, - ); - if (entry === undefined) { - throw new Error(`No declared illegal entry for ${authority}`); - } - return entry; +type DeclaredAuthority = (typeof ILLEGAL_JOINT_STATES)[number]["authority"]; + +interface DeclaredScan { + readonly key: JointAnomalyKey; + readonly sql: string; +} + +/** One query per declared authority. The record is keyed by the declaration + * table's own literals, so adding an illegal combination is a compile error + * here until the scan learns how to look for it. */ +const SCAN_OF: Record = { + absent: { + key: "claim_without_charge", + sql: `SELECT payment.payment_session_id + FROM processed_payments AS payment + WHERE payment.protected_state = ? + AND NOT EXISTS ( + SELECT 1 FROM payment_charges AS charge + WHERE charge.reference_index = payment.payment_reference_index + ) + LIMIT ?`, + }, + send_armed: { + key: "armed_without_claim", + sql: `SELECT payment.payment_session_id + FROM payment_charges AS charge + JOIN processed_payments AS payment + ON payment.payment_reference_index = charge.reference_index + WHERE charge.refund_state_name = 'send_armed' + AND payment.protected_state != ? + LIMIT ?`, + }, }; /** Scan the stored rows for every declared impossible combination. */ export const scanJointAnomalies = async (): Promise => { - // Tie each query to its declaration, so a renamed or removed entry breaks - // the scan loudly instead of leaving a rule silently unchecked. - declaredEntry("send_armed"); - declaredEntry("absent"); - const [armed, unbacked] = await queryBatch([ - { - args: [CLAIM_MIRROR, SCAN_LIMIT], - sql: `SELECT payment.payment_session_id - FROM payment_charges AS charge - JOIN processed_payments AS payment - ON payment.payment_reference_index = charge.reference_index - WHERE charge.refund_state_name = 'send_armed' - AND payment.protected_state != ? - LIMIT ?`, - }, - { - args: [CLAIM_MIRROR, SCAN_LIMIT], - sql: `SELECT payment.payment_session_id - FROM processed_payments AS payment - WHERE payment.protected_state = ? - AND NOT EXISTS ( - SELECT 1 FROM payment_charges AS charge - WHERE charge.reference_index = payment.payment_reference_index - ) - LIMIT ?`, - }, - ]); - const found = ( - result: ResultSet | undefined, - key: JointAnomalyKey, - ): JointAnomaly[] => - resultRows<{ payment_session_id: string }>(result!).map((row) => ({ - key, + const scans = uniqueBy((scan: DeclaredScan) => scan.key)( + ILLEGAL_JOINT_STATES.map((entry) => SCAN_OF[entry.authority]), + ); + const results = await queryBatch( + scans.map((scan) => ({ args: [CLAIM_MIRROR, SCAN_LIMIT], sql: scan.sql })), + ); + return scans.flatMap((scan, index) => + resultRows<{ payment_session_id: string }>(results[index]!).map((row) => ({ + key: scan.key, sessionId: row.payment_session_id, - })); - return [ - ...found(armed, "armed_without_claim"), - ...found(unbacked, "claim_without_charge"), - ]; + })), + ); }; diff --git a/src/shared/payment/joint-state.ts b/src/shared/payment/joint-state.ts index 6ff4832c9a..a4858e72a6 100644 --- a/src/shared/payment/joint-state.ts +++ b/src/shared/payment/joint-state.ts @@ -88,9 +88,11 @@ const CLAIM_ROWS: readonly JointRowFact[] = [ /** * The declared impossible combinations. Kept short on purpose: every entry * must be provable from the flows, because the verifier reports each match - * to an operator as data needing repair. + * to an operator as data needing repair. Each authority keeps its literal + * type, so the database scan must declare a query for every entry here or + * refuse to compile. */ -export const ILLEGAL_JOINT_STATES: readonly IllegalJointState[] = [ +export const ILLEGAL_JOINT_STATES = [ { authority: "send_armed", reason: @@ -106,7 +108,7 @@ export const ILLEGAL_JOINT_STATES: readonly IllegalJointState[] = [ "held row whose references have no charge cannot have been claimed.", rows: CLAIM_ROWS, }, -]; +] as const satisfies readonly IllegalJointState[]; /** The row fact for one parsed row state, split by phase when free. * `finalized` is the phase axis: true once the session booked (attendee diff --git a/src/ui/templates/admin/schema-atlas.tsx b/src/ui/templates/admin/schema-atlas.tsx index a008f6d27c..4d137cede9 100644 --- a/src/ui/templates/admin/schema-atlas.tsx +++ b/src/ui/templates/admin/schema-atlas.tsx @@ -6,6 +6,7 @@ * as JSON and `client/admin/schema-atlas.ts` turns it into an SVG map. */ import { t } from "#i18n"; +import type { JointAnomaly } from "#shared/db/joint-state-scan.ts"; import { SCHEMA_ATLAS_MACHINES } from "#shared/schema-atlas/index.ts"; import type { AtlasActor } from "#shared/schema-atlas/types.ts"; import type { AdminSession, Theme } from "#shared/types.ts"; @@ -132,9 +133,35 @@ const MachineSection = ({ machine }: { machine: ViewMachine }): JSX.Element => ( ); +/** The live answer to the promises above: every row the scan flagged, or + * the all-clear. Findings render the plain words for the broken rule plus + * the record's own id, so the operator can go look at it. */ +const LiveCheckSection = ({ + anomalies, +}: { + anomalies: readonly JointAnomaly[]; +}): JSX.Element => ( +
+

{t("schema.check.heading")}

+

{t("schema.check.intro")}

+ {anomalies.length === 0 ? ( +

{t("schema.check.none")}

+ ) : ( +
    + {anomalies.map((anomaly) => ( +
  • + {t(`schema.check.${anomaly.key}`)} {anomaly.sessionId} +
  • + ))} +
+ )} +
+); + export const adminSchemaAtlasPage = ( session: AdminSession, theme: Theme, + anomalies: readonly JointAnomaly[], ): string => { const machines = SCHEMA_ATLAS_MACHINES.map(viewMachine); return settingsArticlePage( @@ -163,6 +190,7 @@ export const adminSchemaAtlasPage = ( {machines.map((machine) => ( ))} + , diff --git a/test/features/admin/schema-atlas/server.test.ts b/test/features/admin/schema-atlas/server.test.ts index 8b99c5c173..1c29de190e 100644 --- a/test/features/admin/schema-atlas/server.test.ts +++ b/test/features/admin/schema-atlas/server.test.ts @@ -1,7 +1,13 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import { cachedAdminPage, testRequiresAuth } from "#test-utils/assertions.ts"; +import { CLAIM_MIRROR } from "#shared/payment/admit-move.ts"; +import { + assertAdminHtml, + cachedAdminPage, + testRequiresAuth, +} from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; +import { plantPaymentRow } from "#test-utils/joint-state.ts"; describeWithEnv("server (admin schema map)", { db: true }, () => { const page = cachedAdminPage("/admin/schema"); @@ -26,5 +32,22 @@ describeWithEnv("server (admin schema map)", { db: true }, () => { // The page names no real payment anywhere. expect(html).not.toMatch(/\/admin\/privacy\/refunds\/\d+/); }); + + test("renders the live check with a clean answer", async () => { + await page( + 'id="schema-check"', + "Live check", + "All stored payment records fit the rules.", + ); + }); + + test("lists a stored impossible combination with its record id", async () => { + await plantPaymentRow("cs_atlas_seam", "ref_atlas_seam", CLAIM_MIRROR); + await assertAdminHtml( + "/admin/schema", + "A job holds this row, but its payment has no charge record.", + "cs_atlas_seam", + ); + }); }); }); diff --git a/test/shared/db/joint-state-scan.test.ts b/test/shared/db/joint-state-scan.test.ts new file mode 100644 index 0000000000..97f3b96e0d --- /dev/null +++ b/test/shared/db/joint-state-scan.test.ts @@ -0,0 +1,33 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { scanJointAnomalies } from "#shared/db/joint-state-scan.ts"; +import { CLAIM_MIRROR } from "#shared/payment/admit-move.ts"; +import { describeWithEnv } from "#test-utils/db.ts"; +import { plantArmedCharge, plantPaymentRow } from "#test-utils/joint-state.ts"; + +describeWithEnv("joint-state scan", { db: true }, () => { + test("finds nothing on a clean database", async () => { + expect(await scanJointAnomalies()).toEqual([]); + }); + + test("reports an armed send on a row nobody holds", async () => { + await plantPaymentRow("cs_scan_armed", "ref_scan_armed", ""); + await plantArmedCharge("ref_scan_armed"); + expect(await scanJointAnomalies()).toEqual([ + { key: "armed_without_claim", sessionId: "cs_scan_armed" }, + ]); + }); + + test("says nothing about an armed send under a held claim", async () => { + await plantPaymentRow("cs_scan_held", "ref_scan_held", CLAIM_MIRROR); + await plantArmedCharge("ref_scan_held"); + expect(await scanJointAnomalies()).toEqual([]); + }); + + test("reports a held row whose payment has no charge", async () => { + await plantPaymentRow("cs_scan_unbacked", "ref_scan_none", CLAIM_MIRROR); + expect(await scanJointAnomalies()).toEqual([ + { key: "claim_without_charge", sessionId: "cs_scan_unbacked" }, + ]); + }); +}); diff --git a/test/test-utils/joint-state.ts b/test/test-utils/joint-state.ts index a8a26db043..dd4be2979b 100644 --- a/test/test-utils/joint-state.ts +++ b/test/test-utils/joint-state.ts @@ -1,7 +1,9 @@ import { decrypt } from "#shared/crypto/encryption.ts"; +import { encryptWithOwnerKey } from "#shared/crypto/keys.ts"; import type { EnvKeyEncrypted } from "#shared/crypto/sealed.ts"; -import { queryAll } from "#shared/db/client.ts"; +import { execute, queryAll } from "#shared/db/client.ts"; import { paymentClaimRowsSql } from "#shared/db/payment-claim.ts"; +import { settings } from "#shared/db/settings.ts"; import { assertJointStateLegal, authorityFactOf, @@ -26,6 +28,51 @@ const SESSION_AND_SIBLINGS = `payment.payment_session_id = ? FROM processed_payments AS sibling WHERE sibling.payment_session_id = ?))`; +/** Store one payment row exactly as SQL sees it. The scan tests plant + * combinations no production writer can make, so they write the mirror + * columns directly instead of going through a flow. */ +export const plantPaymentRow = async ( + sessionId: string, + referenceIndex: string, + protectedState: string, +): Promise => { + await execute( + `INSERT INTO processed_payments + (payment_session_id, processed_at, protected_state, + payment_reference_index) + VALUES (?, ?, ?, ?)`, + [sessionId, "2026-08-17T10:00:00.000Z", protectedState, referenceIndex], + ); +}; + +/** Store one charge whose refund authority says a send may be out, shaped + * to pass the table's own JSON-mirror and ciphertext checks without a + * production writer. */ +export const plantArmedCharge = async ( + referenceIndex: string, +): Promise => { + const state = JSON.stringify({ + kind: "send_armed", + local: { kind: "not_due" }, + nextActionAt: 0, + request: { capability: "keyless" }, + }); + await execute( + `INSERT INTO payment_charges + (provider, provider_reference, reference_index, capability, + captured_amount, currency, refund_state, refund_state_name, + refund_local_state, next_refund_action_at, created_at, updated_at, + observed_at) + VALUES ('stripe', ?, ?, 'keyless', 100, 'GBP', ?, 'send_armed', + 'not_due', 0, 0, 0, 0)`, + [ + await encryptWithOwnerKey(referenceIndex, settings.publicKey), + referenceIndex, + state, + ], + ); +}; + /** * Load every stored machine one session touches and prove each row's * combination is one a flow can produce. Crash tests call this right after diff --git a/test/ui/templates/admin/schema-atlas.test.tsx b/test/ui/templates/admin/schema-atlas.test.tsx index 772ae2ab36..af7a52a218 100644 --- a/test/ui/templates/admin/schema-atlas.test.tsx +++ b/test/ui/templates/admin/schema-atlas.test.tsx @@ -7,7 +7,7 @@ import { adminSchemaAtlasPage } from "#templates/admin/schema-atlas.tsx"; import { setupAdminPageTest } from "#test-utils/admin-page-test.ts"; const html = (): string => - adminSchemaAtlasPage({ adminLevel: "owner" }, "light"); + adminSchemaAtlasPage({ adminLevel: "owner" }, "light", []); describe("the system map page", () => { beforeAll(async () => { @@ -70,6 +70,22 @@ describe("the system map page", () => { expect(page).toContain("One payment row's held work"); }); + test("the live check answers clean when the scan found nothing", () => { + const page = html(); + expect(page).toContain('id="schema-check"'); + expect(page).toContain("All stored payment records fit the rules."); + }); + + test("the live check lists each flagged record in plain words", () => { + const page = adminSchemaAtlasPage({ adminLevel: "owner" }, "light", [ + { key: "armed_without_claim", sessionId: "cs_seam" }, + ]); + expect(page).toContain( + "A refund is set to send, but no job holds this row. cs_seam", + ); + expect(page).not.toContain("All stored payment records fit the rules."); + }); + test("embeds the resolved diagram data as safe JSON", () => { const page = html(); const start = page.indexOf('