Change-feed endpoint: GET /api/v1/changes (unblocks near-instant desktop sync) #478
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#478
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
The desktop client (
Cordy/cairn-desktop) syncs over WebDAV, and plain WebDAV has no change feed. The client's only option is toPROPFINDthe whole tree on a timer. On a large tree that is expensive for the server and slow to react — a change made on machine A appears on machine B "sometime in the next 30 seconds", and the cost scales with tree size rather than with change volume.A cheap cursor-based delta endpoint fixes both. This is a small addition to an existing API surface, not a new subsystem, and it is what makes sync feel instant rather than eventually-consistent.
Referenced as a known gap in
cairn-desktop/docs/design-spec.md§3 and §10. The client ships poll-only in v1 and adopts this when it exists — so this is not a blocker for the client, it is an upgrade.Proposed shape
sinceomitted → return only the current cursor with an empty change list, so a fresh client can establish a starting point without replaying history.more: true→ the client should immediately request again with the new cursor.Three design points that matter
1. Scope it to the caller. Changes must be filtered to what that user can actually see — their home, their group spaces, honouring the same read-only and app-owned rules
internal/storage/scopealready enforces. A global feed would leak the existence and names of other users' files. This is the security-relevant part of the issue.2. Cursor expiry is mandatory, not optional. The journal will be pruned. A client that has been offline longer than the retention window must be told so explicitly rather than silently receiving an incomplete delta:
On
expired, the client falls back to a full reconciliation. Getting this wrong is a silent-divergence bug — the worst class for a sync product, because nothing appears to fail.3. The feed is advisory, never authoritative. Cairn's whole thesis is that the directory tree on disk is the data model, so files legitimately change underneath us —
rsync, a ZFS rollback, an S3 tool, an admin withmv. Those changes never pass through Cairn and cannot appear in a journal. The endpoint must therefore be documented as an optimisation over periodic full reconciliation, not a replacement for it. The client is already built this way: watchers are a hint, the full rescan is the backstop.Where the data comes from
Cairn already writes an append-only per-object access log in
internal/auditcovering writes, deletes and renames (never listings) — the same events this feed needs. Two options worth weighing in the design pass:I lean to the second, but it is worth a look at the audit driver before deciding.
Interaction with v0.6 (#138)
A change journal is runtime state, so it falls squarely under the state-in-backend work. Build it consistent with whatever #138 lands: if runtime state lives in
.cairn-state/, the journal lives there too. Sequencing is already right — this is v0.7, after v0.6 closes — but do not design the journal in isolation from #138's storage decisions.Also worth confirming against #138's two-writer analysis: if two pods can ever serve the same bucket, the cursor must remain monotonic across both, or clients will see changes go backwards.
Acceptance criteria
expired: truerather than a partial list.internal/api, following the existing patterns there.Out of scope
Push notification (WebSocket/SSE). Polling this endpoint is already dramatically cheaper than polling
PROPFIND, and push adds a connection-management problem for a fraction of the remaining benefit. Revisit only if the beta shows polling latency is still the complaint.Design assessment (2026-09-11, pre-build — recorded now, build deferred; the client ships poll-only v1 regardless):
Feasible cleanly, and the sequencing concern is already resolved. #138 closed 2026-08-10 and
internal/statestoreexists, so the journal has its settled home in encrypted.cairn-state/from day one — no need to design around an unlanded dependency.1. Journal source: separate lightweight journal, as leaned. The decorator precedent already sits at exactly the right layer — quota and the #423 marks decorator both wrap the shared
storage.Driver, the single chokepoint for API uploads, WebDAV PUT/DELETE/MOVE, tus finalisation and peering inbox delivery. A journal decorator recordingwrite/delete/move(Rename supplies from/to) is a small, well-trodden pattern. The journal needs no actor identity — filtering happens at read time — which cleanly sidesteps the audit-log privacy/retention coupling. Deriving from audit is rejected.2. Persistence tension + resolution: epoch cursors. The statestore persists whole JSON objects; a journal persisted append-per-write would rewrite the whole object on every file mutation (an extra S3 PUT per write). Resolution follows from this issue's own point 3 (advisory, never authoritative): in-memory ring buffer, debounced flush (every few seconds / N entries), and the cursor carries an epoch — journal loss or unclean shutdown bumps the epoch, and any old-epoch cursor answers
{"expired": true}, triggering the client's full reconciliation. Incompleteness is always declared, never partial, so the silent-divergence class is structurally closed rather than merely tested against.3. The bulk of the work is the visibility filter, as predicted here. Mapping backend-absolute journal paths to the caller's view (own home, member spaces, app-owned exclusions,
.cairn-state/never) and translating into the caller's namespace is the security-critical part and where most of the TDD effort belongs. Ingredients exist (internal/storage/scoperules,Spaces.Members). One decision to record when building: a newly joined space's history is not replayed — the client's reconciliation covers it; replaying pre-membership history would be both complex and a mild disclosure.4. Bonus since this issue was written: the #476 SSE hub now exists. Push stays out of scope as stated, but when the beta wants it, it is one
Publish(user, "changes")from the same decorator plus a topic case in the already-deployed client wiring — the cost of "revisit later" has dropped to near zero.Cursor shape: opaque base64 of
(epoch, seq), monotonic within an epoch; single-replicaRecreatekeeps monotonicity trivially today, and the #138 two-writer caveat carries over unchanged (multi-replica would need a shared sequencer — recorded, not built).No conflicts with the current tree. Effort estimate: one normal house TDD pass — decorator + ring/flush + epoch store (statestore), the
/api/v1/changeshandler with scope filtering, handbook section stating the advisory contract and reconciliation backstop, tests ininternal/apiper the acceptance list above.Shipped in v0.6.163 (PR #489, merged; live on the dogfood —
cairn_build_info{version="v0.6.163"}).What was built (per the design assessment above, all points held):
internal/changes— journal + decorator:Seq, entries{seq, at, type write|delete|move, path, from?}with backend-absolute paths and no actor identity.(epoch, seq)cursors. Any cursor from another epoch, from a pruned region of the ring, or from the future answers expired — never a partial list.changes.json, added to the s3 statePaths list so it lives in.cairn-state/. A clean shutdown resumes the epoch (changeJournal.Close()wired into the graceful-shutdown path), so cursors survive ordinary rollouts; a crash or lost journal starts a fresh epoch — the state is re-marked dirty immediately on resume, so only a properly closed journal is ever resumed.unscopedStore: REST, WebDAV, TUS, peering deliveries and public-share uploads are all recorded; deletes are recorded logically (above trash); only successful operations land — a hold/lock/read-only refusal never appears..cairn-state/writes are structurally excluded (statestore binds the raw driver) plus a defensive path skip.GET /api/v1/changes?since=&limit=500(internal/api/changes.go):since→{changes:[], cursor, more:false}(client does its own first scan)./home/<user>/x → /home/x; member spaces (IdP groups,-roread-only groups, app-owned via spacestore RoleFor) unchanged; other homes,/.trash/, state never. A move with one end out of view degrades honestly: moved away →deleteat the old path, moved in →writeat the new one. Invisible spans are drained server-side so pages are never empty whilemoreis true.docs/handbook/change-feed.md— protocol, what the feed contains, the advisory contract (backend-direct changes never appear; no space-history replay; reconciliation backstop required), operational notes.TDD: 13 tests (8 journal/decorator, 5 endpoint incl. scope filter, move degradation, epoch expiry, dirty-crash epoch bump, clean-close resume), witnessed red on the runner before implementation, full suite green.
Known gaps, documented as the advisory contract: admin trash restores call the trash driver below the journal wrap and are not recorded (they are backend-direct from the feed's point of view); SSE
changestopic push and the multi-replica sequencer stay deferred as agreed.Closing.