clone

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

feat/write-verdict-foundations
lofi-node / docs / site / write-verdicts.md
write-verdicts.md 4.6K rendered
1# Write verdicts23<!-- Source: FelineStateMachine/lofi-node src/verdict.ts, tests/write_verdict_test.ts;4 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 permission-denied write surfaces `code: "permission_denied"` with the39 originating write's 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.4546## At-least-once, exactly-once: the consumer's duty4748Delivery is at-least-once from the store's point of view (unacked batches are re-proposed on every49reconnect) but the fallback event fires **once per runtime lifetime** — a later reconnect does not50re-fire events for batches already settled. A write journal must therefore:5152- persist the verdict when it first arrives, keyed by batch id;53- treat any duplicate delivery (for example after an app restart) as idempotent by batch id;54- re-derive a verdict on demand by waiting on the batch, not by expecting the event again.5556Compensation handlers keyed by batch id run at most once even when the verdict is observed twice.5758## The taxonomy: permanent or transient5960A consumer classifies every mutation-error code into one of two actions, using the classification61the package exports (`classifyMutationError`, `MUTATION_ERROR_CLASSES`):6263| Class | Meaning | Consumer action |64| ----------- | ---------------------------------------------------- | ------------------------------------------- |65| `permanent` | The store adjudicated the write and never accepts it | Verdict is `rejected`; compensation may run |66| `transient` | No adjudication happened (unreachable, timeout) | Write stays pending; keep waiting |6768The pinned alpha emits exactly one rejection code over the sync protocol: `permission_denied`,69classified permanent. Codes not in the registry classify as transient by design — an unrecognized70code 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), codes72enter the registry here rather than in every consumer.7374## Pending is not offline7576A transient-pending write needs one more input before an app can honestly say "waiting for your sync77node": whether the node is reachable at all. That signal is the78[`/health` endpoint](http-surface.md#health), available in every connection mode, composed79client-side with WebSocket lifecycle events into a single connection observable.