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:

  1. 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.

  2. 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 existing rulesets/{jurisdiction}/jurisdiction.toml + citations.toml array-of-records precedent, selected by CANOPY_<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_audited attempt-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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Edit this page · default