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_pulllog, 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()toasserted_byand puts the feed behind the same consent guard as every other read. A pull with no verified subject is a 403.