Skip to main content

D78 — Document-vault retention + RTBF crypto-shred: config-driven class-aware access prune + key-drop tombstone (RESOLVED, mechanism-only, extends D70/D31/D43)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

The document-vault retention deferral (docs/tracking/document-vault-retention-and-rtbf.md, the Phase-6 D3 follow-up) is resolved as a MECHANISM-ONLY landing, exactly like D77: the machinery ships, but no retention window values are shipped — the config is empty/safe on ship, so nothing prunes or shreds prematurely. Everything clamps to the D31 7-year floor and regulatory retention OVERRIDES RTBF within that floor. Two pieces:

  • Class-aware access-trail prune — documents:prune-accesses (Modules\Documents\Console\Commands\PruneDocumentAccesses, scheduled daily, onOneServer()->withoutOverlapping() — the D70/D77 prune idiom). TENANT-SCOPED, so per-tenant under each tenant's RLS scope (the mirrors:prune-drift idiom, unlike the central single-pass notifications:prune-log): for each tenant it force-deletes document_accesses rows past a per-outcome window off config('documents.access_retention') in bounded chunks via its OWN DocumentAccess model. Window per outcome: configured = outcomes[$outcome] ?? default_days (null ⇒ retained indefinitely); effective = max(configured, floor). A tamper event (integrity_failed) is retained AT LEAST as long as a routine serve (succeeded) — its effective window is bumped up to succeeded's (or indefinite if succeeded is). Empty on ship ⇒ prunes nothing. Additive (outcome, accessed_at) index. Sanctioned out-of-bus by exact class in CqrsBoundaryTest (own model + Tenant iteration — the PruneResolvedDriftCommand precedent).
  • RTBF crypto-shred — documents:shred-document {document} {--ref=} (console-only, NOT scheduled) → CryptoShredDocumentCommandCryptoShredDocumentHandler (over the bus, system-authorized like the other documents system commands, in the owning tenant's RLS scope). Retention guard: if the document is still inside its regulatory retention window (anchor = documents.rtbf.retention_anchor, default the capture time created_at; window = max(documents.rtbf.retention_days, floor), i.e. AT LEAST the 7-year floor; null ⇒ the floor), it is HARD-REFUSED with ShredBlockedByRetentionException (ErrorCode::ShredBlockedByRetention, 409) — you cannot shred inside the window. If eligible: NULL out the wrapped_dek in the encryption_key_ref envelope (the DEK is the sole handle to the ciphertext, so dropping it makes the stored blob permanently unrecoverable without touching the blob — D43 crypto-shred), flip statusDocumentStatus::Shredded, stamp the additive shredded_at + shred_request_ref tombstone columns, optionally delete the blob (documents.rtbf.delete_blob, default false — pending WORM sign-off), emit DocumentShredded + an audit line, and KEEP the row as a tombstone (the metadata/audit proof it existed and was erased). There is deliberately no operator HTTP surface (a separate, gated decision).

Both additive migrations (the shredded_at/shred_request_ref columns on documents, the (outcome, accessed_at) index on document_accesses) keep both tables RLS-FORCED with no policy-body change, so tenants:verify-isolation stays green with no new isolation test (re-ran tenants:rls, idempotent).

Proven by PruneDocumentAccessesTest (safe-by-default no-op; floor-clamped succeeded force-delete; a recent row kept; integrity_failed retained ≥ succeeded incl. the window-inherit bump; cross-tenant) + CryptoShredDocumentTest (refused inside the window with the DEK intact + status unchanged; past-window wrapped_dek null + status Shredded + tombstone stamps + DocumentShredded fired + the row survives; cross-tenant RLS not-found; the console command incl. the mandatory --ref). Prerequisites to ENABLING (config change, not code): legal/compliance sign-off of the exact windows + the erasure workflow, storage-side WORM/object-lock of the vault + the access trail, and the KMS key-material destruction policy.

Group-3-review refinements (D78.1): three correctness/UX fixes to the shred mechanism. (1) RTBF completeness — the shred now also NULLs the PII-adjacent original_name (a small additive migration drops its NOT NULL so a scrubbed tombstone can hold null). It is encrypted under the APP_KEY (a different key scheme from the document DEK the shred destroys), so dropping the wrapped_dek alone left the data subject's filename decryptable in the surviving tombstone row; the erasure must scrub it explicitly. (2) After-commit blob delete — the optional documents.rtbf.delete_blob object-store delete is deferred to getConnection()->afterCommit() (the OpenOrRaiseIncidentHandler precedent), so the irreversible delete runs only once the crypto-shred row durably commits — a handler throw / commit failure can no longer leave a "stored"/downloadable row whose ciphertext is already gone. (3) Distinct anchor-misconfig error — an anchor that does not resolve to a date is now refused with a dedicated ShredRetentionAnchorUnresolvableException (ErrorCode::ShredRetentionAnchorUnresolvable, 422) that names the misconfigured documents.rtbf.retention_anchor, instead of the generic within-window ShredBlockedByRetentionException — still fail-closed (a bad anchor never bypasses the guard), but a config typo can no longer masquerade as a retention block and silently disable every erasure. Covered by four added CryptoShredDocumentTest cases (original_name scrubbed; after-commit blob removal; the distinct exception + fail-closed; the console surface).


← Decision log index