clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/write-expiry-groundwork
lofi-node / docs / site / write-verdicts.md
write-verdicts.md 11.0K rendered
1# Write verdicts23<!-- 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. -->56A lofi app treats every mutation as a durable write that eventually settles: accepted into the7store, or rejected by it. Rejection is what compensation logic (refunds, releasing holds) hangs on,8so its semantics are a contract of the node, not an implementation detail. This page states that9contract as it holds at the pinned Jazz alpha, verified by the lofi-node test suite.1011## Where verdicts come from1213Every write becomes a batch with a client-generated batch id. The store adjudicates the batch and14the client runtime records the outcome as the batch's fate. Two client surfaces carry a rejection:1516- **Awaited writes.** `write.wait({ tier })` rejects with a typed error carrying the originating17 `batchId`, a machine-readable `code`, and a human-readable `reason`.18- **Un-awaited writes.** A fallback `onMutationError` event fires with the same `code`/`reason` plus19 the full batch record (`batch.batchId`, `batch.latestSettlement`), for writes whose awaiter no20 longer exists.2122Both surfaces correlate to the originating write by batch id. The `reason` string is diagnostics23only; its wording changes between Jazz versions and must never be matched on.2425## Local refusal is not a verdict2627The client runtime enforces the policy it currently knows synchronously, at the call site: a write28that the local policy denies throws immediately and no batch ever exists. The store only adjudicates29writes made under a **stale** policy — accepted locally, then denied by a store whose permissions30tightened after the client last synced them. Author-facing effect handlers therefore see two31distinct failure shapes: a synchronous throw (a programming or policy error against known state) and32an asynchronous `rejected` verdict (the store overruled a write it had never seen).3334## Rejection properties3536Three properties hold, and the node's harness keeps them pinned:37381. **Correlation.** A rejected write surfaces its machine-readable code with the originating write's39 batch id, on both verdict surfaces.402. **Re-derivability.** A write unacknowledged at disconnect is re-proposed on reconnect, and the41 rejection fires then. A disconnect cannot swallow a verdict; it only defers it.423. **Determinism.** Waiting again on a rejected batch re-derives the same verdict, across further43 reconnects. An independent device issuing the same write against the same store state derives the44 same code.4546One boundary at the pinned alpha: re-derivability holds for direct writes (plain inserts, updates,47deletes). A **transactional batch committed while offline** does not re-submit its seal on48reconnect, so the store never adjudicates it — the write stays unapplied and waiting on it fails49with a runtime error rather than a verdict. Transactions committed while connected settle normally.5051## At-least-once, exactly-once: the consumer's duty5253Delivery is at-least-once from the store's point of view (unacked batches are re-proposed on every54reconnect) but the fallback event fires **once per runtime lifetime** — a later reconnect does not55re-fire events for batches already settled. A write journal must therefore:5657- persist the verdict when it first arrives, keyed by batch id;58- treat any duplicate delivery (for example after an app restart) as idempotent by batch id;59- re-derive a verdict on demand by waiting on the batch, not by expecting the event again.6061Compensation handlers keyed by batch id run at most once even when the verdict is observed twice.6263## The taxonomy: permanent or transient6465A consumer classifies every mutation-error code into one of two actions, using the classification66the package exports (`classifyMutationError`, `MUTATION_ERROR_CLASSES`):6768| Class | Meaning | Consumer action |69| ----------- | ---------------------------------------------------- | ------------------------------------------- |70| `permanent` | The store adjudicated the write and never accepts it | Verdict is `rejected`; compensation may run |71| `transient` | No adjudication happened (unreachable, timeout) | Write stays pending; keep waiting |7273The pinned alpha's store can emit four rejection codes over the sync protocol:7475| 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` |8182The registry also carries `expired`, classified permanent; its semantics are the83[expiry contract below](#expiry). Codes not in the registry classify as transient by design — an84unrecognized code must never trigger irreversible compensation, because waiting is recoverable and a85wrong `rejected` verdict is not. `invalid_batch_submission` stays unregistered on the same86principle: no rejection carrying it has been observed, so no classification has been demonstrated.87As the upstream code space grows, codes enter the registry here rather than in every consumer.8889## Expiry9091A write may declare a deadline: a wall-clock instant after which the app no longer wants it92accepted. This section states the expiry contract — what a declared deadline means, how refusal93surfaces, 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 store95rejects a late write, and the `expired` code is never emitted. The enforcement design is in progress96at the node layer. The taxonomy entry and everything below define the behavior any enforcement97commits to, so apps written against this page need no changes when it arrives.9899### The deadline is declared outside the payload, and client-asserted100101The deadline is asserted by the writing client as a wall-clock time and travels apart from the102write's row data, so that enforcement never depends on interpreting application data: whichever103component enforces expiry reads only the declared deadline and its own arrival clock, never what the104write does. That keeps the contract intact for columns an app seals client-side, and keeps105enforcement implementable by a component that does not evaluate application schemas at all.106107### The enforcement rule and the skew tolerance108109The enforcing component compares its own arrival time against the declared deadline and refuses the110write when111112```113arrival > expiresAt + tolerance114```115116The refusal surfaces as a rejected batch fate with `code: "expired"`, through the same settlement117path as `permission_denied`, with the same three properties: correlated to the originating batch id,118re-derived on reconnect, and deterministic. `expired` classifies as **permanent**: late arrival is119final, because waiting or re-sending only arrives later still.120121The tolerance is **two minutes**. Deadlines are client-asserted wall-clock times, so enforcement is122only as exact as the two clocks involved: devices with OS time sync sit within seconds of true time,123and two minutes is a wide margin above realistic drift while keeping the moment a client may safely124give up close to the intent's own deadline. A device whose clock is wrong by more than the tolerance125forfeits the guarantee for its own writes, which is consistent with the threat model below.126127### What expiry is for — and what it is not128129The threat model is the **honest client**. A deadline protects an app from its own stale intents: a130write composed before a crash, a queue drained days later, an offer that should not surface after131the window it was meant for. It is not an integrity control. The deadline is asserted by the writer,132so a malicious client simply omits it, or writes a fresh timestamp; nothing about expiry constrains133what a writer can do. Guarantees against hostile writers come from134[permissions](provision-a-store.md), never from expiry.135136### The soundness property for clients137138Client-side expiry alone is unsound: a client can never prove that a pending write is not already in139flight, or was not delivered before a crash, so compensating locally risks watching the write sync140anyway. Enforcement on the store's side of the connection inverts this. A late write is accepted141until `expiresAt` plus the tolerance on the enforcing clock, so once a client — granting its own142clock the same two-minute tolerance — observes its clock pass `expiresAt` plus twice the tolerance,143future acceptance is impossible by construction: one tolerance for the acceptance window, one for144the client's own possible offset. From that instant, retiring the write locally and running145compensation is safe even for a device that never reconnects.146147Until enforcement exists, none of this holds: a declared deadline is ignored, an expired-looking148pending write may still be accepted whenever the device reconnects, and clients must keep treating149such writes as pending.150151## Pending is not offline152153A transient-pending write needs one more input before an app can honestly say "waiting for your sync154node": whether the node is reachable at all. That signal is the155[`/health` endpoint](http-surface.md#health), available in every connection mode, composed156client-side with WebSocket lifecycle events into a single connection observable.