ADR-0152: Comprehensive crypto-shred decrypt-on-read via a single shared consumer + standing contract guard
Date: 2026-07-28 Status: Accepted Deciders: Adrian (Soft4U), Claude (Opus 4.8)
Context
Crypto-shred (ADR-0142 / #550) encrypts the PII sub-values of audit_events.details
(JSONB) and evidence-bundle data at write time whenever crypto_shred_enabled is on,
so that destroying a per-(subject, case) DEK renders that PII permanently undecryptable
(GDPR Art. 17) without deleting the immutable, hash-chained audit rows (ADR-0064/0109).
Decrypt-on-read, however, was wired into only a subset of read surfaces (#605:
evidence-bundle endpoints, DSR, case-pack, memory). Enabling the flag in the reference
deployment (2026-07-27, #787) rendered raw \x00 dek_…/env0 base64 ciphertext across
every other surface that reads audit_events.details or bundle-derived PII — the case
audit-trail viewer (GET /cases/{id}/audit), the SMO/abstention list (Art. 22(2) records),
the Decision-Memorandum §9 evidence appendix, and the report audit-events DB fallback that
feeds the Audit-Ledger / Compliance-Report PDFs and the case-pack master document. This is
why crypto-shred is (correctly) dark-launched, default-off: the read side was incomplete.
Two forces make this non-obvious:
- Flag skew (#784): the worker (write side) can have the flag ON while the API reader
still has it OFF (process not restarted after an
.envflip). A read path that gates decryption on the reader'scrypto_shred_enabledskips the decrypt/mask block entirely and leaks ciphertext. Masking must therefore be independent of the reader's flag. - Silent regression: nothing prevented a newly-added read surface from forgetting to decrypt. The gap is a claim-vs-check defect — encrypt-at-write (the control's shape) was present, comprehensive decrypt-at-read (the control's state) was not.
Decision
Route every audit-derived read surface through a single shared consumer, and enforce comprehensiveness with a standing contract test.
- New
evidence_bundle_service.mask_audit_events_for_read(events, *, case_id, tenant_id)is the one shared consumer foraudit_events.detailssurfaces. It loads the case keys (platform keys ∪ the__case__DEK) viaload_case_shred_keysand routes each event'sdetailsthroughdecrypt_pii_details(mask_shredded=True). - The gate is structural —
contains_encrypted_pii(details)— not the reader'scrypto_shred_enabledflag (#784). A ciphertext envelope is ALWAYS decrypted-or-masked, regardless of the reading process's flag; a case with no encrypted leaf returns the SAME list object (byte-identical, no key load). - The four leaking surfaces are wired:
case_analysisaudit endpoint,smo_abstentionlist,report_data_builder._fetch_audit_events, and the memo §9 bundle summary (decision_memorandum_service, which threadsshred_keysdown and masks bundledataleaves inline; its two async callers load the keys). - A standing contract test (
test_crypto_shred_read_surfaces_787.py) asserts (a) the shared consumer decrypts with an active key, MASKS ([erased — crypto-shredded]) without it, and never returns adek_/env0envelope; and (b) each known surface routes through the helper — so a new surface that forgets to wire it fails the suite.
Fail-closed (ADR-0067): a shredded/absent DEK is simply missing from the key map, so its ciphertext masks rather than leaking base64; masking never blanks-to-clear.
Consequences
Positive
- Enabling
crypto_shred_enabledno longer leaks ciphertext on the audit-derived surfaces; the read path is comprehensive and flag-skew-safe. - The standing contract test converts "did we wire every surface?" from a manual audit into an enforced invariant — the discovery mechanism that kills the defect class, not just the instance.
Negative
- Each memo generation now loads case shred keys (a small platform-key load + one DEK lookup) even when the flag is off; masking is still a no-op there (gated on ciphertext).
- The source-level surface guard is a proxy: it asserts a surface references the helper, not that every code path within it does. A new store (a different table) would need its own wiring and its own guard entry.
Neutral
crypto_shred_enabledstays default-off (dark-launched); this ADR makes the read side GA-ready but does not flip the flag.
Alternatives Considered
Alternative 1: Wrap the two raw readers (AuditService.get_events, load_bundle)
- Decrypt at the single DB/MinIO chokepoint so all consumers inherit it.
- Why rejected: those readers lack a clean
(tenant_id, case_id)for the key load in all call sites, and several consumers are internal-only (never rendered) where decryption is wasted; surface-level wiring keeps the key load where the tenant/case context already is.
Alternative 2: Keep gating on settings.crypto_shred_enabled (the reader's flag)
- Simpler; mirrors the pre-existing
case_pack_service._mask_shredded_audit_pii. - Why rejected: #784 proved this leaks under write/read flag skew. Gating on the ciphertext envelope makes read-time safety independent of the reader's write-flag.