feat(verdict): write-expiry groundwork — expired taxonomy, contract docs, upstream ask
- commit
- 622044ff8522b36c8515fefb253a76e97b2b450f
- parent
- 3efb3159ec58cb56fca6e91edf3dfad211f523d9
- parent
- ad9317eb213b1e5473ea4381fe1400406d268f3c
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 95 | +++++++++- |
| A | docs/ | 108 | +++++++++++ |
| M | src/ | 52 | +++++- |
| M | tests/ | 27 | +++- |
| A | tests/ | 299 | ++++++++++++++++++++++++++++++ |
@@ -1,7 +1,7 @@
11 # Write verdicts22 3-<!-- Source: FelineStateMachine/lofi-node src/verdict.ts, tests/write_verdict_test.ts;4- jazz-tools batch-fate surface at the pinned alpha. -->3+<!-- Source: FelineStateMachine/lofi-node src/verdict.ts, tests/write_verdict_test.ts,4+ tests/write_conflict_test.ts; jazz-tools batch-fate surface at the pinned alpha. -->55 66 A lofi app treats every mutation as a durable write that eventually settles: accepted into the77 store, or rejected by it. Rejection is what compensation logic (refunds, releasing holds) hangs on,@@ -35,14 +35,19 @@ an asynchronous `rejected` verdict (the store overruled a write it had never see
3535 3636 Three properties hold, and the node's harness keeps them pinned:3737 38-1. **Correlation.** A permission-denied write surfaces `code: "permission_denied"` with the39- originating write's batch id, on both verdict surfaces.38+1. **Correlation.** A rejected write surfaces its machine-readable code with the originating write's39+ batch id, on both verdict surfaces.4040 2. **Re-derivability.** A write unacknowledged at disconnect is re-proposed on reconnect, and the4141 rejection fires then. A disconnect cannot swallow a verdict; it only defers it.4242 3. **Determinism.** Waiting again on a rejected batch re-derives the same verdict, across further4343 reconnects. An independent device issuing the same write against the same store state derives the4444 same code.4545 46+One boundary at the pinned alpha: re-derivability holds for direct writes (plain inserts, updates,47+deletes). A **transactional batch committed while offline** does not re-submit its seal on48+reconnect, so the store never adjudicates it — the write stays unapplied and waiting on it fails49+with a runtime error rather than a verdict. Transactions committed while connected settle normally.50+4651 ## At-least-once, exactly-once: the consumer's duty4752 4853 Delivery is at-least-once from the store's point of view (unacked batches are re-proposed on every@@ -65,11 +70,83 @@ the package exports (`classifyMutationError`, `MUTATION_ERROR_CLASSES`):
6570 | `permanent` | The store adjudicated the write and never accepts it | Verdict is `rejected`; compensation may run |6671 | `transient` | No adjudication happened (unreachable, timeout) | Write stays pending; keep waiting |6772 68-The pinned alpha emits exactly one rejection code over the sync protocol: `permission_denied`,69-classified permanent. Codes not in the registry classify as transient by design — an unrecognized70-code must never trigger irreversible compensation, because waiting is recoverable and a wrong71-`rejected` verdict is not. As the upstream code space grows (validation, schema enforcement), codes72-enter the registry here rather than in every consumer.73+The pinned alpha's store can emit four rejection codes over the sync protocol:74+75+| Code | Store meaning | Class |76+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |77+| `permission_denied` | The store adjudicated the write and refused it: policy denial under a stale client policy, content that fails the store's column validation, or schema-resolution failure. The `reason` distinguishes them; the class does not. | `permanent` |78+| `transaction_conflict` | A transactional batch staged against a row whose visible frontier moved before the store validated the seal. The store never rebases or retries the losing batch; the row converges to the winner. | `permanent` |79+| `permissions_head_missing` | The store enforces permissions but none were ever published for the app (schema deployed without a permissions head). Publishing permissions later does not revisit the fate; only new batches see the new head. | `permanent` |80+| `invalid_batch_submission` | The sealed submission itself is malformed. Not producible through the public API: every trigger is an envelope invariant the client runtime computes itself, and the one author-reachable shape (committing an empty transaction) throws synchronously before any batch exists. | unregistered, so `transient` |81+82+The registry also carries `expired`, classified permanent; its semantics are the83+[expiry contract below](#expiry). Codes not in the registry classify as transient by design — an84+unrecognized code must never trigger irreversible compensation, because waiting is recoverable and a85+wrong `rejected` verdict is not. `invalid_batch_submission` stays unregistered on the same86+principle: no rejection carrying it has been observed, so no classification has been demonstrated.87+As the upstream code space grows, codes enter the registry here rather than in every consumer.88+89+## Expiry90+91+A write may declare a deadline: a wall-clock instant after which the app no longer wants it92+accepted. This section states the expiry contract — what a declared deadline means, how refusal93+surfaces, and when a client may safely give up — independent of which component enforces it.94+**Nothing enforces it at the pinned Jazz alpha**: no deadline travels on the wire today, no store95+rejects a late write, and the `expired` code is never emitted. The enforcement design is in progress96+at the node layer. The taxonomy entry and everything below define the behavior any enforcement97+commits to, so apps written against this page need no changes when it arrives.98+99+### The deadline is declared outside the payload, and client-asserted100+101+The deadline is asserted by the writing client as a wall-clock time and travels apart from the102+write's row data, so that enforcement never depends on interpreting application data: whichever103+component enforces expiry reads only the declared deadline and its own arrival clock, never what the104+write does. That keeps the contract intact for columns an app seals client-side, and keeps105+enforcement implementable by a component that does not evaluate application schemas at all.106+107+### The enforcement rule and the skew tolerance108+109+The enforcing component compares its own arrival time against the declared deadline and refuses the110+write when111+112+```113+arrival > expiresAt + tolerance114+```115+116+The refusal surfaces as a rejected batch fate with `code: "expired"`, through the same settlement117+path as `permission_denied`, with the same three properties: correlated to the originating batch id,118+re-derived on reconnect, and deterministic. `expired` classifies as **permanent**: late arrival is119+final, because waiting or re-sending only arrives later still.120+121+The tolerance is **two minutes**. Deadlines are client-asserted wall-clock times, so enforcement is122+only as exact as the two clocks involved: devices with OS time sync sit within seconds of true time,123+and two minutes is a wide margin above realistic drift while keeping the moment a client may safely124+give up close to the intent's own deadline. A device whose clock is wrong by more than the tolerance125+forfeits the guarantee for its own writes, which is consistent with the threat model below.126+127+### What expiry is for — and what it is not128+129+The threat model is the **honest client**. A deadline protects an app from its own stale intents: a130+write composed before a crash, a queue drained days later, an offer that should not surface after131+the window it was meant for. It is not an integrity control. The deadline is asserted by the writer,132+so a malicious client simply omits it, or writes a fresh timestamp; nothing about expiry constrains133+what a writer can do. Guarantees against hostile writers come from134+[permissions](provision-a-store.md), never from expiry.135+136+### The soundness property for clients137+138+Client-side expiry alone is unsound: a client can never prove that a pending write is not already in139+flight, or was not delivered before a crash, so compensating locally risks watching the write sync140+anyway. Enforcement on the store's side of the connection inverts this. A late write is accepted141+until `expiresAt` plus the tolerance on the enforcing clock, so once a client — granting its own142+clock the same two-minute tolerance — observes its clock pass `expiresAt` plus twice the tolerance,143+future acceptance is impossible by construction: one tolerance for the acceptance window, one for144+the client's own possible offset. From that instant, retiring the write locally and running145+compensation is safe even for a device that never reconnects.146+147+Until enforcement exists, none of this holds: a declared deadline is ignored, an expired-looking148+pending write may still be accepted whenever the device reconnects, and clients must keep treating149+such writes as pending.73150 74151 ## Pending is not offline75152 @@ -0,0 +1,108 @@
1+# Design record: engine-enforced write expiry (`expiresAt` on the submission envelope)2+3+Reference spec for the vendor surface lofi would prefer if jazz offered it: an `expiresAt` field on4+the sync submission envelope with an engine-enforced `expired` verdict. Recorded as5+FelineStateMachine/lofi#145 (upstream wishes are labeled reference issues; nothing is filed with6+jazz). Enforcement at the node layer is designed separately against the same contract in7+[write-verdicts](site/write-verdicts.md#expiry).8+9+Target surface: jazz / jazz-napi (engine + sync protocol). Consumer: lofi-node and the lofi effect10+system.11+12+## Motivation13+14+A local-first app wants to declare that a pending write past a deadline should retire and trigger15+compensation. Client-side retirement alone is unsound: the client can never prove a pending write is16+not already in flight, or was not delivered before a crash, so a purely local expiry can compensate17+and then watch the write sync anyway. If the write carries its deadline and the sync node refuses18+late arrivals, the guarantee inverts — once the client's clock passes the deadline plus the skew19+allowance, future acceptance is impossible by construction, which is exactly what makes local20+compensation safe, even for a device that never reconnects.21+22+## Requested surface23+24+1. **Optional `expiresAt` on the sealed batch submission envelope.** The deadline belongs on the25+ envelope, not in row data: enforcement must read only the declared deadline and an arrival clock,26+ never interpret application payloads (which may include client-sealed columns). At the pinned27+ alpha the envelope is28+ `SealedBatchSubmission { batch_id, mode, target_branch_name,29+ batch_digest, members, captured_frontier }`30+ — no expiry or deadline field exists anywhere in the submission path. The design adds31+ `expires_at` as an optional client-asserted wall-clock timestamp (epoch milliseconds, UTC).32+ Absent means "never expires" — every existing write keeps its meaning. The field must be covered33+ by the batch digest so it cannot be stripped or altered in transit without invalidating the34+ submission; today the digest is computed over the member manifest only (a domain-tagged hash of35+ member count, object ids, and row digests), so covering `expires_at` means a new manifest version36+ in the digest preimage.37+38+2. **Engine-side comparison at the sync node.** On first authoritative adjudication of a batch, the39+ engine compares its own arrival time against `expiresAt` and rejects when40+ `arrival > expiresAt + tolerance`. Adjudication happens exactly once and the fate is persisted;41+ later fate queries return the persisted fate rather than re-running the clock comparison, so a42+ verdict can never flip. Server-to-server propagation of an already-accepted batch is never43+ re-adjudicated — expiry gates first acceptance, not replication.44+45+3. **Rejection code `expired` through the existing batch-settlement path.** The refusal surfaces as46+ a rejected batch fate with a stable machine-readable `code: "expired"` (reason free-text,47+ diagnostics only), with the same three properties `permission_denied` satisfies today:48+49+ - **Correlation**: the rejection carries the originating batch id, on both verdict surfaces50+ (`wait()` → `PersistedWriteRejectedError`, and the `onMutationError` fallback event).51+ - **Re-derivability**: a batch unacknowledged at disconnect is re-proposed on reconnect and the52+ rejection fires then; a disconnect defers the verdict, never swallows it.53+ - **Determinism**: waiting again on the batch re-derives the same code across reconnects, from54+ the persisted fate.55+56+ No new client machinery is needed: consumers already classify codes through a registry, and57+ `expired` classifies as permanent (late arrival is final; a re-send arrives later still).58+59+## Skew policy60+61+Deadlines are client-asserted wall-clock times, so enforcement is only as exact as the two clocks62+involved. Requested tolerance: **two minutes**, defined as an engine constant, not a per-deployment63+tuning knob (a tunable tolerance would let two nodes adjudicate the same late batch differently).64+Rationale: devices with OS time sync sit within seconds of true time; two minutes is a wide margin65+above realistic drift while keeping the client-safe give-up point close to the intent's deadline.66+67+The client-side soundness rule this yields: the server accepts until `expiresAt + tolerance` on its68+own clock, so a client granting its own clock the same allowance may treat the write as permanently69+unacceptable once its local clock passes `expiresAt + 2 × tolerance` — one tolerance for the70+server's acceptance window, one for the client's own possible offset.71+72+## Threat model73+74+Honest client only. `expiresAt` protects an app from its own stale intents (a write composed before75+a crash, a queue drained days later). It is **not** an integrity control: the writer asserts the76+deadline, so a malicious client omits it or writes a fresh one. Nothing about this feature should be77+documented as constraining hostile writers; that remains the permission system's job. Consequently a78+device whose clock is wrong by more than the tolerance only forfeits the guarantee for its own79+writes.80+81+## Minimal API sketch (consumer's perspective)82+83+```ts84+// Declaring a deadline on a write (any equivalent placement is fine — per-write85+// option, or a batch-level option; the envelope field is what matters):86+const handle = db.insert(app.orders, { ... }, { expiresAt: Date.now() + 60_000 });87+88+// Settlement, unchanged from today's shapes:89+try {90+ await handle.wait({ tier: "global" });91+} catch (e) {92+ // e instanceof PersistedWriteRejectedError93+ // e.batchId === handle.batchId94+ // e.code === "expired" // stable; reason is diagnostics only95+}96+97+// Fallback surface for un-awaited writes, unchanged:98+db.onMutationError((event) => {99+ // event.code === "expired"100+ // event.batch.batchId, event.batch.latestSettlement.code === "expired"101+});102+```103+104+Wire-level sketch: `SealedBatchSubmission` gains an optional `expires_at` (epoch ms) covered by a v2105+batch-digest manifest, and `BatchFate::Rejected` — whose `code` is an open string, not a closed106+enum, so no wire-format change is needed for the new code — carries `"expired"`. The107+`captured_frontier` field is compatibility-only at the pinned alpha (conflicts are validated from108+per-row parents) and plays no part in this design. No other message changes.@@ -7,8 +7,7 @@
77 // between Jazz versions. The code is the contract, and this module is the one88 // place that maps codes onto the two actions a consumer can take:99 //10-// - `permanent`: the store has adjudicated the write and will never accept it11-// (permission denied, and any future validation/schema-enforcement codes).10+// - `permanent`: the store has adjudicated the write and will never accept it.1211 // The write's verdict is `rejected`; compensation may run.1312 // - `transient`: the outcome is not adjudicated (store unreachable, timeout,1413 // ticket hiccup). The write stays pending; keep waiting and retrying.@@ -23,14 +22,53 @@ export type MutationErrorClass = "permanent" | "transient";
2322 /**2423 * The registry of known mutation-error codes.2524 *26- * The pinned Jazz alpha emits exactly one rejection code over the sync27- * protocol: `permission_denied` (policy denies the operation on the table for28- * this session). Writes that violate the CURRENT locally-known policy or29- * schema never produce a code at all — the client runtime refuses them30- * synchronously at the call site, before any batch exists.25+ * The pinned Jazz alpha's server can emit exactly four rejection codes26+ * through the batch-settlement path (source: `jazz-tools` crate at the27+ * alpha.53 publish commit; the code space is the set of string literals fed28+ * into `BatchFate::Rejected`, not a closed enum):29+ *30+ * - `permission_denied` — the store adjudicated the write and refused it.31+ * Despite the name this is the server's general write-denial code: policy32+ * denial under a stale client policy, content that fails the server's33+ * column validation, and schema-resolution failures all surface under it34+ * (the `reason` differs; the classification does not). Permanent.35+ * - `transaction_conflict` — a transactional batch whose staged row's36+ * visible parent frontier moved before the authority validated the seal.37+ * Observed in the harness: the fate is final and deterministic across38+ * reconnects; the engine does not rebase or retry. A retry is a new batch.39+ * Permanent.40+ * - `permissions_head_missing` — the store enforces permissions but has no41+ * published permissions head (schema deployed without permissions).42+ * Observed in the harness: a persisted, batch-correlated rejection on both43+ * verdict surfaces. Publishing permissions later does not revisit the44+ * fate. Permanent.45+ * - `invalid_batch_submission` — the sealed submission itself is malformed46+ * (empty member list, digest mismatch, rows off the declared branch,47+ * invalid row states, mixed direct/transactional rows). Every trigger is48+ * an envelope invariant the client runtime itself computes, and the one49+ * author-reachable shape (committing an empty transaction) throws50+ * synchronously at the call site — so the code is unreachable from the51+ * public API by construction. It stays out of the registry (classifying52+ * as transient) until a rejection can actually be observed: an unprovoked53+ * permanent entry could authorize compensation on a fate whose semantics54+ * were never demonstrated.55+ *56+ * Writes that violate the CURRENT locally-known policy or schema never57+ * produce a code at all — the client runtime refuses them synchronously at58+ * the call site, before any batch exists.59+ *60+ * `expired` (the store refused the write because it arrived past its declared61+ * deadline) is registered permanent: late arrival is final, since waiting or62+ * re-sending only arrives later still. No store emits it yet, so the entry is63+ * inert until one does. The pinned alpha does use the string `expired` on a64+ * different surface — the transport's unauthenticated response, for a65+ * rejected JWT — but that surface never reaches this classifier.3166 */3267 export const MUTATION_ERROR_CLASSES: Readonly<Record<string, MutationErrorClass>> = {3368 permission_denied: "permanent",69+ transaction_conflict: "permanent",70+ permissions_head_missing: "permanent",71+ expired: "permanent",3472 };3573 3674 /**@@ -14,8 +14,33 @@ Deno.test("verdict: permission_denied is permanent", () => {
1414 assert(isPermanentMutationError("permission_denied"));1515 });1616 17+Deno.test("verdict: transaction_conflict is permanent", () => {18+ assertEquals(classifyMutationError("transaction_conflict"), "permanent");19+ assert(isPermanentMutationError("transaction_conflict"));20+});21+22+Deno.test("verdict: permissions_head_missing is permanent", () => {23+ assertEquals(classifyMutationError("permissions_head_missing"), "permanent");24+ assert(isPermanentMutationError("permissions_head_missing"));25+});26+27+Deno.test("verdict: expired is permanent", () => {28+ assertEquals(classifyMutationError("expired"), "permanent");29+ assert(isPermanentMutationError("expired"));30+});31+32+Deno.test("verdict: invalid_batch_submission stays unregistered (transient)", () => {33+ // Unreachable from the public API by construction (the registry doc comment34+ // records why); it must not classify permanent until a rejection carrying it35+ // has actually been observed.36+ assertEquals(classifyMutationError("invalid_batch_submission"), "transient");37+ assertEquals(isPermanentMutationError("invalid_batch_submission"), false);38+});39+1740 Deno.test("verdict: unknown codes classify transient", () => {18- for (const code of ["", "store_unreachable", "some_future_code", "PERMISSION_DENIED"]) {41+ for (42+ const code of ["", "store_unreachable", "some_future_code", "PERMISSION_DENIED", "EXPIRED"]43+ ) {1944 assertEquals(classifyMutationError(code), "transient", `code ${JSON.stringify(code)}`);2045 assertEquals(isPermanentMutationError(code), false);2146 }@@ -0,0 +1,299 @@
1+// Behavioral coverage for the two batch-fate codes beyond permission_denied2+// that the pinned alpha's server can emit (see src/verdict.ts for the full3+// code-space audit):4+//5+// - `transaction_conflict`: two devices stage a transactional update to the6+// same row from the same visible frontier; the authority accepts the first7+// seal and rejects the second ("row visible parent changed since transaction8+// write was staged"). The fate is final — the engine does not rebase or9+// retry the losing batch — and deterministic across reconnects.10+// - `permissions_head_missing`: a store whose schema is deployed without a11+// permissions head fails closed and rejects every write, on both verdict12+// surfaces, with the batch correlated. Publishing permissions afterwards13+// does not revisit the persisted fate; only new batches see the new head.14+//15+// `invalid_batch_submission` has no test here because it is unreachable from16+// the public API: every trigger is a sealed-envelope invariant the client17+// runtime itself computes, and the one author-reachable shape — committing an18+// empty transaction — throws synchronously before any batch exists (pinned19+// below).20+//21+// Scope note: the conflict is staged ONLINE. A transactional batch committed22+// while offline does not re-submit its seal on reconnect at the pinned alpha,23+// so the authority never adjudicates it — the write stays unapplied and24+// `wait()` fails with a runtime error rather than a verdict. That degradation25+// is an upstream client defect at the pin, not a settlement contract, so it26+// is documented here rather than pinned as a test.27+//28+// Schemas are defined INSIDE the test fns: jazz registers schemas process-29+// globally at definition time, and module-level definitions would clobber30+// other test files' declared hashes.31+32+import { assert, assertEquals, assertThrows } from "@std/assert";33+import { createDb, schema as s } from "jazz-tools";34+import { deploy } from "jazz-tools/testing";35+import { definePermissions } from "jazz-tools/permissions";36+import { createSyncNode } from "../src/node.ts";37+import { classifyMutationError } from "../src/verdict.ts";38+39+interface MutationErrorEvent {40+ code: string;41+ reason: string;42+ batch: {43+ batchId: string;44+ mode: string;45+ sealed: boolean;46+ latestSettlement: { kind: string; batchId: string; code?: string; reason?: string } | null;47+ };48+}49+50+interface Rejection {51+ outcome: "rejected";52+ name?: string;53+ batchId?: string;54+ code?: string;55+ reason?: string;56+}57+58+type Verdict = Rejection | { outcome: "settled" };59+60+function accountSecret(fill: number): string {61+ let binary = "";62+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);63+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");64+}65+66+async function within<T>(operation: Promise<T>, label: string, milliseconds = 20_000): Promise<T> {67+ let timer: ReturnType<typeof setTimeout> | undefined;68+ try {69+ return await Promise.race([70+ operation,71+ new Promise<never>((_resolve, reject) => {72+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);73+ }),74+ ]);75+ } finally {76+ if (timer !== undefined) clearTimeout(timer);77+ }78+}79+80+/** Settle a write handle into a plain verdict object. */81+function settle(handle: { wait(options: { tier: "global" }): Promise<unknown> }): Promise<Verdict> {82+ return handle.wait({ tier: "global" }).then(83+ (): Verdict => ({ outcome: "settled" }),84+ (e: Error & Partial<Rejection>): Verdict => ({85+ outcome: "rejected",86+ name: e.name,87+ batchId: e.batchId,88+ code: e.code,89+ reason: e.reason,90+ }),91+ );92+}93+94+async function pollUntil(95+ label: string,96+ predicate: () => Promise<boolean>,97+ milliseconds = 20_000,98+): Promise<void> {99+ const deadline = Date.now() + milliseconds;100+ while (Date.now() < deadline) {101+ if (await predicate()) return;102+ await new Promise((r) => setTimeout(r, 300));103+ }104+ throw new Error(`${label} did not become true within ${milliseconds}ms`);105+}106+107+Deno.test({108+ name:109+ "write verdict: a losing concurrent transaction settles transaction_conflict, final and deterministic",110+ sanitizeOps: false,111+ sanitizeResources: false,112+ fn: async () => {113+ const tables = { conflict__rows: s.table({ text: s.string() }) };114+ const app = s.defineApp(tables);115+ const permissions = definePermissions(app, ({ policy, session }) => [116+ policy.conflict__rows.allowRead.where({ $createdBy: session.userId }),117+ policy.conflict__rows.allowInsert.where({ $createdBy: session.userId }),118+ policy.conflict__rows.allowUpdate119+ .whereOld({ $createdBy: session.userId })120+ .whereNew({ $createdBy: session.userId }),121+ ]);122+123+ const node = await createSyncNode({124+ appId: crypto.randomUUID(),125+ backendSecret: "lofi_backend_conflict",126+ adminSecret: "lofi_admin_conflict",127+ inMemory: true,128+ mesh: "off",129+ });130+ const target = {131+ appId: node.appId,132+ serverUrl: node.url.replace(/^ws/, "http"),133+ adminSecret: "lofi_admin_conflict",134+ };135+ try {136+ await deploy({ ...target, schema: app, permissions });137+ const secret = accountSecret(34);138+ const connect = () =>139+ createDb({140+ appId: node.appId,141+ serverUrl: target.serverUrl,142+ secret,143+ userBranch: "main",144+ driver: { type: "memory" },145+ });146+ const deviceA = await connect();147+ const deviceB = await connect();148+ try {149+ const seed = await within(150+ deviceA.insert(app.conflict__rows, { text: "seed" }).wait({ tier: "global" }),151+ "seed write settles",152+ );153+ await pollUntil(154+ "device B observes the seed",155+ async () =>156+ (await deviceB.all(app.conflict__rows.where({ id: seed.id }), { tier: "global" }))157+ .length === 1,158+ );159+160+ // Both devices stage a transactional update to the same row from the161+ // same visible frontier, while connected.162+ const txA = deviceA.beginTransaction();163+ txA.update(app.conflict__rows, seed.id, { text: "A wins" });164+ const txB = deviceB.beginTransaction();165+ txB.update(app.conflict__rows, seed.id, { text: "B loses" });166+167+ // A's seal reaches the authority first and advances the frontier.168+ const handleA = txA.commit();169+ assertEquals((await within(settle(handleA), "A's transaction settles")).outcome, "settled");170+171+ // B's seal validates against a frontier that has moved.172+ const handleB = txB.commit();173+ assert(handleA.batchId !== handleB.batchId, "distinct batches");174+ const verdict = await within(settle(handleB), "B's transaction settles");175+ assertEquals(verdict.outcome, "rejected");176+ const rejection = verdict as Rejection;177+ assertEquals(rejection.name, "PersistedWriteRejectedError");178+ assertEquals(rejection.code, "transaction_conflict");179+ assertEquals(rejection.batchId, handleB.batchId, "verdict correlates to the losing batch");180+ assert(rejection.reason && rejection.reason.length > 0, "reason present (diagnostics)");181+ assertEquals(classifyMutationError(rejection.code!), "permanent");182+183+ // The fate is final and deterministic: re-waiting across a reconnect184+ // re-derives the same code, and the engine never rebases or retries185+ // the losing write — the row converges to the winner everywhere.186+ await deviceB.disconnect();187+ await deviceB.reconnect();188+ const again = await within(settle(handleB), "re-derived verdict after reconnect");189+ assertEquals((again as Rejection).code, "transaction_conflict");190+ assertEquals((again as Rejection).batchId, handleB.batchId);191+ await pollUntil("device B converges on the winning write", async () => {192+ const rows = await deviceB.all(app.conflict__rows.where({ id: seed.id }), {193+ tier: "global",194+ });195+ return rows.length === 1 && rows[0].text === "A wins";196+ });197+ const onA = await deviceA.all(app.conflict__rows.where({ id: seed.id }), {198+ tier: "global",199+ });200+ assertEquals(onA[0].text, "A wins");201+202+ // The one author-reachable malformed-submission shape never leaves203+ // the client: committing an empty transaction throws synchronously,204+ // so `invalid_batch_submission` cannot be provoked from this surface.205+ assertThrows(() => deviceA.beginTransaction().commit());206+ } finally {207+ await Promise.allSettled([208+ within(deviceA.logout(), "device A cleanup", 3000),209+ within(deviceB.logout(), "device B cleanup", 3000),210+ ]);211+ }212+ } finally {213+ await node.stop();214+ }215+ },216+});217+218+Deno.test({219+ name:220+ "write verdict: a store without a permissions head rejects writes with permissions_head_missing",221+ sanitizeOps: false,222+ sanitizeResources: false,223+ fn: async () => {224+ const tables = { headless__rows: s.table({ text: s.string() }) };225+ const app = s.defineApp(tables);226+ const permissions = definePermissions(app, ({ policy, session }) => [227+ policy.headless__rows.allowRead.where({ $createdBy: session.userId }),228+ policy.headless__rows.allowInsert.where({ $createdBy: session.userId }),229+ ]);230+231+ const node = await createSyncNode({232+ appId: crypto.randomUUID(),233+ backendSecret: "lofi_backend_headless",234+ adminSecret: "lofi_admin_headless",235+ inMemory: true,236+ mesh: "off",237+ });238+ const target = {239+ appId: node.appId,240+ serverUrl: node.url.replace(/^ws/, "http"),241+ adminSecret: "lofi_admin_headless",242+ };243+ try {244+ // Schema only — no permissions head. The store fails closed.245+ await deploy({ ...target, schema: app });246+ const device = await createDb({247+ appId: node.appId,248+ serverUrl: target.serverUrl,249+ secret: accountSecret(35),250+ userBranch: "main",251+ driver: { type: "memory" },252+ });253+ try {254+ const events: MutationErrorEvent[] = [];255+ device.onMutationError((event) => events.push(event as MutationErrorEvent));256+257+ const awaited = device.insert(app.headless__rows, { text: "awaited" });258+ const unawaited = device.insert(app.headless__rows, { text: "un-awaited" });259+260+ // Awaited surface: typed rejection, batch-correlated.261+ const verdict = await within(settle(awaited), "awaited write settles");262+ assertEquals(verdict.outcome, "rejected");263+ const rejection = verdict as Rejection;264+ assertEquals(rejection.name, "PersistedWriteRejectedError");265+ assertEquals(rejection.code, "permissions_head_missing");266+ assertEquals(rejection.batchId, awaited.batchId);267+ assertEquals(classifyMutationError(rejection.code!), "permanent");268+269+ // Fallback surface for the un-awaited write: same code, full batch270+ // record with the persisted settlement.271+ await pollUntil(272+ "onMutationError fires for the un-awaited write",273+ () => Promise.resolve(events.some((e) => e.batch.batchId === unawaited.batchId)),274+ );275+ const event = events.find((e) => e.batch.batchId === unawaited.batchId)!;276+ assertEquals(event.code, "permissions_head_missing");277+ assertEquals(event.batch.latestSettlement?.kind, "rejected");278+ assertEquals(event.batch.latestSettlement?.code, "permissions_head_missing");279+280+ // Publishing permissions afterwards does not revisit the persisted281+ // fate; a new batch under the new head settles.282+ await deploy({ ...target, schema: app, permissions });283+ const after = await within(settle(awaited), "re-derived verdict after the head appears");284+ assertEquals((after as Rejection).code, "permissions_head_missing");285+ await pollUntil("a fresh write settles under the new head", async () => {286+ const verdict = await within(287+ settle(device.insert(app.headless__rows, { text: "fresh" })),288+ "fresh write settles",289+ );290+ return verdict.outcome === "settled";291+ });292+ } finally {293+ await within(device.logout(), "device cleanup", 3000).catch(() => {});294+ }295+ } finally {296+ await node.stop();297+ }298+ },299+});