Skip to content

0008 — Sync is two endpoints and no server-held state

Status: accepted Phase: 6

Context

Brief §4 invariant 5: offline is the default, not a feature. A device captures records with no server contact, then reconciles when it next has a connection. The brief asks for device registration, an outbox drain, and a per-device cursor, and says to keep it small.

Decisions

The cursor is held by the device, not by the kernel

A server-held cursor has to be written on every pull, which means an UPDATE, and this codebase has no UPDATE path anywhere — invariant 1 is enforced by the absence of the grant, not by discipline. Adding an updatable table for sync positions would be the first crack in that.

It is also less correct. A server-held cursor records what the kernel sent; a device-held cursor records what the device has. Those differ exactly when it matters — the response was lost in transit — and in that case the device-held cursor is the one that recovers without loss.

The cursor is the same opaque (recorded_at, id) keyset token the read path issues, so there is one cursor format in the system, not two.

The replication feed includes superseded and retracted records

GET /v1/records shows what is currently believed to be true. GET /v1/sync/changes does not: it carries every record appended after the cursor, including ones that have since been superseded or retracted, each labelled.

A device holds a partial copy of the log and has to resolve chains locally while offline. If the feed hid superseded records, a device that pulled a delivery on Monday and its correction on Friday would hold two unrelated-looking records with no way to know which is current. If it hid retractions, a device would never learn that a record it already holds should stop being shown.

A batch reports per record and never fails whole

POST /v1/sync/outbox returns 200 whenever the batch envelope itself was well formed, with one result per submitted record: accepted, replayed, or rejected with the reason.

An all-or-nothing batch strands a device. A field officer with fifty records captured over a week gets a 422 naming one of them, and no way to send the other forty-nine — so the app either drops the batch or retries it forever. Neither produces a record.

Replaying a batch is safe because ingest is already idempotent per id, so a device that loses the response resends and gets replayed rather than a conflict. This is the same property as the single-record endpoint; sync did not need its own idempotency mechanism.

A device is not a record

kernel.device is a plain table in the kernel schema, not an entity in facts. A device has no asserted_by, cannot be superseded, and is not a claim about the world — it is operational state. Putting it in the fact log would mean inventing an entity the schema does not have, which §3 of the brief forbids.

It is still append-only and has no UPDATE grant. Re-registering the same device to the same party is idempotent; claiming a device id already held by another party is a 409, because that is either a mistake or an attempt to inherit another device's history.

DeviceRegistration in src/sync/sync.service.ts is the only Zod object the kernel defines for itself. That is not a second validation layer for records — it validates something @clycites/schema has no opinion about.

Consequences

  • A device that loses its cursor re-pulls from the beginning. Acceptable: the feed is cheap and the device deduplicates on id.
  • There is no way to ask "which devices are behind", because the kernel does not know. If that becomes a real operational need it is an append-only device_pull log, not a mutable column.
  • The feed is unfiltered by status — superseded and retracted records are in it deliberately — but it is scoped to the requesting party. This was a standing finding when this decision was written; it is now closed by decision 0009, which scopes since() to asserted_by and puts the feed behind the same consent guard as every other read. A pull with no verified subject is a 403.