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 (themirrors:prune-driftidiom, unlike the central single-passnotifications:prune-log): for each tenant it force-deletesdocument_accessesrows past a per-outcomewindow offconfig('documents.access_retention')in bounded chunks via its OWNDocumentAccessmodel. 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 inCqrsBoundaryTest(own model + Tenant iteration — thePruneResolvedDriftCommandprecedent). - RTBF crypto-shred —
documents:shred-document {document} {--ref=}(console-only, NOT scheduled) →CryptoShredDocumentCommand→CryptoShredDocumentHandler(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 timecreated_at; window =max(documents.rtbf.retention_days, floor), i.e. AT LEAST the 7-year floor;null⇒ the floor), it is HARD-REFUSED withShredBlockedByRetentionException(ErrorCode::ShredBlockedByRetention, 409) — you cannot shred inside the window. If eligible: NULL out thewrapped_dekin theencryption_key_refenvelope (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), flipstatus→DocumentStatus::Shredded, stamp the additiveshredded_at+shred_request_reftombstone columns, optionally delete the blob (documents.rtbf.delete_blob, default false — pending WORM sign-off), emitDocumentShredded+ 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).