clone

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

release/0.4.0
lofi-node / docs / upstream-write-expiry.md
upstream-write-expiry.md 6.3K rendered
1# Design record: engine-enforced write expiry (`expiresAt` on the submission envelope)23Reference spec for the vendor surface lofi would prefer if jazz offered it: an `expiresAt` field on4the sync submission envelope with an engine-enforced `expired` verdict. Recorded as5FelineStateMachine/lofi#145 (upstream wishes are labeled reference issues; nothing is filed with6jazz). Enforcement at the node layer is designed separately against the same contract in7[write-verdicts](site/write-verdicts.md#expiry).89Target surface: jazz / jazz-napi (engine + sync protocol). Consumer: lofi-node and the lofi effect10system.1112## Motivation1314A local-first app wants to declare that a pending write past a deadline should retire and trigger15compensation. Client-side retirement alone is unsound: the client can never prove a pending write is16not already in flight, or was not delivered before a crash, so a purely local expiry can compensate17and then watch the write sync anyway. If the write carries its deadline and the sync node refuses18late arrivals, the guarantee inverts — once the client's clock passes the deadline plus the skew19allowance, future acceptance is impossible by construction, which is exactly what makes local20compensation safe, even for a device that never reconnects.2122## Requested surface23241. **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.37382. **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.44453. **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:4849 - **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.5556 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).5859## Skew policy6061Deadlines are client-asserted wall-clock times, so enforcement is only as exact as the two clocks62involved. Requested tolerance: **two minutes**, defined as an engine constant, not a per-deployment63tuning knob (a tunable tolerance would let two nodes adjudicate the same late batch differently).64Rationale: devices with OS time sync sit within seconds of true time; two minutes is a wide margin65above realistic drift while keeping the client-safe give-up point close to the intent's deadline.6667The client-side soundness rule this yields: the server accepts until `expiresAt + tolerance` on its68own clock, so a client granting its own clock the same allowance may treat the write as permanently69unacceptable once its local clock passes `expiresAt + 2 × tolerance` — one tolerance for the70server's acceptance window, one for the client's own possible offset.7172## Threat model7374Honest client only. `expiresAt` protects an app from its own stale intents (a write composed before75a crash, a queue drained days later). It is **not** an integrity control: the writer asserts the76deadline, so a malicious client omits it or writes a fresh one. Nothing about this feature should be77documented as constraining hostile writers; that remains the permission system's job. Consequently a78device whose clock is wrong by more than the tolerance only forfeits the guarantee for its own79writes.8081## Minimal API sketch (consumer's perspective)8283```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):86const handle = db.insert(app.orders, { ... }, { expiresAt: Date.now() + 60_000 });8788// Settlement, unchanged from today's shapes:89try {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}9697// Fallback surface for un-awaited writes, unchanged:98db.onMutationError((event) => {99 // event.code === "expired"100 // event.batch.batchId, event.batch.latestSettlement.code === "expired"101});102```103104Wire-level sketch: `SealedBatchSubmission` gains an optional `expires_at` (epoch ms) covered by a v2105batch-digest manifest, and `BatchFate::Rejected` — whose `code` is an open string, not a closed106enum, 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 from108per-row parents) and plays no part in this design. No other message changes.