ADR-041: Configurable Structured Logging + Jurisdiction-Owned Field Redaction
On this page
Status
Accepted (2026-08-03)
Realized by epic &74: #1299 (this Decision MR), #1300 (redaction mechanism),
#1301 (audit-export channel), #1302 (§9 detector repair), #1303 (retention
legal-hold lifecycle), #1304 (retirement of the hash chain + chain-v2, gated).
Supersedes and amends
-
Supersedes ADR-014 — FTI Audit Hash-Chain Integrity: its original Decision (the live FTI chain origin), Amendments 1/3/4 (the audit hash formula), the C1–C6 acceptance criteria (including the C5 external append-only authority), and the chain/anchor bindings of Amendments 5–11. The surviving non-chain obligations of C7 (per-jurisdiction retention, legal-hold, purge boundary) and C8 (per-record Pub 1075 §4 granularity, ADR-004 isolation, least-privilege) are re-ratified here — their chain mechanics are superseded, their obligations are not. Recorded against ADR-014 as its Amendment 12 (a supersession pointer; ADR-014’s accepted text stays immutable).
-
Amends ADR-004 — Legally-Scoped Data Tenancy: the tamper-evidence mechanism reference moves from ADR-014’s chain to this facility; the FTI audit-log retention floor is corrected from "5 years" to 7 years (IRS Pub 1075 AU-11); Amendment 1’s A6 (reporting-owned audit rows) is preserved via this facility, and its A7 (chain-v2 retention attachment) is withdrawn. ADR-004’s §Decision isolation mandates (separate store, off-bus scrubbing, per-access logging) are re-affirmed unchanged.
Context
ADR-014 gave fti_audit_log (canopy-tanf, canopy-medicaid) and audit_events
(canopy-security) an SHA-256 hash chain, and the "chain-v2" line
(Amendments 5–11, #1245–#1247, #1278–#1280) extended that toward an
externally-notarized append-only anchor authority. The design accreted a
fail-closed hot-path dependency (the chain append runs inside the determination
commit), a dormant-but-partially-live control plane (chain-v2 schemas migrated,
HTTP routes registered, staging/background components running), and a growing
maintenance and correctness burden (four live v1 chain instances across three
families; a canonicalization workaround; an always-502 "cite for hearing").
The guarantee the chain chases — a privileged insider cannot silently rewrite an audit row after the fact — is a general property of log infrastructure, not of one application table. Every real deployment already runs a general logging facility (SIEM / log pipeline / WORM store) that provides off-box capture, tamper-evidence, retention, and alerting for all of its logs. Building a bespoke cryptographic chain for one table duplicates that infrastructure, badly, and only for FTI — while the same deployment has the same concern about PHI, PII, credentials, and every other sensitive field in every other service.
Two observations reframe the problem:
-
Redaction is the general need. "Ensure FTI never reaches the logs" is a special case of "ensure any jurisdiction-designated sensitive field never reaches the logs." canopy should not decide, for every jurisdiction, which fields are sensitive — "everywhere is different." It should ship the mechanism (per-field redaction, everywhere) and safe defaults, and let the jurisdiction own the policy.
-
Tamper-evidence + retention are the deployment’s. Once the sensitive values are redacted, the audit record can be exported to the deployment’s general logging facility, which owns off-box integrity and retention for the exported copy. canopy’s job is to emit a complete, integrity-checkable record; the deployment’s job is to keep it safe.
This ADR makes that split explicit: canopy provides the mechanism; the deployment provides the policy.
Decision
Policy model — fully jurisdiction-overridable; the deployment owns all compliance risk
canopy ships the mechanism plus a secure-by-default policy that protects the known FTI/PHI/PII classes. The policy is config-as-data and fully jurisdiction-overridable:
-
Expressed as ruleset-as-data —
records (field path, action, citation), following the existingrulesets/{jurisdiction}/jurisdiction.toml+citations.tomlarray-of-records precedent, selected byCANOPY_<SVC>__JURISDICTION. -
Missing applicable policy → the safe default (protect the known classes).
-
Malformed / unparseable policy → fail closed (boot error) — never a silent weakening.
-
An explicit override — including one that weakens or removes a default protection — is honored, and is the deployment’s accountable, documented choice.
-
There is no canopy-enforced floor. canopy will not tell a jurisdiction what it must treat as sensitive; different jurisdictions and programs classify differently, and the deployment owns the compliance risk of its policy.
The redaction mechanism (not a passive tracing Layer)
canopy_common::telemetry::init installs fmt (JSON→stdout) and otel as
sibling layers under the registry root; a passive tracing Layer cannot
rewrite an event before those layers serialize it. Redaction is therefore
implemented as:
-
a custom stdout
FormatEvent/ field visitor that applies the policy as the event is rendered, and -
a separate OTEL span/attribute sanitizer at the OTEL boundary,
installed in the common init and in canopy-portal’s own
tracing_subscriber::fmt().init() (or the portal adopts the common init). The
"no protected value leaves the process" guarantee is scoped to
post-telemetry-init events and must cover: event fields, span fields, span
updates (record()), nested JSON/arrays, Display/Debug-rendered values,
error chains (source()), and secrets embedded in the message string.
Bootstrap is reordered so telemetry init precedes secret reads, closing the
pre-init window; the residual window is documented and minimized. The executable
service inventory (workspace member binaries + the portal exception) is
enumerated with a per-binary coverage assertion.
The audit-export channel (the actual audit trail)
Redaction hides values; the exported copy is the audit evidence. "Emit to stdout and hope" is not an audit trail. canopy provides a dedicated channel, distinct from operational logging:
-
Unfilterable — bypasses
EnvFilter/RUST_LOG; an operator cannot suppress audit records by tuning log levels. -
Versioned + schema’d — a stable, snapshot-tested record contract with a stable record ID.
-
Commit-coupled — attempt/completion/crash semantics mirroring today’s
fti_auditedattempt-before / failure-after, so a crash mid-operation leaves a reconcilable attempt rather than a silent gap. -
Integrity-carrying — each record carries a complete-row digest + the policy version, so an off-box↔DB comparison detects a post-hoc row edit without exporting the raw sensitive values (redaction hides the values; the digest carries the integrity).
-
Operable — dedup on replay, shutdown flush, gap detection + reconciliation, and a collector-health signal.
The in-app audit DB rows remain the system-of-record. A production capture
reconciliation conformance gate (defined with #1301) MUST pass before any
retirement (#1304) proceeds.
The three FTI/PII controls stay distinct
The redaction facility governs logs. It does not subsume the three separate FTI/PII enforcement points, which stay distinct (only the sensitive-field vocabulary becomes shared config):
-
scrub_fti_fields— mutates RabbitMQ bus payloads (ADR-004 off-bus rule); -
the publisher fail-closed guard — blocks FTI on the wire;
-
data-tenancy-authorisation.toml— service tenancy + the CI scan.
A tracing formatter cannot sanitize RabbitMQ or enforce tenancy; retiring these would break ADR-004’s off-bus requirement, which is re-affirmed.
§9 detection — repaired, not delegated
The in-app IRS Pub 1075 §9 detector is currently inert (detection.rs dispatch
handles only "event_count" while the seeded rule is 'failed_auth'; it reads
only the shared audit_events). It is repaired (#1302): the dispatch/seed
mismatch is fixed, the FTI + reporting input streams are defined, and it stays
in-app and config-driven. The deployment facility adds off-box alerting on the
exported stream; it does not replace the in-app detector.
Citation-for-hearing
"Cite for hearing" becomes a canopy-signed rendering of the current
system-of-record row, accurately labeled per
ADR-029 — canopy’s signature
attests that it rendered that row, not chain-level row integrity — optionally
referencing the exported complete-row digest. This removes trusted_anchor_seq,
verified_through, BeyondTrustedManifest, and the chain-state fail-closed
matrix. Today the endpoint always 502s (the chain-v2 verifier is dormant); the
redesign turns it into a working signed PDF — a live API/UI change delivered in
the gated retirement (#1304).
Reporting Amendment-1 obligations preserved
ADR-004 Amendment 1’s A6 (reporting-owned Pub 1075 §4 / HIPAA audit rows in reporting’s own DB) is preserved — reporting keeps its own audit rows as system-of-record and additionally exports via this facility. A6’s chain-v2-family attachment (A7) is withdrawn. A8 (restricted-data storage controls: encryption-at-rest, restricted role, audited export) is unchanged and tracked separately (#1256).
Retention
The FTI audit-log retention floor is corrected to 7 years (IRS Pub 1075 AU-11); ADR-004’s "minimum 5 years" §Decision statement is superseded by this value. The HIPAA 45 CFR 164.316(b)(2) six-year documentation floor for the reporting audit log is unchanged. Retention + legal-hold become a general, config-driven lifecycle across every audit family (#1303).
Threat model (honest)
This facility detects a post-hoc DB edit of an audit row by a privileged insider, via off-box digest comparison + reconciliation against a copy the insider cannot reach in the deployment’s logging control plane. It does not defeat an attacker who controls both the application DB and the deployment’s logging control plane — that, and the risk of a jurisdiction’s own weaker policy override, are deployment-infrastructure responsibilities the deployment owns. There is no in-app cryptographic anti-privileged-rewrite claim; the retired chain’s claim to one was the source of much of its complexity and its fail-closed hot-path coupling.
Alternatives considered
Per the architectural-recommendation protocol:
-
Keep + finish the chain-v2 external anchor (ADR-014 Amendments 5–11, #1278). REJECTED: it is special-cased tamper-evidence for one log type, duplicating general deployment logging infrastructure; it keeps a fail-closed dependency on the determination hot path and a dormant-but-live control plane; and its strongest honest guarantee still cannot defeat an attacker who controls both the DB and the anchor authority.
-
Redaction only, no export channel. REJECTED: FTI logging is DB-insert-only and off-bus (ADR-004); "the deployment’s logging handles it" would replace a control with nothing unless canopy actually emits a complete, integrity-checkable record. The export channel is the real replacement work.
-
A canopy-enforced sensitive-field floor. REJECTED per maintainer decision: "everywhere is different." A floor imposes canopy’s classification on every jurisdiction; the mechanism + safe defaults + accountable override is the correct division of responsibility.
-
General configurable logging + jurisdiction-owned redaction (this ADR). ACCEPTED: it generalizes the real need (redact any sensitive field, everywhere), gives canopy the mechanism and the deployment the policy, and delegates tamper-evidence + retention of the exported copy to infrastructure built for it.
Consequences
-
Every service gains per-field redaction and a uniform audit-export channel; the sensitive-field vocabulary is jurisdiction-owned config, not canopy code.
-
The FTI-special hash chain, the chain-v2 machinery, and the external anchor are retired (#1304), after the replacement is proven via the conformance gate — the old control never disappears before the new one is demonstrated in a deployment.
-
"Cite for hearing" changes from an always-502 to a working signed system-of-record rendering (a live API/UI change).
-
Deployments MUST configure a logging facility that captures, retains, and alerts on the exported audit stream; canopy documents the contract but does not ship a SIEM/log pipeline (explicitly out of scope).
-
Migration safety: pre-export rows cannot retroactively gain off-box evidence; the retirement retains legacy hashes + a read-only verifier until expiry, or exports a durable closing checkpoint, and resolves open chain breaches into the incident system before the chain surfaces disappear.