Optional expiresAt on the batch cleartext envelope, engine-side arrival-time comparison at the sync node, and an expired rejection through the existing batch-settlement path with the three properties permission_denied already satisfies.
- commit
- 87a2232c6e695890a7353e1880b415f84e727b19
- parent
- 994f08824b59da3542edfe9a1aaf5021bc9e66f2
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 91 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,91 @@
1+# Upstream ask: server-enforced write expiry (`expiresAt` on the cleartext envelope)2+3+Target: jazz / jazz-napi (engine + sync protocol). Consumer: lofi-node and the lofi effect system.4+5+## Motivation6+7+A local-first app wants to declare that a pending write past a deadline should retire and trigger8+compensation. Client-side retirement alone is unsound: the client can never prove a pending write is9+not already in flight, or was not delivered before a crash, so a purely local expiry can compensate10+and then watch the write sync anyway. If the write carries its deadline and the sync node refuses11+late arrivals, the guarantee inverts — once the client's clock passes the deadline plus the skew12+allowance, future acceptance is impossible by construction, which is exactly what makes local13+compensation safe, even for a device that never reconnects.14+15+## Requested surface16+17+1. **Optional `expiresAt` on the transaction/batch cleartext envelope.** Private transaction18+ payloads are encrypted, so the engine cannot inspect them; the deadline must live on the sealed19+ batch submission's cleartext envelope (alongside the batch digest and captured frontier), as an20+ optional client-asserted wall-clock timestamp (epoch milliseconds, UTC). Absent means "never21+ expires" — every existing write keeps its meaning. The field should be covered by the batch22+ digest so it cannot be stripped or altered in transit without invalidating the submission.23+24+2. **Engine-side comparison at the sync node.** On first authoritative adjudication of a batch, the25+ engine compares its own arrival time against `expiresAt` and rejects when26+ `arrival > expiresAt + tolerance`. Adjudication happens exactly once and the fate is persisted;27+ later fate queries return the persisted fate rather than re-running the clock comparison, so a28+ verdict can never flip. Server-to-server propagation of an already-accepted batch is never29+ re-adjudicated — expiry gates first acceptance, not replication.30+31+3. **Rejection code `expired` through the existing batch-settlement path.** The refusal surfaces as32+ a rejected batch fate with a stable machine-readable `code: "expired"` (reason free-text,33+ diagnostics only), with the same three properties `permission_denied` satisfies today:34+35+ - **Correlation**: the rejection carries the originating batch id, on both verdict surfaces36+ (`wait()` → `PersistedWriteRejectedError`, and the `onMutationError` fallback event).37+ - **Re-derivability**: a batch unacknowledged at disconnect is re-proposed on reconnect and the38+ rejection fires then; a disconnect defers the verdict, never swallows it.39+ - **Determinism**: waiting again on the batch re-derives the same code across reconnects, from40+ the persisted fate.41+42+ No new client machinery is needed: consumers already classify codes through a registry, and43+ `expired` classifies as permanent (late arrival is final; a re-send arrives later still).44+45+## Skew policy46+47+Deadlines are client-asserted wall-clock times, so enforcement is only as exact as the two clocks48+involved. Requested tolerance: **two minutes**, defined as an engine constant, not a per-deployment49+tuning knob (a tunable tolerance would let two nodes adjudicate the same late batch differently).50+Rationale: devices with OS time sync sit within seconds of true time; two minutes is a wide margin51+above realistic drift while keeping the client-safe give-up point close to the intent's deadline.52+53+The client-side soundness rule this yields: the server accepts until `expiresAt + tolerance` on its54+own clock, so a client granting its own clock the same allowance may treat the write as permanently55+unacceptable once its local clock passes `expiresAt + 2 × tolerance` — one tolerance for the56+server's acceptance window, one for the client's own possible offset.57+58+## Threat model59+60+Honest client only. `expiresAt` protects an app from its own stale intents (a write composed before61+a crash, a queue drained days later). It is **not** an integrity control: the writer asserts the62+deadline, so a malicious client omits it or writes a fresh one. Nothing about this feature should be63+documented as constraining hostile writers; that remains the permission system's job. Consequently a64+device whose clock is wrong by more than the tolerance only forfeits the guarantee for its own65+writes.66+67+## Minimal API sketch (consumer's perspective)68+69+```ts70+// Declaring a deadline on a write (any equivalent placement is fine — per-write71+// option, or a batch-level option; the envelope field is what matters):72+const handle = db.insert(app.orders, { ... }, { expiresAt: Date.now() + 60_000 });73+74+// Settlement, unchanged from today's shapes:75+try {76+ await handle.wait({ tier: "global" });77+} catch (e) {78+ // e instanceof PersistedWriteRejectedError79+ // e.batchId === handle.batchId80+ // e.code === "expired" // stable; reason is diagnostics only81+}82+83+// Fallback surface for un-awaited writes, unchanged:84+db.onMutationError((event) => {85+ // event.code === "expired"86+ // event.batch.batchId, event.batch.latestSettlement.code === "expired"87+});88+```89+90+Wire-level sketch: `SealedBatchSubmission` gains an optional `expires_at` (epoch ms), and91+`BatchFate::Rejected` carries `code: "expired"`. No other message changes.