Skip to main content

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:

  1. 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 .env flip). A read path that gates decryption on the reader's crypto_shred_enabled skips the decrypt/mask block entirely and leaks ciphertext. Masking must therefore be independent of the reader's flag.
  2. 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 for audit_events.details surfaces. It loads the case keys (platform keys ∪ the __case__ DEK) via load_case_shred_keys and routes each event's details through decrypt_pii_details(mask_shredded=True).
  • The gate is structuralcontains_encrypted_pii(details)not the reader's crypto_shred_enabled flag (#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_analysis audit endpoint, smo_abstention list, report_data_builder._fetch_audit_events, and the memo §9 bundle summary (decision_memorandum_service, which threads shred_keys down and masks bundle data leaves 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 a dek_/env0 envelope; 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_enabled no 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_enabled stays 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.