#541: file versioning v1 #622

Merged
Cordy merged 10 commits from fix-541 into main 2026-09-18 17:58:04 +00:00
Owner

Implements the accepted ADR (VERSIONING.md) end to end.

Engine + decorator (internal/storage/versions, TDD on an in-memory driver):

  • Mint-on-overwrite: byte-copy of the previous raw object (ciphertext) to <dir>/.cairn-versions/<basename>/<rfc3339>_<seq>_<author>.v — lexicographic = chronological, author rides the key (no metadata index). Mint before write: a crash leaves a harmless extra version, never a lost file.
  • Decorator placed between encrypt and quota (after the recovery-open and drain captures, so rotation rewrites never mint and public-share recovery is untouched). Plaintext flows through it → sha256 tee powers the same-content skip; raw copies below reserved → never re-encrypted at mint, invisible to quota and per-user usage walks.
  • Namespace guard at this layer (ADR deviation, documented in VERSIONING.md "Implementation notes"): reserved sits below encrypt where version blobs must stay decryptable, so the guard lives here — lists filtered, direct access ErrNotFound, internal ctx marker for the API. Version downloads decrypt with the file's own path-scoped identity because the version dir shares its prefix.
  • Caps at mint time: 10/file, 90 days (lazy at list, permanent at next mint), WOPI coalescing 10 min per author (ctx marker set in wopiPutFile); holds freeze the chain; copies start empty; file renames carry the chain through the inner encrypt-aware Rename (cross-scope moves re-encrypt versions like the file); trash carries chains to the trash and back via its rename-based delete/restore, purge drops them.
  • keyscan/keydrain already walk baseStore (raw), so rotation covers version blobs with no change.

API: GET /api/v1/versions, GET /api/v1/versions/raw, POST /api/v1/versions/restore — Stat-gated, /shared refused (recipients hidden, per review), restore own-home-only in v1 (the raw restore bypasses scope/hold by design — ADR allows restore under hold since it mints current first). Restore emits version-restore audit (registered in Verbs) + a restore activity event.

Settings: versionsMaxPerFile / versionsMaxAgeDays (-1 = forever) / versionsCoalesceMinutes, zero = ADR defaults, read live.

UI: Versions drill-in row in the details pane (files only, hidden on delegated paths), panel rows time · author · size with Download and Restore (restore hidden on read-only), inline empty state, i18n ×4. feat.versions boot probe.

Deferred (noted in ADR + issue): admin "of which versions" line, changes-journal nudge on restore, restore beyond own home.

Implements the accepted ADR (VERSIONING.md) end to end. **Engine + decorator** (`internal/storage/versions`, TDD on an in-memory driver): - Mint-on-overwrite: byte-copy of the previous raw object (ciphertext) to `<dir>/.cairn-versions/<basename>/<rfc3339>_<seq>_<author>.v` — lexicographic = chronological, author rides the key (no metadata index). Mint before write: a crash leaves a harmless extra version, never a lost file. - Decorator placed between encrypt and quota (after the recovery-open and drain captures, so rotation rewrites never mint and public-share recovery is untouched). Plaintext flows through it → sha256 tee powers the same-content skip; raw copies below reserved → never re-encrypted at mint, invisible to quota and per-user usage walks. - Namespace guard at this layer (ADR deviation, documented in VERSIONING.md "Implementation notes"): reserved sits below encrypt where version blobs must stay decryptable, so the guard lives here — lists filtered, direct access ErrNotFound, internal ctx marker for the API. Version downloads decrypt with the file's own path-scoped identity because the version dir shares its prefix. - Caps at mint time: 10/file, 90 days (lazy at list, permanent at next mint), WOPI coalescing 10 min per author (ctx marker set in wopiPutFile); holds freeze the chain; copies start empty; file renames carry the chain through the inner encrypt-aware Rename (cross-scope moves re-encrypt versions like the file); trash carries chains to the trash and back via its rename-based delete/restore, purge drops them. - keyscan/keydrain already walk `baseStore` (raw), so rotation covers version blobs with no change. **API**: `GET /api/v1/versions`, `GET /api/v1/versions/raw`, `POST /api/v1/versions/restore` — Stat-gated, `/shared` refused (recipients hidden, per review), restore own-home-only in v1 (the raw restore bypasses scope/hold by design — ADR allows restore under hold since it mints current first). Restore emits `version-restore` audit (registered in Verbs) + a `restore` activity event. **Settings**: `versionsMaxPerFile` / `versionsMaxAgeDays` (-1 = forever) / `versionsCoalesceMinutes`, zero = ADR defaults, read live. **UI**: Versions drill-in row in the details pane (files only, hidden on delegated paths), panel rows time · author · size with Download and Restore (restore hidden on read-only), inline empty state, i18n ×4. `feat.versions` boot probe. Deferred (noted in ADR + issue): admin "of which versions" line, changes-journal nudge on restore, restore beyond own home.
Cordy merged commit 8bf73b3cb9 into main 2026-09-18 17:58:04 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#622
No description provided.