T2-6 — Crypto-shred redaction/expungement + JWS signing-key retention (#687)
On this page
Epic &56 / Track 2, T2-6 (#687). ADR-027 §8 names the Track-2 fix for the append-only tension: "a genuine purge via crypto-shredding — per-value encryption where redaction destroys that value’s key, leaving the hash over ciphertext intact so the chain stays verifiable — across facts, events, and snapshots, with an explicit Pub 1075 access-audit story for SSN." #687 also carries an independent second half: JWS verification-key retention — today a rotated verification key is removed 30 days after rotation, so a determination signed with a retired key can never be re-verified. This plan delivers both as one dependency-sliced multi-MR marathon closing #687, on the hash-over-ciphertext model, ripping out the back-compat scaffolding that pre-1.0 (no production data, forward-only ADR-016 migrations, devstack re-seeds) no longer needs.
file.rs:NNN anchors below are pre-implementation (accurate on main as of the plan commit).
Pair each with its semantic anchor (the named fn/struct), which is the durable address — line numbers
drift as the code evolves.
- Scope boundary
- Status
- Context
- Decisions
- Data model
- Implementation
- MR1 — Foundation crate + ADR-036
- MR2 — JWS key-retention store + lazy-load (closes the retention half)
- MR3 — Rip out
_PREVenv dual-key + runbook rewrite - MR4 — Audit-chain v1 rip-out + drop
hash_version - MR5 — Snapshot sealing contract + seal ALL programs (re-sliced)
- MR6 — Snap redaction op + CLI (reference impl)
- MR7 — Redaction op + CLI for tanf/medicaid/caps/wic
- MR8 — Persons fact-value + persons-PII sealing + redaction + CLI
- MR9 (FINAL) — Event-value sealing + shared-DEK audit expungement + Pub-1075 + close
- Verification
- Risks / sharp edges
- Follow-ups
Scope boundary
In scope:
-
Foundation — a new
canopy-crypto-shredcrate: theSealedValueAEAD envelope (AAD-bound), per-value DEK generation + KEK-wrap (extendingcanopy_common::cryptowith AAD variants), theRedactionKeyStore+KeyHistoryProvidertraits, and the hash-over-ciphertext discipline. Property-tested. -
Crypto-shred across all three surfaces — (a) canopy-persons fact-value version tables + the persons-table PII columns (
ssn_encrypted,date_of_birth); (b) the attributed event / canopy-security change-historybefore/afterpayloads; (c) the determination snapshots (all five program services). -
The redaction/expungement operation — per-service endpoints +
canopyCLI parity (ADR-027 §10), role-gated, emitting tamper-evident*.redactedaudit events with a cross-service fan-out; the Pub-1075ssn.accessedaudit event on every SSN open. -
JWS verification-key retention — a persistent
signing_key_historystore, a stable program-boundkidscheme, anasyncVerifyingKeyRegistrylazy-load on cache-miss, and aGET …/jwksendpoint. -
Back-compat rip-outs (per the maintainer directive) — audit-chain v1 +
hash_version; envCANOPY_VERIFY_KEY_*_PREV;snapshot_hash: Option+NoInputSnapshot/tri-state read; snapshotschema_version1/2/3 plaintext formats (collapse to v4-only, with a v4 lower-bound on read). -
ADRs — new ADR-036 (the crypto-shred + key-retention architecture, incl. an explicit threat model) + amendments to ADR-028/014/017.
Out of scope (each a boundary with a reason; filed as follow-ups, never silently dropped):
-
KEK rotation tooling / Vault backend — the
kek_versioncolumn + the unwrap-old/wrap-new re-wrap path are designed in; the operator runbook +xtask kek-rotateare a follow-up (ADR-017’sCANOPY_ENCRYPTION_KEY+_PREVIOUSwindow already covers the in-flight decrypt path). -
Two-person-integrity enforcement for expungement — the ADR records the requirement; v1 enforces a single privileged role + mandatory
reason+ actor capture. Dual-control needs an approvals surface that does not exist yet. -
WAL/backup secure-erase tooling — see Decision O (threat model): shred is application-layer; block-layer-encrypted storage + bounded backup retention are operational prerequisites, with a post-grace secure-overwrite sweep filed as a follow-up.
-
Encrypting non-PII structural columns — sealing buys no redaction value and costs queryability (Decision L).
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + its |
Done (2026-06-24) — the plan commit. |
MR1 — foundation crate + ADR-036 |
|
Done (2026-06-24) — the implementation commit. |
MR2a — key-retention store + lazy-load mechanism |
canopy-security |
Done (2026-06-24) — the implementation commit. |
MR2b — key-derived |
The 5 program services mint a key-derived |
Done (2026-06-24) — the implementation commit. |
MR3 — rip out |
Delete |
Done (2026-06-24) — the implementation commit. |
MR4 — audit-chain v1 rip-out |
Collapse to the single (former-v2) formula; drop the |
Done (2026-06-24) — the implementation commit. |
MR5 — snapshot sealing contract + seal ALL programs (re-sliced) |
|
Done (2026-06-24) — the implementation commits (sealing only; the split-out |
MR6 — snap redaction op + CLI (reference) |
|
Done (2026-06-24) — the implementation commit. |
MR7 — redaction op + CLI for tanf/medicaid/caps/wic |
Mechanically identical per-service redaction op (the |
Done (2026-06-25) — the implementation commit. |
MR8 — persons fact-value + persons-PII sealing + redaction + CLI |
Seal value columns + |
Done (2026-06-25) — the implementation commit. As-built deviations: (1) the per-fact DEK uses |
MR9 (FINAL) — event sealing + shared-DEK redaction + Pub-1075 + close |
Sealed |
Done (2026-06-25) — the implementation commit. As-built deviations: (1) No cross-service fan-out (Model B, the architecturally-correct realization). The proposed Decision M had canopy-security re-seal the audit |
Epic: &56
Issue: #687 — a single issue delivered as 9 dependency-sliced MRs (justified per
gitlab-issue-mr-standards: each slice is independently reviewable
mergeable + leaves the tree green; bundling would be one unreviewable diff across ~9 crates).
Relates to #687 on MR1–8; Closes #687 on MR9.
Branches: feat/fact-authoring-t2-6-{foundation,key-retention,prev-rip,chain-v1-rip,snapshot-seal,snap-redact,program-fanout,persons-seal,events-finalize}, each cut fresh from a main that already has its deps (not stacked); regular merge commits, never squash.
Merge order (mandatory — not stacked): MR1 first. Then three independent chains off MR1: (retention)
MR2 → MR3; (chain rip-out) MR4; (sealing) MR5 → MR6 → MR7 and MR5 → MR8. MR9 depends on BOTH MR4
(v2-only metadata hash) AND MR8 (sealed fact events) and is last. MR5/MR6/MR7 do not depend on MR4
(snapshots are not audit_events).
The original slicing assumed sealing could roll out per program (MR5 contract → MR6 snap → MR7
others). It can’t: all five program services construct IncomeFactLeaf / AssetFactLeaf /
ExpenseFactLeaf directly against the shared canopy-contracts-eligibility::DeterminationSnapshot
type, so changing a leaf field (Decimal → SealedDecimal) changes the type all five compile against —
it breaks every producer at once. Sealing one program at a time would require a transitional
plaintext-or-sealed leaf representation, i.e. exactly the back-compat scaffolding the maintainer ruled
out. So MR5 seals all five programs together (contract change + per-program redaction_keys
RedactionKeyStore + builder wiring), and MR6/MR7 carry only the genuinely-per-service redaction
operation (the /redact endpoint — sub-resource form per the MR6 as-built note + one-way-tombstone
trigger + CLI). The dependency graph and MR count are unchanged; only the MR5↔MR6/MR7 content boundary
moved. Maintainer-approved 2026-06-24.
Context
ADR-027 §8 makes a plain DELETE untenable: append-only facts + immutable signed snapshots + a tamper-evident chain mean a delete is either blocked (immutability triggers) or chain-breaking (deleting a hashed value rotates every downstream hash). Crypto-shred resolves it: encrypt the value, hash the ciphertext, redact by destroying the per-value key — the ciphertext + hash stay (chain + signature still verify), only the plaintext becomes unrecoverable.
Today (verified on main):
-
Fact values are plaintext columns —
income_versions.amount NUMERIC(10,2),employer_name TEXT,address_versions.line_1/line_2, etc. -
Only SSN is encrypted at rest —
ssn_encrypted, via a single service-wide key applied directly (services/canopy-persons/src/store/persons.rs,encrypt_ssn), no per-value key → not selectively shreddable.date_of_birthis plaintext on thepersonsrow. -
The snapshot hash is over plaintext —
serde_jcs::to_vec(snapshot)(crates/canopy-contracts-eligibility/src/snapshot.rs,canonical_bytes); the ECDSA P-256 detached-JWS signature bindssnapshot_hash(crates/canopy-signing/src/envelope.rs, set before signing in the programdetermine.rs). -
The audit chain is dual-path — v1 (delimiter-free concat) + v2 (JCS over
AuditChainInputsV2, which includes the fullmetadataJSONB) selected by ahash_versioncolumn (services/canopy-security/src/store/mod.rs). -
The verifier is in-memory only —
VerifyingKeyRegistry(crates/canopy-signing/src/verifier.rs) loadsCANOPY_VERIFY_KEY_{P}+_PREVenv vars;verify_detachedis synchronous; the runbook removes_PREVafter 30 days → retired-key determinations become unverifiable forever.
Reuse target. canopy_common::crypto already provides AES-256-GCM encrypt/decrypt (output
nonce||ct||tag, non-deterministic), EncryptionKeys{current, previous}, decrypt_with_rotation, and
the CANOPY_ENV fail-closed loader (ADR-017). aes-gcm 0.10, sha2, p256 (with jwk),
rand/getrandom are workspace deps; hkdf/zeroize are transitive-only (MR1 cargo add`s them).
`deny.toml bans openssl → pure-Rust only. crypto::encrypt/decrypt take no AAD today — MR1 adds
encrypt_with_aad/decrypt_with_aad (the existing fns delegate with empty AAD, so the SSN path is
byte-compatible until MR8 migrates it).
Decisions
| Decision | Resolution |
|---|---|
A — new |
|
B — hash-over-ciphertext; seal ONCE; the |
A value field becomes a |
C — KEK = the existing |
Reuse the ADR-017 secret + fail-closed loader + |
D — DEK granularity = redaction granularity |
One DEK per version-row for facts (a row’s value-tuple redacts together); per PII column per person
for persons-table PII ( |
E — |
Each sealing service owns its table (ADR-001). Shred = |
F — |
Public verification-key material is cross-cutting compliance metadata, not program-tenant data; the orchestrator already reaches across services to verify; canopy-security owns tamper-evidence (ADR-014). One store = one retention owner + one lazy-load target. Public keys only — never private material, never FTI. The carve-out is recorded in ADR-036 for reviewer scrutiny. |
G — stable program-bound |
Today kids are |
H — |
|
I — rip out env |
Delete |
J — rip out audit-chain v1 + |
No v1 rows exist post-reseed. Delete |
K — snapshot: |
Make |
L — seal PII-bearing values; leave structural discriminators plaintext |
See the two lists below the table. |
M — redaction op: role-gated, emits tamper-evident |
Redaction is privileged + irreversible: a dedicated |
N — NUMERIC→BYTEA: drop the value |
The non-overlap |
O — honest threat model: shred is application-layer redaction |
Tombstoning the DEK destroys it in the live DB, but the wrapped-DEK plaintext can residue in Postgres
WAL + base backups (until retention expires), unencrypted storage pages (until overwritten), and the
|
Decision L — seal vs. leave plaintext:
-
Seal (PII-bearing): money (
amount,value),employer_name,description, addressline_1/line_2, persons-tablessn+date_of_birth, SOLQ dollar amounts,DerivedFactNode.value, the wholeprogram_input(embeds household money) +cross_program_inputs(FTI-derived). -
Leave plaintext (structural discriminators / non-PII — sealing costs queryability, buys no redaction value):
income_type/asset_type/expense_type,frequency,relationship,household_size, all UUIDs (fact_id/person_id/…),corpus_hash,policy_params(jurisdiction thresholds),address_type/city/state/zip/county_fips(already coarse; the street is the PII).
Data model
New types live in canopy-crypto-shred (MR1) unless noted. serde_json::Value appears only where a
sealed value is genuinely heterogeneous (the existing program_input/DerivedFactNode.value
STRUCTURAL-VALUE pattern, ADR-003); no business logic reads it.
// canopy-crypto-shred (MR1) — the AEAD envelope; serializes JCS-stably and is the hashed unit.
pub struct SealedValue {
pub v: u8, // envelope format version (1); refuse unknown on read
pub alg: String, // "A256GCM"
pub dek_id: Uuid, // -> redaction_keys.dek_id; shred tombstones that row, orphaning this
pub ct: String, // base64url(nonce||ct||tag); value-seal AAD = "{v}:{alg}:{dek_id}"
}
// Hand-written Debug redacts `ct`. Send + Sync. Derives Serialize/Deserialize/Clone/PartialEq/Eq.
pub struct SealedDecimal(SealedValue); // seal: rescale(2) -> canonical string -> seal
pub struct SealedJson(SealedValue); // seal: serde_jcs canonical bytes -> seal
// canopy_common::crypto (MR1) — AAD-capable variants; existing encrypt/decrypt delegate w/ empty AAD.
pub fn encrypt_with_aad(plaintext: &[u8], key: &[u8;32], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
pub fn decrypt_with_aad(ciphertext: &[u8], key: &[u8;32], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
pub trait RedactionKeyStore { // sqlx-backed per service; the impl holds the service's PgPool
// mint DEK -> wrap under KEK (AAD = dek_id:subject_kind:subject_id) -> persist -> seal value (AAD = v:alg:dek_id)
async fn seal(&self, kek: &EncryptionKeys, subject_kind: &str, subject_id: Uuid, plaintext: &[u8])
-> Result<SealedValue, ShredError>;
async fn open(&self, kek: &EncryptionKeys, subject_kind: &str, subject_id: Uuid, sealed: &SealedValue)
-> Result<Option<Vec<u8>>, ShredError>; // Ok(None) = DEK tombstoned (redacted); plaintext in Zeroizing
async fn shred(&self, subject_kind: &str, subject_id: Uuid) -> Result<u64, ShredError>; // rows tombstoned
}
pub trait KeyHistoryProvider: Send + Sync { // the registry calls this on a kid cache-miss (MR2)
async fn public_key_pem(&self, program: Program, kid: &str) -> Result<Option<String>, KeyHistoryError>;
}
-- redaction_keys: per sealing service (persons, the 5 program services, security). One-way-tombstone trigger (Decision E).
CREATE TABLE redaction_keys (
dek_id UUID PRIMARY KEY,
wrapped_dek BYTEA NOT NULL, -- nonce||ct||tag of the DEK under the KEK (AAD-bound); zero-sentinel after shred
kek_version SMALLINT NOT NULL DEFAULT 1, -- which KEK wrapped it (supports KEK-rotation re-wrap)
subject_kind TEXT NOT NULL, -- 'income_version' | 'ssn' | 'date_of_birth' | 'audit_event' | 'determination_snapshot' | ...
subject_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
shredded_at TIMESTAMPTZ -- NULL = live; non-NULL = tombstoned (redacted-at proof)
);
CREATE INDEX idx_redaction_keys_subject ON redaction_keys (subject_kind, subject_id);
-- signing_key_history: canopy-security only (Decision F). STRICTLY INSERT-only (append-only
-- trigger blocks UPDATE/DELETE/TRUNCATE) so a public key can never be silently swapped — a
-- tamper-evidence property. "Current vs. retired" is DERIVED from registration order (latest
-- registered_at per program = the active signer; older rows = retired), so no mutable retired_at
-- is needed. Registration is idempotent: INSERT ... ON CONFLICT (kid) DO NOTHING. MR2a.
CREATE TABLE signing_key_history (
kid TEXT PRIMARY KEY, -- 'canopy-{program}-{YYYYqN}' (Decision G)
program TEXT NOT NULL,
public_key_pem TEXT NOT NULL, -- SPKI PEM, PUBLIC key only
registered_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX idx_signing_key_history_program ON signing_key_history (program, registered_at DESC);
Redaction + access events (as-built: canopy-contracts-persons, MR9 — they are persons-published, so
they live with the other persons event payloads, not in canopy-contracts-security), all plaintext-free:
-
FactRedactedEvent { person_id, kind, fact_id, author: Option<Author>, reason, redacted_at }— the as-built shape (the proposedsubject_kind/subject_id/dek_idswere for the rejected fan-out; under the single-owner shared-DEK model the persons redaction expunges the audit copy directly, so nodek_idstravel and canopy-security recordsfact.redactedas an ordinary audit row — no subscriber/shred). -
SsnRedactedEvent { person_id, author: Option<Author>, reason, redacted_at }. -
SsnAccessedEvent { person_id, actor_sub, purpose: SsnAccessPurpose, source_service }—purposeis the enum{ case_view, search, batch_lookup, foia, portability }, never free-text.
Implementation
Eight MRs + the (plan) commit under #687, sliced by dependency. Each commit builds green; per commit
the pre-commit token gate + a fresh Explore subagent answering J1–J8 over the staged diff, reported as
text. The (plan) commit (this .adoc + the nav entry) lands first in MR1. The seal/open of a value
always happens inside the owning service’s store layer (never in an API handler), so unsealed PII never
crosses a service boundary.
MR1 — Foundation crate + ADR-036
Files: crates/canopy-crypto-shred/** (new), crates/canopy-common/src/crypto.rs (AAD variants), root
Cargo.toml (workspace member + cargo add hkdf zeroize), docs/…/adrs/adr-029-crypto-shred-redaction.adoc
(new), this plan .adoc + nav.adoc.
-
Add
encrypt_with_aad/decrypt_with_aadtocanopy_common::crypto; existingencrypt/decryptdelegate with empty AAD (SSN path byte-stable until MR8). -
Define
SealedValue/SealedDecimal/SealedJson, AAD-boundseal_*/open_*, DEK generation (OsRng→Zeroizing<[u8;32]>;openreturns plaintext inZeroizing), KEK-wrap/unwrap, theRedactionKeyStore+KeyHistoryProvidertraits, typedShredError/KeyHistoryError. -
No service wiring, no migrations (the DDL is defined here but applied per-service later).
-
Proptests (mandatory):
seal(plaintext) → hash(h1) → drop-DEK → hash(h2) → assert h1==h2 → open()==Ok(None); re-serialize byte-stability of a storedSealedValue;sealtwice on equal plaintext → differentct; AAD-swap rejection (unwrapwrapped_dek_Bunderdek_id_A’s AAD → `Err); round-tripseal→openfor arbitraryDecimal/JSON. -
ADR-036 draft (Status
Proposed): Decisions A–O condensed; an explicit Threat model section (Decision O residue vectors + the block-layer-encryption / bounded-retention prerequisites); cross-ref ADR-027 §8 / 028 / 014 / 017.
MR2 — JWS key-retention store + lazy-load (closes the retention half)
Files: services/canopy-security/migrations/<ts>_create_signing_key_history.sql,
services/canopy-security/src/store/signing_keys.rs (new) + store/mod.rs,
services/canopy-security/src/api/… (jwks handler + route), crates/canopy-signing/src/{verifier.rs,signer.rs},
services/canopy-eligibility/src/{config.rs,main.rs,orchestrator.rs,api/handlers.rs}.
-
signing_key_historytable + append-only trigger + INSERT/by-(program,kid)/by-programreads;GET /v1/signing-keys/{program}/jwks(P-256 → JWK, incl. retired). -
Stable program-bound
kid(Decision G): signer embedsCANOPY_{PROGRAM}__SIGNING_KID, validated at startup; each program idempotently registers its current public key on boot. -
VerifyingKeyRegistry: addprovider: Option<Arc<dyn KeyHistoryProvider>>; makeverifyasync(kid-extract → program-prefix check → in-memory → on-missprovider.public_key_pem(program, kid)→ verify → cache). Thread.awaitthrough the orchestrator verify call sites (already async). Orchestrator injects an HTTP-backed provider hitting the jwks endpoint; unit tests passNone.
MR3 — Rip out _PREV env dual-key + runbook rewrite
Files: crates/canopy-signing/src/verifier.rs, docs/…/security-operations.adoc,
docs/…/runbooks/signing-key-rotation.adoc, docs/…/configuration-reference.adoc,
docs/…/deployment-guide.adoc.
-
Delete
CANOPY_VERIFY_KEY_*_PREVloading from both registry loaders (each now holds only the current key). As-built (Decision I refinement): no in-memory second key is sourced from the store — the MR2b lazy-load already serves the rotation window, so_PREVis fully removed.RotationState/add_keysare kept as a programmatic escape hatch. Rewrite the standalone rotation runbook + thesecurity-operations.adocrunbook section (delete the PREV slot + dual-key-window/30-day steps) and drop the stale_PREVrows fromconfiguration-reference.adoc+deployment-guide.adoc.devstack_guard::ensure_signing_keysnever set_PREV, so no devstack change. Subtractive — lands after MR2 bakes.
MR4 — Audit-chain v1 rip-out + drop hash_version
Files: services/canopy-security/src/store/{mod.rs,models.rs,fact_history.rs},
services/canopy-security/migrations/20260624130000_drop_audit_hash_version.sql; docs
adrs/adr-014-fti-audit-hash-chain.adoc (Amendment 3), data-models/canopy-security.adoc,
api/canopy-security.adoc, CHANGELOG.adoc. (As-built: fact_history.rs carried a
hash_version: 2 test-fixture literal, and the J5 doc surfaces describe the column as live —
both were under-specified in the original file list; added here per the living-spec rule.)
-
Collapse
compute_event_hashto the single formula (deletecompute_event_hash_v1+ the version dispatch +AuditHashError::UnknownVersion; renamecompute_event_hash_v2→compute_event_hashandAuditChainInputsV2→AuditChainInputs— the "v2" suffix is vestigial with no v1); drop thehash_versioncolumn (audit_events+ archive, one lock-step migration so the positional archiveINSERT … SELECTkeeps aligned ordinals) + theAuditEventRowmodel field + the test-fixture literal. Rewritemixed_v1_v2_chain_verifies→multi_row_chain_verifies(pure single-formula), deleteunknown_hash_version_breaks_chain(the version concept is gone), and fold the three v1-only unit tests into one sole-formula test. ADR-014 Amendment 3 + the J5 doc flips (the formula is unchanged — it *is the former v2 — so existingevent_hashvalues verify unchanged; DB-only, no wire/OpenAPI delta). Independent rip-out; MR9 depends on it (event-metadata sealing relies on the single formula).
MR5 — Snapshot sealing contract + seal ALL programs (re-sliced)
Files: crates/canopy-crypto-shred/src/store.rs (add mint_dek to RedactionKeyStore);
crates/canopy-contracts-eligibility/src/{snapshot.rs,derivation.rs} (+ Cargo.toml dep on
canopy-crypto-shred);
services/canopy-{snap,tanf,medicaid,caps,wic}/src/{determine.rs,store/…} (per-service RedactionKeyStore
impl + redaction_keys migration with the one-way-tombstone trigger + builder wiring); contract
per-service tests. (The snapshot_hash-required + NoInputSnapshot-drop cascade — canopy-signing
envelope, the 5 program DTOs, canopy-web, OpenAPI, the snapshot_hash NOT NULL migrations — is split to
follow-up #911; see the status note above.)
-
Contract (canopy-contracts-eligibility). Value leaves →
Sealed*per Decision L:IncomeFactLeaf.amount,AssetFactLeaf.value,ExpenseFactLeaf.amount→SealedDecimal;IevsReconstructionmoney (self_reported_monthly_income/verified_monthly_income/variance_monthly) →Option<SealedDecimal>;program_input+DerivedFactNode.value→SealedJson;cross_program_inputs→Option<SealedJson>(seal the whole SOLQ/FTI projection — the ADR-004-conservative choice; covers the SOLQ dollar amounts + the SSA flags/category/dates as one opaque blob). Leave plaintext:_type,frequency,relationship,household_size,MemberLeaf.date_of_birth(the *authoritative DOB is sealed at the persons table in MR8), all UUIDs/person_id`s, `corpus_hash,policy_params. The rescale(2) invariant lives insideSealedDecimal::seal. -
SCHEMA_VERSION_MAX = 4+SCHEMA_VERSION_MIN = 4; the builder emitsschema_version = 4.verify_schema_versionrejects> 4(unknown-future, existing guard) and< 4(a newPlaintextSchemaVersionRejected— a v1–v3 plaintext snapshot is a downgrade, refused so unsealed PII is never served/re-verified). This floor makes sealing sound on its own; thesnapshot_hash-optionality tightening is the separable #911 follow-up. -
Sealing (all 5 programs).
mint_dek(kek, "determination_snapshot", determination_id)mints+wraps+persists ONE DEK per determination (Decision D), returns(Zeroizing<[u8;32]>, dek_id); the snapshot builder seals each value leaf with the syncSealedDecimal::seal/SealedJson::sealunder that DEK, in the store layer (unsealed PII never crosses a service boundary). The KEK is the program’s existingCANOPY_ENCRYPTION_KEY(EncryptionKeys, ADR-017). FTI programs (tanf/medicaid): the program’s own KEK/DEK seals its FTI-bearing snapshot; the orchestrator receives onlysnapshot_hash+ outcome, neverct/keys (ADR-004). -
Orchestrator. Treats the snapshot as opaque for verification (re-hashes
canonical_bytes→ compares to the signedsnapshot_hash); the sealed leaves are justctstrings, so noopen()is needed to verify. Nosnapshot_hash-optionality change here (that is #911). -
Tests: v4 round-trip (seal →
canonical_hashstable across re-serialize →open()recovers the leaf);verify_schema_versionrejects v3 (plaintext floor) and v5 (unknown future); a sealed snapshot’s wire JSON carriesct, never plaintext money; the per-program determine path seals + the signature verifies over the sealed bytes.
MR6 — Snap redaction op + CLI (reference impl)
Files: services/canopy-snap/src/{store/mod.rs,api/…} (the shred call + redact handler),
tools/canopy-cli/src/cmd/… (the snap determination redact subcommand). (Sealing + the redaction_keys
table/trigger already landed in MR5.)
-
POST /v1/determinations/{id}/redact(gated on the dedicateddata_stewardrole, Decision M) →store.shred("determination_snapshot", id)+ emitdetermination.redacted, both in one TX (ADR-018); request bodyRedactDeterminationRequest { reason }(blank → 400), responseRedactDeterminationResponse { determination_id, redacted_at }; CLIcanopy snap determination redact --id <id> --reason <reason>. -
Headline integration tests (devstack): (1) sign → store sealed snapshot → redact (shred DEK) → re-read JSONB →
canonical_hash== signedsnapshot_hash→ JWS still verifies → theredaction_keysrow is tombstoned → a leafopen()→Ok(None). (2) no-blob-leak: the orchestrator’sSignableDeterminationresponse carries onlysnapshot_hash(hex), never the snapshot blob. (3) role-gate (non-steward → 403), idempotent (re-redact → 0 rows, still 200), and unknown determination → 404.
Two deviations from the plan above, recorded per the living-spec rule:
-
:redact→/redact(sub-resource form, not the AIP-136 custom method). The endpoint isPOST …/determinations/{id}/redact, mirroring…/{id}/resolve, not…/{id}:redact. axum/matchit 0.8 allows only one parameter per path segment, so a{id}:redactsegment is unroutable. The same routing constraint applies to MR7’s/redactand MR8’s/redact//redact-ssnendpoints (updated above). -
A dedicated
data_stewardrealm role was added (Claims::require_data_steward), mirroringfti_auditor: admins do not auto-hold it (separation of duties — admins grant/revoke it but do not themselves hold redaction authority). This is the concrete realization of Decision M’s "`canopy:redact`/data-steward role".
MR7 — Redaction op + CLI for tanf/medicaid/caps/wic
Files: services/canopy-{tanf,medicaid,caps,wic}/src/{store/…,api/…} (the shred call + redact
handler); tools/canopy-cli parity. (Sealing + the per-service redaction_keys table/trigger already
landed in MR5.)
-
Mechanically identical to MR6’s redaction op. FTI programs (tanf/medicaid) respect ADR-004: the redact op shreds the program-local DEK; the orchestrator/canopy-security never see FTI.
MR8 — Persons fact-value + persons-PII sealing + redaction + CLI
Files: services/canopy-persons/src/store/{income_versions,asset_versions,expense_versions,address_versions,persons}.rs,
services/canopy-persons/migrations/<ts>_create_redaction_keys.sql (+ trigger)
<ts>_seal_fact_value_columns.sql, services/canopy-persons/src/api/…,
tools/canopy-cli/src/cmd/{income,asset,expense,address,person}.rs.
-
SQL-aggregate audit gate (do FIRST):
rg -n "SUM\(|WHERE\s+amount|WHERE\s+value|ORDER BY\s+amount" services/canopy-*/srcacross all five program services + persons; record per-service the result (expected: all value math is in-Rust post-read). Any SQL-side value math must move to Rust before its column is sealed; capture the table in the MR description. -
Seal
amount/value/employer_name/description/line_1/line_2(column →BYTEA/JSONBSealedValue; DEK per version-row,subject_id = version_id); dropCHECK (amount>=0)(Decision N). -
Seal persons-table PII:
ssn(migrate off the direct-KEKssn_encryptedto a per-personSealedValue,subject_kind='ssn',subject_id=person_id) +date_of_birth(subject_kind='date_of_birth', separate DEK so it redacts independently of SSN). -
POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact(shreds the fact’s version-row DEKs)
…/redact-ssn(sub-resource form per the MR6 as-built note — the matchit-0.8 one-param-per-segment constraint applies); CLIcanopy {income,asset,expense,address} redact <fact_id>+canopy person redact-ssn <id>.
MR9 (FINAL) — Event-value sealing + shared-DEK audit expungement + Pub-1075 + close
Files: crates/canopy-contracts-persons/src/events.rs (sealed event-value types + typed
redaction/access events), crates/canopy-contracts-security/src/fact_history.rs ((sealed) display),
services/canopy-persons/src/{events.rs,store/*_versions.rs,api/{mod,export}.rs} (seal in-store before
publish + ssn.accessed emit), the ADR/data-model/CHANGELOG/master-plan/this-plan/status.
-
Persons seals each fact event’s PII
before/afterleaves in the store layer under the same per-fact DEK as the at-rest value (the envelope copied verbatim, never re-sealed — so the eventctshares the fact DEK), and publishes the fully-typed*ClaimedEvent; canopy-security stores them inmetadata(the v2 hash coversctunchanged — Decision J / ADR-014 Amendment 4). -
As-built deviation from Decision M — no cross-service fan-out. Because the audit copy is sealed under the persons fact DEK, redacting the fact (one
shred_with) expunges the at-rest and audit-ledger copies atomically. canopy-security needs noredaction_keys, no KEK, and no subscriber — a fan-out would require it to own a second value-key (only obtainable by receiving plaintext → violates ADR-004) and would make redaction delivery-dependent. The single-owner shared-DEK model is the architecturally-correct realization; the change-history shows(sealed)for value leaves (worker value display re-sourced from the SoR in follow-up #920). -
ssn.accessedPub-1075 event at each of the 7persons_to_wireSSN-open sites, one per genuinely-decrypted person (enumpurpose), fail-closed through the outbox. ADR-036 →Accepted
ADR-014 Amendment 4 (ADR-028/017’s snapshot/SSN-at-rest surfaces were MR5/MR8, already cross-referenced from ADR-036 §Amends); thedata-models/canopy-{persons,security}.adoc+api/pages +cargo xtask api-docs --update(no OpenAPI delta — events/access are not HTTP shapes); CHANGELOG== Unreleased; master plan T2-6 → Done; this plan → Done + As-built.Closes #687.
Verification
Per MR: cargo build -p <touched>; cargo clippy -p <…> --all-targets — -D warnings; focused tests
on the service’s dedicated postgres (set -a; source .ports.env; set +a; cargo nextest run -p <svc>);
cargo xtask quality-budgets (the crypto Value`s in `canopy-crypto-shred / canopy-contracts-* are
legitimately structural — mark per convention; OFFSET, never raise); cargo xtask check-docs + docs
plan-lint; full pre-push battery (validate --skip-docker + Playwright e2e + cargo doc + k6
git-lfs) on every push; cargo xtask dev refresh before integration tests.
Load-bearing assertions:
-
MR1:
seal → hash(h1) → drop-DEK → hash(h2) → h1==h2 → open()==Ok(None); re-serialize is byte-stable; double-seal differs; AAD-swap (open(dek_id_A, wrapped_dek_B)) →Err. -
MR2: sign with kid-A → insert kid-B active + retire kid-A → drop kid-A from memory →
verifyan old kid-A determination → lazy-load fromsigning_key_historysucceeds; a forged kid (canopy-tanf-…presented to a snap verify, or an unknown kid) is rejected without a fetch; JWKS returns retired keys. -
MR4: post-rip, a chain over sealed-metadata events verifies; tampering a
ctbreaks it. -
MR5/6 (mirrored MR7): headline — redact a determination → snapshot
canonical_hashunchanged → JWS still verifies → leafopen()→Ok(None); no-blob-leak — orchestrator response carries onlysnapshot_hash;verify_schema_versionrejects v3 + v5. -
MR8: seal a fact value → as-of read opens it → redact → read returns redacted; SSN + DOB round-trip through the envelope and redact independently; the SQL-aggregate audit table is recorded.
-
MR9: the fact change-history is plaintext-free — a claim/correction/close renders
wages · (sealed) · monthlyand NEVER the figure (the integration test asserts the plaintext amount is absent + the marker present); theaudit_eventschain stays valid after a fact redaction shreds the shared per-fact DEK (the auditctis byte-identical, only unopenable — no security-side shred); SSN open emits one plaintext-freessn.accessedper decrypted person, a redacted SSN fires none; the event payload carries aSealedDecimal(ctpresent), never a plaintext amount.
Risks / sharp edges
-
Non-deterministic nonce → seal once, never re-seal (Decision B). A correction appends a new version row (its own
SealedValue); no read path re-seals. -
NUMERIC→BYTEA (Decision N): EXCLUDE safe; drop
CHECK (amount>=0); the MR8 SQL-aggregate audit gate must pass before sealing. -
FTI / ADR-004 / Pub-1075: seal in the store (not the handler) so unsealed PII never crosses a boundary; orchestrator gets only outcome +
snapshot_hash; canopy-security receives only sealed bytes + public keys;ssn.accessed/*.redactedare plaintext-free;purposeis an enum. -
Shred is application-layer (Decision O): wrapped-DEK plaintext can residue in WAL/backups/page-reuse/the KEK-previous window — ADR-036 states this + the block-layer-encryption
bounded-retention prerequisites; a secure-overwrite sweep is a filed follow-up. Not overclaimed. -
Key-loss = data-loss (by design): KEK is the ADR-017 secret; KEK rotation re-wraps DEKs (
kek_version, unwrap-old/wrap-new via theEncryptionKeyswindow) without re-sealing values; the role-gate + mandatoryreason+ the one-way-tombstone trigger guard accidental/malicious shred. -
Deploy ordering: MR2 before MR3; MR5 (verifier accepts required+v4) before MR6/7; MR9 after BOTH MR4 + MR8. Pre-1.0 + devstack re-seed keeps the window short.
-
"No
seal()on a read path" is a review-checklist item for MR5–MR9 (the catastrophic-failure trap of Decision B); plus a grep aid in each MR’s J1–J8 subagent prompt. -
Sealed
serde_json::Valuefields (program_input,DerivedFactNode.value) become opaque blobs — future materiality (T2-7) / overpayment (T2-8) readers mustopen()before comparing (those consumers aren’t built yet; noted in ADR-036).
Follow-ups
File each as a separate GitLab issue and /relate #687 before merging MR9:
-
KEK rotation runbook +
xtask kek-rotate(the re-wrap path is designed in; tooling deferred). -
Two-person-integrity enforcement for expungement (needs an approvals surface).
-
Post-grace secure-overwrite sweep for WAL/backup DEK residue (Decision O Tier-3).
-
Full
DeterminationSnapshotToSchemasweep so OpenAPI documents the sealed fields (the T2-1/T2-2-deferred item, now also coveringSealedValue).