Retention windows + legal holds (#63) #68

Merged
Cordy merged 8 commits from feat/legal-hold into main 2026-07-30 02:14:14 +00:00
Owner

Closes #63 — completes the public-sector batch (#59 derived).

Legal holds (internal/storage/hold): admins mark a path or subtree held via GET/POST/DELETE /api/v1/admin/holds; while held, NOTHING may delete, overwrite, or rename it — admins included — until the hold is lifted. Containment is bidirectional (deleting an ancestor of held content is blocked too); reads and copy-out are never blocked. Holds persist as one atomic JSON file (holdsPath, default /data/.cairn/holds.json); the enforcing decorator sits under audit (refusals logged as failed attempts, set/lift logged as hold-set/hold-lift). Always active — an empty store costs nothing.

Retention (settings-store policy retentionPeriods, e.g. {"/spaces/records": "8760h"}, longest prefix wins): deletes still go to trash as usual — it's permanent destruction that waits. Purge and admin raw-deletes inside /.trash (single entries or whole "empty trash" trees) are refused with storage.ErrRetention until deletedAt + window passes. Window counted from deletion (the recoverability guarantee), evaluated per purge so policy edits apply without a restart. Restore is always allowed.

Both surface as 403 with self-explanatory messages on REST. Explicitly out of scope (as the issue records): WORM guarantees against a hostile root operator — that layer belongs to backend snapshots/object-lock, same delegation decision as versioning (#23).

Tests: full hold matrix (overwrite/write-inside/delete/ancestor/rename-out/rename-onto/copy-onto blocked; read/copy-out allowed; lift restores; persistence; nil-safety) and retention (purge blocked in window, restore allowed, admin raw/empty-trash blocked, unretained purges immediately, nil policy = old behavior). Green on the runner.

Closes #63 — completes the public-sector batch (#59 derived). **Legal holds** (`internal/storage/hold`): admins mark a path or subtree held via `GET/POST/DELETE /api/v1/admin/holds`; while held, NOTHING may delete, overwrite, or rename it — admins included — until the hold is lifted. Containment is bidirectional (deleting an ancestor of held content is blocked too); reads and copy-out are never blocked. Holds persist as one atomic JSON file (`holdsPath`, default `/data/.cairn/holds.json`); the enforcing decorator sits under audit (refusals logged as failed attempts, set/lift logged as `hold-set`/`hold-lift`). Always active — an empty store costs nothing. **Retention** (settings-store policy `retentionPeriods`, e.g. `{"/spaces/records": "8760h"}`, longest prefix wins): deletes still go to trash as usual — it's *permanent destruction* that waits. Purge and admin raw-deletes inside `/.trash` (single entries or whole "empty trash" trees) are refused with `storage.ErrRetention` until `deletedAt + window` passes. Window counted from deletion (the recoverability guarantee), evaluated per purge so policy edits apply without a restart. Restore is always allowed. Both surface as 403 with self-explanatory messages on REST. Explicitly out of scope (as the issue records): WORM guarantees against a hostile root operator — that layer belongs to backend snapshots/object-lock, same delegation decision as versioning (#23). Tests: full hold matrix (overwrite/write-inside/delete/ancestor/rename-out/rename-onto/copy-onto blocked; read/copy-out allowed; lift restores; persistence; nil-safety) and retention (purge blocked in window, restore allowed, admin raw/empty-trash blocked, unretained purges immediately, nil policy = old behavior). Green on the runner.
Cordy merged commit d74ff16f87 into main 2026-07-30 02:14:14 +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#68
No description provided.