clone

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

docs(site): write verdicts and the health/connection contract

Dami committed 7a7975f
New write-verdicts page: where verdicts come from, local refusal vs server
adjudication, the three rejection properties, at-least-once implications for
consumers, and the permanent/transient taxonomy. The HTTP surface page gains
the /health contract per connection mode and the client-side composition into
a connection observable.
commit
7a7975f7c4b6719d1d9e3dd721d92ffa46d30a62
parent
23e82a1305713535a22a12bd136e8722658b79f0
author
Dami <dami.etoile@gmail.com>
3 files changed, +113 -8
Mdocs/site/README.md2+
Mdocs/site/http-surface.md40++++++++++++---
Adocs/site/write-verdicts.md79++++++++++++++++++++++++++++++
M docs/site/README.md +2 -0
M docs/site/http-surface.md +32 -8
@@ -9,18 +9,42 @@ loopback-only Jazz server. This page is the route-level reference for a **ticket
99 1010 ## Routes1111 12-| Route | Ticket | Behavior |13-| ------------------------------------- | ----------- | ------------------------------------------------------------------------- |14-| `GET /health` | none | Liveness; proxied to Jazz. `200`, or `502` when the store is unreachable. |15-| `GET /t/<secret>/store-status` | any scope | Metadata-only schema preflight (below). |16-| `/t/<secret>/apps/<appId>/ws` | any scope | WebSocket sync; re-originated toward Jazz with subprotocol forwarded. |17-| `/t/<secret>/apps/<appId>/…` | any scope | Catalogue reads and other app routes, prefix-stripped and proxied. |18-| `/t/<secret>/apps/<appId>/admin/…` | `provision` | Store administration; the node's admin secret is injected server-side. |19-| `POST /t/<secret>/derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below). |12+| Route | Ticket | Behavior |13+| ------------------------------------- | ----------- | ---------------------------------------------------------------------- |14+| `GET /health` | none | Liveness (below). `200`, or `502` when the store is unreachable. |15+| `GET /t/<secret>/health` | any scope | Same liveness under a ticket base; `401` once the ticket is revoked. |16+| `GET /t/<secret>/store-status` | any scope | Metadata-only schema preflight (below). |17+| `/t/<secret>/apps/<appId>/ws` | any scope | WebSocket sync; re-originated toward Jazz with subprotocol forwarded. |18+| `/t/<secret>/apps/<appId>/…` | any scope | Catalogue reads and other app routes, prefix-stripped and proxied. |19+| `/t/<secret>/apps/<appId>/admin/…` | `provision` | Store administration; the node's admin secret is injected server-side. |20+| `POST /t/<secret>/derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below). |2021 2122 The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,2223 strips the prefix, preserves the query string, and forwards.2324 25+## health26+27+```jsonc28+// 200 — the store answered29+{ "status": "healthy" }30+```31+32+One liveness contract, every connection mode:33+34+- **Open mode** — Jazz serves `/health` directly on the public URL.35+- **Ticket mode** — the gate proxies it, unauthenticated at the top level (reachable before36+ enrollment) and under any valid ticket base, so `serverUrl`-relative polling works unchanged. A37+ revoked ticket gets `401` on its own base while the top level stays `200`: "node up, access gone"38+ stays distinguishable from "node unreachable".39+- **Peer mode** — the request rides the iroh tunnel to the peer's Jazz like any other HTTP.40+41+Responses are cheap in all modes (one loopback hop at most) and never hang on a dead store: the gate42+answers `502` when Jazz is unreachable. The client contract is composition, not a new primitive — an43+app derives its single connection observable from WebSocket lifecycle events (connect, close, auth44+failure) plus periodic `/health` polls, and uses it to label pending writes `offline` rather than45+leaving them silently waiting. Verdict semantics for those pending writes are on the46+[write verdicts](write-verdicts.md) page.47+2448 ## store-status2549 2650 ```jsonc
A docs/site/write-verdicts.md +79 -0
@@ -0,0 +1,79 @@
1+# Write verdicts2+3+<!-- Source: FelineStateMachine/lofi-node src/verdict.ts, tests/write_verdict_test.ts;4+ jazz-tools batch-fate surface at the pinned alpha. -->5+6+A lofi app treats every mutation as a durable write that eventually settles: accepted into the7+store, or rejected by it. Rejection is what compensation logic (refunds, releasing holds) hangs on,8+so its semantics are a contract of the node, not an implementation detail. This page states that9+contract as it holds at the pinned Jazz alpha, verified by the lofi-node test suite.10+11+## Where verdicts come from12+13+Every write becomes a batch with a client-generated batch id. The store adjudicates the batch and14+the client runtime records the outcome as the batch's fate. Two client surfaces carry a rejection:15+16+- **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.21+22+Both surfaces correlate to the originating write by batch id. The `reason` string is diagnostics23+only; its wording changes between Jazz versions and must never be matched on.24+25+## Local refusal is not a verdict26+27+The client runtime enforces the policy it currently knows synchronously, at the call site: a write28+that the local policy denies throws immediately and no batch ever exists. The store only adjudicates29+writes made under a **stale** policy — accepted locally, then denied by a store whose permissions30+tightened after the client last synced them. Author-facing effect handlers therefore see two31+distinct failure shapes: a synchronous throw (a programming or policy error against known state) and32+an asynchronous `rejected` verdict (the store overruled a write it had never seen).33+34+## Rejection properties35+36+Three properties hold, and the node's harness keeps them pinned:37+38+1. **Correlation.** A permission-denied write surfaces `code: "permission_denied"` with the39+ originating write's batch id, on both verdict surfaces.40+2. **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.42+3. **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.45+46+## At-least-once, exactly-once: the consumer's duty47+48+Delivery is at-least-once from the store's point of view (unacked batches are re-proposed on every49+reconnect) but the fallback event fires **once per runtime lifetime** — a later reconnect does not50+re-fire events for batches already settled. A write journal must therefore:51+52+- 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.55+56+Compensation handlers keyed by batch id run at most once even when the verdict is observed twice.57+58+## The taxonomy: permanent or transient59+60+A consumer classifies every mutation-error code into one of two actions, using the classification61+the package exports (`classifyMutationError`, `MUTATION_ERROR_CLASSES`):62+63+| 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 |67+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+74+## Pending is not offline75+76+A transient-pending write needs one more input before an app can honestly say "waiting for your sync77+node": whether the node is reachable at all. That signal is the78+[`/health` endpoint](http-surface.md#health), available in every connection mode, composed79+client-side with WebSocket lifecycle events into a single connection observable.