ADR-014: FTI Audit Hash-Chain Integrity
On this page
Amends
ADRs are immutable once accepted, so this ADR amends ADR-004 rather than editing it. Read both together: ADR-004 defines where FTI audit logs live; ADR-014 defines what integrity properties those logs must have.
Context
ADR-004 §"FTI audit logs" requires that every FTI access be logged with user ID, timestamp, purpose code, data elements accessed, and originating system, and that those logs be retained 5+ years for IRS audit. It is silent on tamper evidence. The current fti_audit_log schema in canopy-tanf and canopy-medicaid (12 columns; migrations 20260325000001_create_fti_audit_log.sql and 20260326000001_create_fti_audit_log.sql) is append-only text — a row tampered with after the fact (column overwrite, row deletion, row reordering) cannot be detected.
The shared (non-FTI) audit_events table in canopy-security solved the same problem for its own scope in MR !90 / 20260402000001_add_hash_chain.sql by adding previous_hash and event_hash columns, an SHA-256 chain over (previous_hash || id || type || timestamp), and a verify_chain walker. Two production bugs surfaced during end-to-end testing of that chain (issue #312, MR !114):
-
Bug 6 (timestamp-precision drift).
chrono::DateTime::to_rfc3339()picks fractional-second precision dynamically — nanoseconds at insert time (fromchrono::Utc::now()), microseconds at verify time (round-tripped through PostgresTIMESTAMPTZ). The two strings hash to different values for the same logical timestamp. Fix: canonical fixed-width%Y-%m-%dT%H:%M:%S%.6f+00:00format, applied identically at insert and verify. -
Bug 7 (transaction-start vs. insert-time ordering). Inserts serialised by
pg_advisory_xact_lock(1)defaultedcreated_attonow(), which in Postgres istransaction_timestamp()— fixed atBEGIN, not atINSERT. Concurrent tasks acquire the advisory lock in scheduler order, butcreated_atreflects tx-start order. The chain-from querySELECT … ORDER BY created_at DESC LIMIT 1then returns whichever row has the latest tx-start time, not the actually-most-recently-committed row. Multiple concurrent inserts chain from the same predecessor → fork. Fix: explicitcreated_at = clock_timestamp()in the INSERT, inside the advisory-locked critical section.clock_timestamp()is strictly increasing across serialised inserts.
The fti_audit_log extension (Phase B, issue #311) must reproduce both fixes. Phase A’s three end-to-end tests against audit_events (chain_verifies_after_five_sequential_inserts, chain_breaks_at_tampered_row, chain_stays_valid_under_concurrent_inserts) are the Phase B test contract.
The remaining design questions surfaced during Phase A planning and are settled by this ADR before implementation begins:
-
What goes into the FTI hash?
-
What advisory-lock ID isolates the FTI chain from other writers?
-
How does the chain extend across the archive boundary (
fti_audit_log→fti_audit_log_archive)? -
What happens when verification fails — Pub 1075 §9 reportable, surface to auditor endpoint, both?
-
Does this require ADR-004 amendment? (Yes, this ADR.)
Decision
1. Schema additions
Add two nullable TEXT columns to fti_audit_log and fti_audit_log_archive in both canopy-tanf and canopy-medicaid:
ALTER TABLE fti_audit_log
ADD COLUMN previous_hash TEXT,
ADD COLUMN event_hash TEXT;
ALTER TABLE fti_audit_log_archive
ADD COLUMN previous_hash TEXT,
ADD COLUMN event_hash TEXT;
CREATE INDEX idx_fti_audit_event_hash ON fti_audit_log (event_hash);
CREATE INDEX idx_fti_audit_archive_event_hash ON fti_audit_log_archive (event_hash);
Each service has its own database (ADR-001), so the migrations live in each service’s migrations/ directory and apply independently. Existing rows have NULL for both columns; verification skips contiguous NULL-block prefixes (genesis tail) and starts the chain at the first row with a non-NULL event_hash.
2. Hash inputs
fn compute_fti_event_hash(
previous_hash: Option<&str>,
id: Uuid,
accessed_at: &DateTime<Utc>,
accessed_by: &str,
purpose_code: &str,
data_elements_accessed: &[String],
originating_system: &str,
action: &str,
resource_type: &str,
resource_id: Option<Uuid>,
) -> String {
let canonical_timestamp = accessed_at.format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string();
let canonical_data_elements = data_elements_accessed.join(",");
let canonical_resource_id = resource_id
.map(|u| u.to_string())
.unwrap_or_else(|| "NONE".into());
let mut hasher = Sha256::new();
hasher.update(previous_hash.unwrap_or("GENESIS"));
hasher.update(id.to_string());
hasher.update(canonical_timestamp);
hasher.update(accessed_by);
hasher.update(purpose_code);
hasher.update(canonical_data_elements);
hasher.update(originating_system);
hasher.update(action);
hasher.update(resource_type);
hasher.update(canonical_resource_id);
format!("{:x}", hasher.finalize())
}
Included: every column an IRS Pub 1075 §4 auditor cares about — who accessed FTI, when, why, what fields, from which system, what kind of action, against what resource.
Excluded: request_id (request-scoped, not access-defining), ip_address (operationally useful but can change without altering the access semantics — DHCP / proxy churn would cause spurious chain breaks), success (binary; an attacker flipping false → true would not be caught by chaining alone, but Pub 1075 §4 does not require it; auditors get the field in the row, not the hash). created_at is excluded for the same reason it’s excluded from the audit_events chain — it’s an internal bookkeeping column written by clock_timestamp() after the hash is computed.
3. Advisory-lock ID per database
Each FTI database (canopy-tanf, canopy-medicaid) uses its own pg_advisory_xact_lock ID, distinct from any other writer in that database:
-
canopy-tanfFTI chain:pg_advisory_xact_lock(advisory_lock_id("canopy-tanf.fti_chain")) -
canopy-medicaidFTI chain:pg_advisory_xact_lock(advisory_lock_id("canopy-medicaid.fti_chain"))
Pre-#423 the lock ID was the magic number 2 for both services. Per the amendment in #423 (2026-05-12), the lock ID is now derived from a hash of the human-readable name "<originating_system>.fti_chain" via fti_chain_lock_id (a private helper in canopy-common that mirrors canopy-db::advisory::advisory_lock_id — kept inline to avoid a canopy-db → canopy-common → canopy-db dep cycle). The chain hash itself is unchanged; only the lock-acquisition machinery moved from magic numbers to hash-based naming, matching the canopy-wide convention introduced in E0.4.
(Lock IDs are scoped to a Postgres database, so the same numeric ID in two different databases doesn’t contend. Pre-#423 lock 1 was reserved by canopy-security for audit_events; post-#423 every chain uses its own hashed name and the reserved-number registry is gone.)
Non-FTI writers (program eligibility logic, policy queries) do not take this lock. The advisory lock contends only with concurrent FTI inserts in the same service — workload is small (eligibility determinations call log_access once per FTI read, typically a few per case-touch), so the serialisation overhead is negligible.
4. Insert path
PostgresFtiAuditLogger::log_access (crates/canopy-common/src/fti_audit.rs:225) is rewritten to follow the audit_events insert pattern exactly:
-
pool.begin()→ start transaction. -
SELECT pg_advisory_xact_lock($1)with$1 = fti_chain_lock_id(originating_system)→ serialise (pre-#423 was the magic number2). -
SELECT event_hash FROM fti_audit_log ORDER BY created_at DESC LIMIT 1→ previous-hash lookup, inside the lock. -
compute_fti_event_hash(…)→ deriveevent_hash. -
INSERT INTO fti_audit_log (…, previous_hash, event_hash, created_at) VALUES (…, $13, $14, clock_timestamp())→ explicitclock_timestamp()per Bug 7. -
tx.commit().
The Bug 6 + Bug 7 fixes are mandatory — implementations that write to_rfc3339() or rely on the column default now() are not Pub 1075-compliant under this ADR.
5. Verification path
A new crates/canopy-common::fti_audit::verify_fti_chain(pool: &PgPool) → sqlx::Result<Result<usize, (Uuid, String)>> helper mirrors canopy_security::store::verify_chain:
-
Walk rows ordered by
created_at ASC. -
Skip the contiguous
NULL-event-hash prefix (rows written before this ADR landed); start verification at the first row with a non-NULLevent_hash. -
For each row: assert
previous_hashequals the prior row’sevent_hash, then assertevent_hashequalscompute_fti_event_hash(…)recomputed from the row’s columns. -
Return
Ok(usize)for the number of rows successfully verified, orOk(Errrow_id, reason)if the chain breaks. The outerResultis reserved for sqlx I/O failure.
6. Archive-boundary chaining
When rows age past Pub 1075’s 5-year retention floor (figure corrected to 7 years, Pub 1075 AU-11 — see Amendment 12 / ADR-041; #1363), archive_expired_records (crates/canopy-common/src/fti_audit.rs:406) moves them from fti_audit_log to fti_audit_log_archive. The chain MUST continue across this boundary so that an auditor inspecting both tables can verify a single contiguous chain from genesis.
Pattern (precedent: services/canopy-security/migrations/20260409000000_align_archive_hash_columns.sql):
-
fti_audit_log_archivecarries the sameprevious_hash/event_hashcolumns. Archival is a row copy (with both columns preserved) followed by source delete. -
verify_fti_chain_fullwalksfti_audit_log_archiveordered bycreated_at ASC, thenfti_audit_logordered bycreated_at ASC. The first row infti_audit_logmust haveprevious_hashmatching the last row infti_audit_log_archive’s `event_hash. -
verify_fti_chain(live-only) remains the cheap default;verify_fti_chain_fullis invoked by the auditor endpoint when?include_archive=trueis set.
7. Failure-mode contract
A chain break is reportable under IRS Pub 1075 §9 (incident response). When verify_fti_chain returns Errrow_id, reason:
-
canopy-securitypublishes afti.audit_chain.breach_detectedevent oncanopy.events. The event payload containsservice,database,row_id,reason,detected_at— no FTI fields. Thecanopy-securityaudit subscriber persists this tobreach_alerts. -
The auditor endpoint
GET /v1/security/fti/chain-status?service=<canopy-tanf|canopy-medicaid>&window=<duration>returns HTTP503 Service Unavailablewith the breach details until the breach is investigated and resolved. -
Successful verifications publish
fti.audit_chain.verifiedwithlast_verified_at,rows_verified,service.
The 503 is not a routine API behaviour — it is the contractual signal that the integrity guarantee has been violated and out-of-band incident response (per Pub 1075 §9) is required. The endpoint MUST NOT serve cached "last good" results while a breach is unresolved.
8. Scheduled verification
A daily job services/canopy-security/src/jobs/fti_chain_verify.rs walks each FTI service’s chain and emits the appropriate event. canopy-security is the natural host because (a) it already runs the breach-detection background loop for audit_events, (b) it has read access to no FTI columns — only the chain integrity columns, which are not FTI under Pub 1075 §4 (they are metadata about FTI access events, not the FTI itself).
The verify job uses a read-only Postgres connection per FTI database, configured via per-service connection strings in the canopy-security settings. It does not write to FTI tables; it does not log to fti_audit_log (a verify is not an FTI access).
9. Performance budget
Per FTI insert (steady state):
-
1 advisory-lock acquisition (negligible under low FTI write rate)
-
1 SELECT for previous-hash lookup (indexed by
created_at DESC) -
1 SHA-256 computation (~µs)
-
2 additional column writes
-
1 explicit
clock_timestamp()evaluation
Eligibility determinations are not in the hot path of high-RPS workloads (they’re case-scoped, not session-scoped). The serialisation overhead is acceptable for the integrity guarantee.
Rationale
Why hash-chain rather than column-level signing or HSM?
Column-level row signatures (e.g., per-row HMAC) detect column tampering but not row deletion or reordering. Hash chaining detects all three at the cost of one extra column. HSM-based signing was the alternative considered in ADR-004 itself for the same reason; rejected there for cost. The chain pattern has no marginal infrastructure cost.
Why per-database advisory locks rather than a global one?
Each FTI database is independent (ADR-001). Cross-database locking would require a coordinator service or a shared lock database. Per-database locks are simpler, contention-free across services, and consistent with how canopy-security already serialises its own chain.
Why 503 rather than serving the last-known-good verification?
Pub 1075 §9 expects out-of-band incident response, not API workarounds. A cached success response would mask an unresolved integrity violation from anyone polling the endpoint — exactly the wrong behaviour. The 503 is the API surface of the breach state; clearing it requires investigator action, not auto-recovery.
Why exclude created_at from the hash?
created_at is written by clock_timestamp() after the hash is computed. Including it in the hash would create a circular dependency. The same reasoning applies in canopy-security::store::compute_event_hash — created_at is a chain-ordering column, not a chain-content column.
Why exclude request_id and ip_address?
Both can change without altering the FTI access itself: request_id is request-scoped (the same access can have different request IDs across retries), ip_address can change with proxy / load-balancer churn. Including either would produce spurious chain breaks during normal operations. They remain in the row for auditors but not in the chain.
Alternatives considered
Alternative 1: Per-row signature (HMAC) instead of chain. Detects column tampering, not row deletion or reordering. Rejected — Pub 1075 §4 expects "an audit trail" that is end-to-end verifiable; per-row signatures break "end-to-end".
Alternative 2: Shared chain across canopy-tanf and canopy-medicaid via canopy-security. Would require either replicating FTI rows to canopy-security (violates ADR-004) or a cross-service chain coordinator. Per-database chains preserve isolation; the auditor endpoint aggregates verification status across services without aggregating data.
Alternative 3: Hash includes created_at. Would require splitting the INSERT into two phases (insert with placeholder, update with hash) or reading clock_timestamp() before the INSERT and reusing it. Extra round-trip + complexity for no integrity gain — created_at only orders rows; chain integrity comes from previous_hash / event_hash.
Amendment 1 — Change-history tamper-evidence (audit_events v2 hash) (T2-5, 2026-06-21)
Status: Accepted (this ADR remains Accepted; amendments extend, they do not supersede).
hash_version selector and the legacy v1 formula described in
this amendment were removed by Amendment 3 (T2-6 #687) once it
was confirmed no v1 rows exist (pre-1.0; devstack re-seeds). The v2 formula below
is now the sole audit_events chain formula — read each "v2" / "per-row version"
reference here as historical.
The non-FTI audit_events chain in canopy-security (the chain whose Bug-6/Bug-7
fixes this ADR codified — see Context) originally hashed only
previous_hash · event_id · event_type · canonical_timestamp. The worker
fact-mutation change-history (T1-5 #673, surfaced by T1-6 #674) stores the actor
(user_id/user_role + nested metadata.author), the action, the resource
(resource_id = the fact_id), and the before/after values (in metadata) —
none of which were hashed, so a privileged row rewrite could flip claim_status,
rewrite an amount, or re-attribute an action without breaking the chain. T2-5 (#686)
closes that gap with a versioned hash; the FTI fti_audit_log chain (the original
subject of this ADR) is unchanged — it already covers actor/action/resource, and
before/after is not an FTI concept.
-
Per-row versioning. A new
audit_events.hash_version SMALLINT(added DEFAULT 1 to backfill history, thenSET DEFAULT 2) selects the formula per row, soverify_chainrecomputes each row under its own version and a mixed v1/v2 chain verifies. v1 (hash_version = 1) is the original formula, byte-for-byte — zero churn for existing rows. v2 (hash_version = 2) is the current formula. Historical v1 rows are never re-hashed. An unknownhash_versionis a chain break, never a silent re-hash under the wrong formula. -
v2 = JCS over a typed struct, not a delimiter-free concat. v2 hashes the RFC 8785 (JCS) canonical bytes of a typed input struct (mirroring
DeterminationSnapshot::canonical_bytes) coveringprevious_hash,event_id,event_type, canonicaltimestamp,user_id,user_role,action,resource_type,resource_id,source_service,household_id, andmetadata(which carries before/after + the nested author/claim_status/version_id).source_servicescopes fact-history selection andhousehold_idscopes case-audit reads, so both are hashed — otherwise a rewrite could move/hide events from those views. A delimiter-free concatenation with a string null-sentinel was rejected: it permits field-boundary shifts andNonevsSome("NONE")collisions. JCS gives named keys + canonical ordering, withNone→ JSONnull— unambiguous and independently reproducible. -
JSONB round-trip closed by construction. The hash covers
metadata, which is re-hashed at verify; a non-integer float could round-trip through Postgres JSONB differently than the pre-store value. At insert the metadata is normalized through Postgres once (SELECT $1::jsonb) and that normalized value is both hashed and stored, so verify re-hashes byte-identical bytes — for any JSON, floats included.audit_eventsis a wildcard ingest, so this is a construction guarantee, not a float-free assumption. -
Deterministic ordering.
clock_timestamp()is microsecond-precision; the insert previous-hash lookup and the verify walk both order bycreated_at, id(UUID v7 tie-break) so a tie cannot fork or mis-order the chain. -
Verification scope. Verification is server-side / DB-level:
verify_chainreadshash_versionfrom the row. The wireevent_hashstays an opaque integrity token (it was never independently recomputable from the DTO, which omitsprevious_hash/event_id/timestamp/…), sohash_versionis an internal DB column — no wire/contract change.
Consequences
-
fti_audit_logrows written before the Phase B migration land haveNULLchain columns. Verification starts at the first non-NULLrow; the pre-migration tail is treated as historical, unverifiable, and out of scope for §9 breach reporting (consistent with howaudit_eventshandled its own grandfather window in MR !90). -
archive_expired_recordsMUST preserveprevious_hash/event_hashduring the move; archival without preservation breaks the chain at the archive boundary. -
PostgresFtiAuditLogger::log_accessis now serialised by an advisory lock. Concurrent FTI access in the same service-database is rare (eligibility determinations are case-scoped), so contention is bounded; if it becomes measurable, switch to a sharded lock keyed onaccessed_by(still ordered, no fork risk). -
canopy-securitygains per-FTI-service connection configuration (verify job + auditor endpoint). The connections are read-only. -
The auditor endpoint’s 503 is a contractual response on breach — clients must treat it as a "stop the world" signal, not a transient network issue. Documented as part of the OpenAPI surface.
-
This ADR does not change ADR-004’s isolation map. FTI remains in canopy-tanf and canopy-medicaid only; canopy-security inspects only the chain columns, never FTI fields.
-
New federal data sources added under ADR-004 that fall under Pub 1075 inherit this ADR’s hash-chain requirement automatically.
Amendment 2 — Derivation edges reference FTI by id, not value (T2-2 #679)
Status unchanged (still Accepted). The T2-2 determination derivation graph
(ADR-028 Amendment 2) records,
for FTI-derived facts, edges whose inputs are typed references into the frozen
snapshot — never copies of the FTI/SSA payload. Concretely, the Medicaid ABD chain
(derive_abd_flags_from_solq) emits FactPath::CrossProgram { path:
"solq[person_id=<uuid>].<field>" } inputs pointing at the by-value SOLQ projection
already frozen in cross_program_inputs.solq (Amendment 1) — the edge names the
field, not its value. So the derivation graph adds no new FTI surface: it does
not duplicate FTI into a second location, and a determination snapshot’s existing
ADR-014 chain entry (which records solq in data_elements_accessed when SOLQ was
frozen) already covers the only FTI the graph references. The graph’s
`DerivedFactNode.value`s are the derived outputs (eligibility booleans, COA
flags), not raw FTI records. No change to the chain columns, the hash, or the
ADR-004 isolation map.
Amendment 3 — Collapse to the single chain formula (drop v1 + hash_version) (T2-6 #687, 2026-06-24)
Status unchanged (still Accepted). Amendment 1 introduced a
per-row hash_version SMALLINT selector so legacy v1 rows (the original
previous_hash · event_id · event_type · canonical_timestamp concatenation) stayed
byte-stable alongside the hardened v2 formula. Pre-1.0 there is no production data
and the devstack re-seeds, so no v1 rows exist: T2-6 (#687, the crypto-shred /
key-retention marathon — ADR-036)
drops the hash_version column from audit_events and its archive (migration
20260624130000) and collapses compute_event_hash to the single (former-v2)
JCS-over-typed-struct formula.
-
No formula change. The surviving formula is byte-for-byte the Amendment 1 v2 formula — the same
AuditChainInputsfield set (previous_hash,event_id,event_type, canonicaltimestamp, actor, action, resource,source_service,household_id,metadatacontent) hashed over RFC 8785 (JCS) canonical bytes. Existing v2event_hashvalues verify unchanged; only the now-redundant per-row selector and the dead v1 branch are removed. -
verify_chainsimplifies. It recomputes every row with the one formula; the "unknownhash_versionis a break" rule is gone with the column. A NULLevent_hashmid-chain or a canonicalization failure remains a break, and the leading NULL-hash seed prefix is still skipped. -
Archive ordinals stay aligned. The column is dropped from both
audit_eventsandaudit_events_archivein the one migration, so the positionalINSERT INTO audit_events_archive SELECT * FROM audit_eventsarchive move keeps matching column ordinals (mirroring how Amendment 1 added it to both). -
No wire/contract change.
hash_versionwas always an internal DB column (never on the wireAuditEvent), so its removal is DB-only — no OpenAPI/DTO delta.
Amendment 4 — Sealed fact-event before/after verify under the unchanged formula (T2-6 #687, 2026-06-25)
Status unchanged (still Accepted). T2-6 MR9
(ADR-036 §5/§7) seals the PII money leaves of the
income/asset/expense.claimed/closed event before/after windows before publish, so the
audit_events.metadata JSONB canopy-security stores now holds AEAD ciphertext (a SealedValue
envelope) where it previously held plaintext figures.
-
No formula change (again). The Amendment 3 single formula JCS-hashes the full
metadatacontent, so a sealedbefore/afteris hashed exactly as it is stored — the ciphertextctstring is canonicalized like any other JSON value. Existingevent_hashvalues over already-sealed rows verify unchanged. -
The chain stays valid across redaction. A fact redaction shreds the per-fact DEK (tombstones the
redaction_keysrow); it never rewrites theaudit_eventsrow, sometadata— and theevent_hashover it — are byte-identical before and after. The value simply becomes unopenable; the chain still verifies. Tamper-evidence is thus preserved over redacted values (ADR-036’s hash-over-ciphertext property at the audit-ledger surface), with no security-side key: the audit copy shares the persons fact DEK (single-owner; ADR-036 §5 as-built), so there is no cross-service shred or fan-out. -
No wire/contract change.
before/afterwere already opaque per-kind JSON on theFactChangeEntryread shape; sealing changes their content (an envelope object), not the schema.
Amendment 5 — chain-v2 protocol: hash-bound sequence + durable head, sharded, externally anchored (scale audit epic &73, #1236, 2026-07-27)
Status unchanged (still Accepted; amendments extend, they do not supersede). The
2026-07-25 scale-readiness audit (epic &73) found that the original design cannot scale or
stay tamper-evident at the 3M-Georgia / 15M-single-deployment horizon: verification is a
full-walk-from-genesis fetch_all on request/boot paths (C5/C6), the global append is
single-writer with an unindexed in-lock predecessor lookup (C4/H13), archival by timestamp
breaks the genesis check and can silently drop rows (H8), the FTI advisory lock forks on
a caller-supplied originating_system (§3 as-built), and the hash binds no ordinal so a
reorder is invisible. Five findings (three CRITICAL) share the ADR-014 root. This amendment
replaces the timestamp-ordered, full-walk chain with chain-v2: a hash-bound sequence with
a durable head, sharded for horizontal write scale, verified incrementally, and anchored to an
external notarized authority. It applies to all three chain families — audit_events
(canopy-security), fti_audit_log (canopy-tanf, canopy-medicaid), and ele_grant_events
(canopy-medicaid; migration deferred, #1248).
This amendment pins the contract (C1–C8), the invariants, and the acceptance-test suite;
the implementation children own the byte-level design (exact schemas, KAT vectors, staging
transport, DTOs) under these constraints. It supersedes the original §5/§8 full-walk
verification, the §4 originating_system lock (Amendment to §3, #423), and the §6
timestamp-ordered archive boundary. The pre-named sharded-lock follow-up
(§3 + the Consequences note) is insufficient and is withdrawn: a batch of appends does not
share one accessed_by, and a lock never gives a shard path — the chain itself must shard.
Settled decisions
-
Hash-bound sequence + durable head first — never timestamp order. Resumability, sharding, and archive boundaries are all derived from a monotonic per-shard sequence and a durable head row, not from
created_at. Retrofitting resumability onto timestamp order was rejected. -
Reset, not rebuild. Pre-1.0 there is no production FTI/audit data (devstack + UAT re-seed), so the transition is a coordinated-downtime cutover that resets every chain to an empty, externally-notarized genesis. No legacy formula, no dual-format reader, no mixed-order verifier, no rebuild-from-history machinery is written — that burden is avoided entirely (canopy has no pre-1.0 backward-compatibility obligation).
-
Anchors are external notarized head-manifests. Chain integrity is bound to a signed manifest stored in an external append-only authority on a cadence/SLO, for both families. This closes the coherent-privileged-rewrite gap that a purely in-database chain cannot: an attacker who can rewrite a suffix and the head and the checkpoint still diverges from the last external manifest.
-
Retention/purge is policy data, not source. The retention bound and purge-enablement are per-jurisdiction ruleset values, per family (ADR-003 / ADR-011; legal-hold aware), bounded below by the IRS Pub 1075 §4 federal floor — not a source constant.
-
ADR = contract + invariants; children own byte-level. This ADR is the contract and the gate list; the substrate/verifier/append/archive children carry the schemas, KAT vectors, and wire shapes.
Defects this amendment resolves (verified file:line)
-
FTI lock fork. The FTI lock is
fti_chain_lock_id(originating_system)over a single logical chain —crates/canopy-common/src/fti_audit.rs:31-38,334. Two differently-labeled writers take different locks and fork the chain. -
False genesis / false breach on archive. The predecessor is read from the live table only (
ORDER BY created_at DESC LIMIT 1,fti_audit.rs:302-304); archival cuts byreceived_at/accessed_at(≠ chain order) with no anchor (store/mod.rs:1164-1199,fti_audit.rs:700-752), and the general archive’sINSERT … ON CONFLICT DO NOTHINGthen blanketDELETE(store/mod.rs:1175) can drop a row whose archive copy was never proven equal. -
OOM / no-lease / stale-green / auto-clear-breach.
fetch_allfrom genesis (fti_audit.rs:629,store/mod.rs:205); every replica runs the verifier,Err→warn;return, latest-row-wins clears a breach (jobs/fti_chain_verify.rs:60-124,store/mod.rs:427-443) — violating the §7 sticky-breach contract. -
Hash excludes the ordinal; ambiguous FTI hashing. audit uses typed JCS (
store/mod.rs:42), FTI uses concat + comma-join (fti_audit.rs:77); neither binds position. -
False least-privilege claim. §8 and the Consequences both state canopy-security "has read access to no FTI columns — only the chain integrity columns". That is inaccurate: verification must re-hash the access metadata (
accessed_by,purpose_code,data_elements_accessed, …) it reads viaSELECT *(fti_audit.rs:313,667-678). C8 corrects this. -
Doc/schema drift + retention conflict. The §9
created_atindex claim (resolved by removing timestamp-order verification, so #1197 stays closed); and the retention conflict betweenato-readiness.adoc(3–5 yr) andauditor-handbook.adoc("indefinite"), reconciled to the per-jurisdiction ruleset value.
P0 containment (lands first, independently — #1245)
Before the redesign, a fix: MR contains the live hazards without waiting on chain-v2: replace the
originating_system-derived lock with a constant per-chain lock (+ a concurrency test proving two
distinct originating_system values cannot fork), and fail closed every current full-walk /
archive-mutation path — the sync archive endpoint (api/mod.rs:601), the sync FTI/audit verify
endpoints (:692, :547), and the boot verify tick — so no full-table walk or inline archive move
runs on any request or boot thread. Chain-status reports an explicit interim unknown, never
stale-green. (Delaying the boot tick alone is insufficient — the sync manual endpoints still OOM.)
The chain-v2 contract
The ADR pins these clauses; the substrate child (#1246) owns the byte-level schema, KAT vectors, and restricted roles.
C1 — Identity + position. The canonical position of every event is the tuple
(chain_instance_id, chain_family, chain_epoch, shard_id, chain_seq). chain_instance_id is a
non-reusable, trusted per-chain identity minted at each reset; chain_family/service/database identity
is enumerated — "FTI" is two instances (canopy-tanf, canopy-medicaid), distinct from audit_events
and from ele_grant_events. The full identity is bound into rows, hashes, heads, checkpoints, and
anchors. Timestamps are queryable metadata, never position.
C2 — Hash (rules pinned; KAT vectors owned by the substrate child). event_hash = SHA-256 over a
typed RFC-8785 / JCS canonical encoding (FTI converges off its concat/comma-join onto the audit JCS
approach) of: the domain tag "canopy-chain-v2", the full C1 identity tuple, hash_formula_version
(mandatory, fixed = 2, no legacy values — the protocol is named chain-v2 and the domain tag is
"canopy-chain-v2" specifically to disambiguate from the pre-existing audit_events hash_version = 2
of Amendment 1, migration 20260621000000), previous_hash, and the
canonical_event_payload. The ADR carries a per-family field-coverage table classifying every
stored column as hashed / derived / excluded-with-rationale / forbidden (today FTI excludes
request_id/ip_address/success/created_at; audit excludes the row id / ip / receipt timestamp).
UUID / timestamp / JSONB / string / array normalization, genesis, and the anchor canonical
representation are pinned as rules; frozen known-answer test vectors live in the substrate child and
gate it. canonical_event_payload must be obtainable and stored so it re-hashes identically — the
audit PG-JSONB normalization pattern (store/mod.rs:125, SELECT $1::jsonb once at insert, hash + store
the normalized bytes) is the reference.
C3 — Append (durable head + FOR UPDATE). A chain_heads row per (instance, family, epoch, shard)
holds last_seq + last_hash — the authoritative durable tip. Writers always chain from
chain_heads, never from the newest live row. Per batch: validate + canonicalize before locking;
process each shard in its own transaction — SELECT … FOR UPDATE that shard’s head, assign a
contiguous chain_seq range, hash + insert, advance the head in the same tx. N is per-shard; a
single tx does NOT lock all touched heads (that recreates global serialization + cross-shard rollback).
A multi-shard write inside one FTI determination tx computes all shards first, then locks heads in
shard-id order. Shard = a server-derived hash (function + modulo pinned) of an immutable event id
— the ingestion path pins which id (bus envelope id for #-queue events, server-minted row id for
direct ingest). Genesis / empty-head hash, the epoch-0 root, empty-shard genesis, and the first-row
previous_hash are all specified. Direct ingest (POST /security/audit/ingest, api/mod.rs:235)
enqueues into the same durable staging transport (not a synchronous append); its 202 is redefined
to "accepted + durably staged" (chained by the per-shard drainer), replacing today’s "accepted and
chained".
C4 — Topology + epochs (fenced state machine). A chain_epochs / topology registry records the active
epoch, the fixed shard count, the routing version, the state open|closing|anchored|closed, shard
membership, and FKs. Head rows are pre-created — locking a missing row locks nothing, so lazy-create
is a fork race. Transitions are fenced so a stale binary is structurally unable to append to a closed
epoch or use a different shard count. Shard count changes only at an epoch boundary; a new epoch’s
shard genesis references the previous epoch’s anchor. Sharding loses global append order: chronology
comes from hashed timestamps / query metadata, and the amendment records that no API or auditor contract
relies on the old global order.
C5 — Anchors = external notarized head-manifests. Distinct anchor kinds — periodic tip-notarization,
epoch-closure, archive-watermark, purge-boundary. Each manifest binds
{chain_instance_id, family, epoch, hash_formula_version, shard-count, routing-version, per-shard
(last_seq, last_hash), anchor-kind, previous-anchor-hash} under a pinned canonical encoding, is signed
and stored in an external append-only authority (outside the chain-owning DB), is verifier-confirmed,
and is emitted on a cadence/SLO. Periodic tip notarization is what closes the coherent-privileged-
rewrite gap. The amendment pins the authority + credentials + append-only storage + signature/key
retention + monotonic replay protection + every failure transition. Epoch closure is a
crash-resumable cross-database state machine, not a single transaction.
C6 — Verification (sharded). Per-(instance, family, epoch, shard) targets + progress + an aggregate
run manifest. Two loops: tail (capture a fixed (target_seq, target_hash) per shard, verify bounded
batches up to it, expose verified_through + lag) and historical scrub (bounded, resumable, over the
full retained history to its own fixed trusted target so it terminates under continuous writes; detects
mutation behind the tail). Verification MUST reject: missing/unexpected shards or heads; duplicate,
missing, or noncontiguous seq; wrong previous_hash / formula version; a head that is not its terminal
row; rows beyond the head; invalid genesis / epoch-anchor linkage; orphan heads or rows; and
divergence from the latest external manifest. Status states are unknown|verifying|healthy|stale|error|
breached with separate tail vs scrub coverage/freshness (per-shard aggregation, lag/max-age
thresholds, derived-state precedence); stale is computed at read time (a worker dying before it
records an error cannot leave cached green); a clean pass never auto-clears breached — resolution needs
an authorized actor + reason + evidence + revalidation. Citation path: event-specific coverage (the
cited event’s shard is verified at/below verified_through and within the notarized manifest) or a
dedicated attestation endpoint; fail closed for newer-than-checkpoint / unknown / stale / error /
breached. The status DTOs (crates/canopy-contracts-security/src/{chain,fti}.rs) are replaced with
per-shard coverage + tail/scrub times + lag + incident id + HTTP mappings. A nonzero staging backlog
(staged-but-unchained events) MUST degrade status (never healthy) — events are durably staged
inbox-stamped before ack, and backlog depth surfaces in status. The status/checkpoint store separates
mutable progress (cursors/lease) from immutable run results from the sticky incident record; checkpoint
identity = (instance, family, epoch, shard, loop-kind) + fence/CAS; the async manual verify returns a
job id + polling URL (truly async, not sync-then-202).
C7 — Archive + purge. Archive/purge move a contiguous chain_seq prefix per shard, never a
timestamp-selected set. Retention decides eligibility; chain_seq decides the boundary = the greatest
leading seq for which every row is eligible (not the largest eligible seq — received_at /
accessed_at are not monotonic with seq). The retention bound + purge-enablement are per-jurisdiction
ruleset values, per family (loaded from the jurisdiction ruleset, legal-hold aware, bounded below by the
Pub 1075 §4 floor). Isolation contract: verifier snapshot semantics, duplicate handling, movement
generation, single-archiver ownership, archived_through advanced in the same tx; the move is one
transactional DELETE … RETURNING → INSERT with exact count/content equality and rollback on conflict
(killing the ON-CONFLICT-DO-NOTHING data-loss path). Purge order: determine the contiguous eligible
prefix → verify from the previous trusted boundary → externally sign/store + acknowledge a per-shard
boundary manifest → delete locally → advance the local boundary. Failures leave rows intact;
anchor-without-purge is safe and retryable. Archival is scheduled only after both verifiers prove the
archive-aware path. The audit_events archive/purge is #1208; the FTI twin is #1247.
C8 — ADR-004 preservation + least-privilege. Restricted-data isolation is preserved: per-record
Pub 1075 §4 granularity (a batched chain append never collapses per-access rows), and no FTI on
canopy.events (manifests and breach events carry no FTI). The amendment corrects the inaccurate
"only integrity columns" claim in §8 and the Consequences: verification
reads the hashed-preimage projection (the access metadata needed to re-hash — accessed_by,
purpose_code, data_elements_accessed, action, resource_type, …), not SELECT * and not "only the
chain columns". It pins the minimum projection + grants + restricted DB roles/procedures for the
live / archive / head / epoch / anchor tables — the real containment of the maintenance-GUC threat (the
append-only guard’s statement-level shape is not the defect). The canopy-security cross-database read is
reconciled against ADR-004 by naming the authorized
read-only verification role (or amending ADR-004).
Invariants / acceptance-test suite (the gates children must pass)
-
Concurrent different-origin FTI writes cannot fork.
-
No duplicate seq and no head-row divergence after rollback or crash; a batch failure cannot partially advance a head.
-
A cursor / seq / payload rewrite → hash failure; historical mutation behind the tail cursor is detected by the scrub; a coherent suffix + head + checkpoint rewrite is detected by divergence from the external manifest.
-
Archive concurrent with append/verify neither loses nor duplicates a logical row; a live-empty append continues from the durable head; purge preserves a verifiable retained-history boundary.
-
Restart resumes at every batch boundary, in O(batch) memory; multiple replicas + manual triggers cannot overlap ownership; a query failure →
stale/error, never cached green; a breach is latched until explicit authorized resolution. -
Hash + anchor golden vectors (KATs); deterministic routing; empty-shard genesis; epoch rollover + crash recovery; anchor replay / key rotation / authority outage; whole-shard/head deletion detected.
-
Direct-HTTP-ingestion append; inbox/ack crash windows; poison-row isolation; multi-shard contention (no global serialization); event-specific citation (fail-closed for newer-than-checkpoint); multi-million-row memory + render; truly-async manual POST; archive index plan + resume; singleton scheduling; purge crash recovery; sustained-throughput evidence recorded in the MR.
-
The clean-break cutover yields a fully-verifiable notarized-genesis chain, or fails closed.
Rollout sequence
P0 containment (#1245) → this amendment (#1236) → chain-v2 substrate (#1246: schema + KATs + roles
empty-genesis) → verifier children (#1205 audit, #1206 FTI) landed dormant → coordinated-downtime
cutover (quiesce, reset live + archive + integrity, mint chain_instance_id, install topology + heads,
deploy v2 writers + verifier, verify + notarize the empty genesis, reopen) → append transport (#1207:
durable staging + leased per-shard drainer) → finish the verifier children (background jobs, citation
coverage, status DTOs, singleton scheduling) → archive/purge children last (#1208 audit + #1247 FTI).
ele_grant_events migration is deferred (#1248). #1197 stays closed; its predecessor-query EXPLAIN tests
are retargeted or retired, and which created_at indexes remain is an ordinary-query / retention decision,
not a chain concern.
Consequences
-
The original §4–§8 mechanics (timestamp-ordered predecessor lookup, full-walk verify, timestamp-boundary archive, per-caller lock) are superseded by C1–C8 for all three families; the §3/§261 sharded-lock note is withdrawn.
-
Retention doc conflict is reconciled:
ato-readiness.adocandauditor-handbook.adocdescribe the concrete retention/purge boundary as a per-jurisdiction ruleset value (per family, legal-hold aware), bounded below by the federal floor — replacing the flat "indefinite" claim. -
canopy-security’s chain-integrity role gains an explicitly-named, restricted, read-only cross-database verification grant (C8), reconciled with ADR-004; it still holds no FTI beyond the hashed-preimage projection required to re-hash.
-
New federal data sources added under ADR-004 that fall under Pub 1075 inherit chain-v2 automatically (the chain-family enumeration in C1 is the extension point).
Amendment 6 — chain-v2 substrate byte-level contract: field coverage, source identity, epoch gating, conforming canonicalization (#1246, 2026-07-30)
Status unchanged (still Accepted). Amendment 5 delegated the byte level to the substrate child (#1246); this amendment records the contract-surface consequences of that design (plan: chain-v2 substrate) — accepted amendments are immutable, so each is a formal revision here, never an in-place edit of Amendment 5.
Canonicalization must be RFC 8785-CONFORMING (revises C2’s implied implementation)
The substrate research proved serde_jcs 0.1 (the then-JCS dependency; retired repo-wide by #1281 with per-site byte-identity evidence) is not a conforming
RFC 8785 implementation: it sorts serialized UTF-8 key bytes (quotes + escapes included) where
the RFC requires raw-name UTF-16 code-unit order — divergent even on plain-ASCII keys — and it
emits i64 verbatim beyond 2^53−1, which a conforming verifier cannot reproduce. Since C5’s
whole point is independent external verification, chain-v2 pins: a conforming implementation
(serde_json_canonicalizer, selected per the plan’s D-CANON) plus a recursive I-JSON
validation layer that rejects non-finite numbers and integers with |n| > 2^53−1 before hashing
(conforming serializers silently ROUND oversize integers through f64 — a semantic collision an
audit chain must refuse; floats pass). The legacy call sites' migration is #1281 (out of
chain-v2’s scope).
chain_source joins the trusted identity (extends C1)
C1’s "chain_family/service/database identity is enumerated" is made columnar and
hash-bound: every v2 event row and every anchor manifest carries chain_source
(canopy-security | canopy-tanf | canopy-medicaid), bound into the C2 preimage, validated
by the append functions against the chain_sources registry (source → instance, installed at
genesis), with the source literal baked into each service’s rendered append function at
migration-authoring time — never caller-supplied at runtime. The two FTI instances therefore
remain distinct end-to-end, per C1’s original intent.
Epoch states revised (revises C4)
The C4 state set open|closing|anchored|closed becomes
installing|active|closing|anchored|closed: genesis installs epoch 0 as installing
(structurally NOT appendable — the append functions require state = 'active' AND the epoch
equal to the topology pointer’s current_epoch); the single installing → active transition
is the chain_epoch_activate CAS, executed at the coordinated-downtime cutover (#1279) only
after the genesis anchor is externally notarized and confirmed. This closes the
appendable-before-notarization window. The transition executor for closing → anchored →
closed remains #1280’s, under the pinned epoch→head lock-order protocol.
The genesis anchor kind (extends C5’s kind list)
C5’s anchor kinds gain a fifth: genesis — the notarized empty-genesis manifest
(anchor_seq = 1, previous_anchor_hash = the zero sentinel), distinct from
periodic_tip so the trust root is self-describing. The C5 requirement that "the amendment
pins the authority + credentials" is discharged in stages: the substrate pins the manifest
bytes, the local record, and the DTOs; the concrete authority/credential selection (#1278) is
RATIFIED by a further amendment here when it lands.
Storage semantics corrected (revises C2’s reference pattern)
C2’s "hash + store the normalized bytes" reading is corrected: PostgreSQL JSONB stores a
semantic value, not bytes. The pinned semantics are refetch-and-recanonicalize — both
the insert side and every verify side canonicalize the same refetched JSONB value, which is
deterministic for any JSON because normalization happened once through $1::jsonb at intake.
Per-family field-coverage tables (discharges Amendment 5’s asserted table; supersedes C2’s "today … excludes" parenthetical)
| Class | FTI family (fti_audit_log_v2) |
|---|---|
Hashed |
|
Excluded-with-rationale (stored via the |
|
Server-side (clocked/minted inside the append function; never caller-supplied) |
|
Forbidden in payload |
every excluded column (builder-enforced + SQL-validated) |
| Class | Audit family (audit_events_v2) |
|---|---|
Hashed |
|
Excluded-with-rationale |
|
Server-side |
the row |
Domain-tag / protocol-version registry
Domain tags: canopy-chain-v2 (event-hash preimage), canopy-chain-v2/routing/v1 (shard
routing), canopy-chain-v2/genesis (empty-head hashes), canopy-chain-v2/anchor (anchor
manifests). Independent versions — a byte-level change bumps ITS OWN version, never the
others: event_hash_formula_version=2, routing_version=1, genesis_version=1,
anchor_manifest_version=1, anchor_signing_version=1. The frozen KAT corpus (substrate
MR-1) pins all of them; changing a frozen vector requires the corresponding version bump plus
an amendment here.
Direct-ingest routing id (corrects C3’s wording)
C3 said "server-minted row id for direct ingest"; the row PK is append-minted and excluded
from the hash, so it cannot be the routing id. The corrected recommendation is the
server-minted envelope event_id (the ingest endpoint already mints it); the binding pin
remains #1207’s, and shard_for is id-agnostic.
C8 clarification — "read-only" covers CHAINED DATA
The verification role’s read-only posture governs the chained data (especially the
cross-database FTI reads: projection views only, never SELECT *); its own verification-STATE
tables (checkpoints/runs/incidents/anchors, all homed in canopy_security) are written by
design — exclusively through guarded SECURITY DEFINER CAS/transition functions, never direct
DML, with incident RESOLUTION authority separated from the background verifier entirely.
Rollout order corrected (revises Amendment 5’s sequence)
The cutover cannot precede the append transport: at cutover direct DML becomes forbidden and v1 writers cannot call the v2 functions, so nothing could write. The corrected sequence — substrate (#1246) → #1207 writers (dormant) + #1205/#1206 verifiers (dormant) + #1278 anchor authority delivered → #1279 coordinated-downtime cutover LAST, depending on all of them (go/no-go gate in #1279). The parent plan carries the same correction.
Amendment 7 — chain-v2 append-transport bindings: routing id, event uniqueness, the structural drainer lease, the ingest 202 (#1207, 2026-07-30)
Status unchanged (still Accepted). Amendments 5/6 delegated the append transport’s byte-level decisions to #1207; this amendment ratifies the bindings its plan (chain-v2 append transport) makes — accepted amendments are immutable, so each is a formal revision here.
Direct-ingest routing id BOUND (discharges Amendment 6’s delegation)
Amendment 6 recommended the server-minted envelope event_id for direct ingest and left
the binding pin to 1207. BOUND: direct ingest (POST /v1/security/audit/ingest) routes on
the envelope id the endpoint already server-mints; -queue events route on the
publisher-minted envelope id. Both ARE the payload’s hashed event_id — the routing id is
hash-bound and immutable, and the staging store enforces the payload/column equality
structurally (a CHECK constraint; a divergent pair is unrepresentable — lands in #1207
MR-2).
Event uniqueness joins the invariants (extends Amendment 5’s acceptance suite)
One chained row per event_id per family table, enforced by a UNIQUE expression index on
the live v2 table ((canonical_event_payload→>'event_id') — lands in #1207 MR-2; the FTI
tables already discharge it via the payload-derived primary key). This is the PERMANENT replay
identity: staging rows are dequeued on chaining and inbox dedup rows are reaped after
seven days, so without it a sufficiently-late redelivery would double-chain. Exact late
replays (equal canonical-payload digest) are silently absorbed; same-id/different-content
replays fail closed and are observable. Residual, documented: once #1208 archival moves
rows out of the live table (years later, per retention) the live index no longer covers
them — the replay horizon is days, the archive horizon years; #1208 carries the note.
The C3 "leased per-shard drainer" is discharged STRUCTURALLY
The drain work unit — claim staged rows (FOR UPDATE SKIP LOCKED) → head lock → hash →
append → dequeue — is ONE database transaction, so ownership IS the row locks: there is no
lease table, TTL, renewal, or steal path (the lease-steal defect class is unrepresentable).
Release on failure is bounded by the pinned per-transaction lock_timeout /
statement_timeout and the session idle_in_transaction_session_timeout — NOT by
wall-clock lease expiry, and not "instant" under a wedged session (PostgreSQL releases a
partitioned session’s locks only when the server notices; the timeouts bound that window).
This satisfies Amendment 5’s pinned drainer invariants — "multiple replicas + manual
triggers cannot overlap ownership" and "restart resumes at every batch boundary, in
O(batch) memory" — discharging a delegated byte-level decision, never overriding "leased".
(Contrast: the transactional-outbox drainer needs timed leases because its AMQP publish
necessarily happens outside the claiming transaction.)
The ingest 202 redefinition (activates Amendment 5’s C3 wording)
202 = "accepted + durably staged" (chained asynchronously by the per-shard drainer), with
an {event_id} receipt body. Dormant until the #1279 cutover flips
chain_v2_append_enabled; until then the endpoint keeps today’s synchronous-append 202.
Amendment 8 — chain-v2 verifier bindings: the token-fenced lease, whole-preimage verification, retained-history scrub, trusted-manifest binding, the anchor role split, the async verify (#1205/#1206, 2026-08-01)
Status unchanged (still Accepted). Amendments 5/6 delegated the verification byte-level decisions to #1205/#1206; this amendment ratifies the bindings their plan (chain-v2 verifiers) makes — accepted amendments are immutable, so each is a formal revision here.
The C6 verifier lease is a TOKEN-FENCED timed lease, enforced in the database
The substrate’s checkpoint functions are reshaped (pre-1.0, dormant — the old signatures
are DROPPED, never overloaded): chain_checkpoint_acquire is the ONLY row-creating,
fence-raising path — it mints a unique per-acquisition token under the checkpoint row
lock, respects an unexpired foreign lease, and REQUIRES an initial cursor on first
acquire (an existing checkpoint row can never hold a NULL cursor hash);
chain_checkpoint_advance becomes existing-row-only, exact-token-bound, and
cursor-monotonic. Run recording and incident latching are token-guarded functions — a
fenced-out worker cannot write state, latch a breach, or contaminate runs; incident
dedup is a partial-unique DB invariant. Wall-clock expiry gates only WHEN a claim may be
contested; the token is the correctness mechanism. This is deliberately NOT Amendment
7’s structural lease: the verify read (a cross-database SELECT for FTI) and the
checkpoint write cannot share one transaction — Amendment 7’s own stated precondition
for timed leases.
Verification covers the WHOLE hashed preimage
Beyond seq/linkage/formula/hash: closed per-family key/type sets; derived-column ↔
payload consistency (a mutated typed projection column breaks verification even when
payload + hash are internally consistent — the field-coverage tables made executable);
routing placement (shard_for(routing id, shard count) == shard_id — discharging the
substrate plan’s delegated placement validation); stored chain_source vs the registry;
FTI row.id == payload.id. Persisted-data decode/canonicalization failures are
integrity findings (latched), never transient errors.
The scrub covers the FULL retained history
Archive ∪ live with the seam at archived_through — archived-row mutation is detected
by the scrub, not deferred. Cycle targets and the boundary-cursor reset persist through
ONE atomic acquire write (restart resumes the same cycle to the same fixed target);
cycle_completed_at is the coverage stamp, distinct from activity. Only the
purge-boundary manifest machinery remains with the archive children (#1208/#1247); a
purged boundary row fails closed until it lands.
Status and attestation bind to the last VERIFIER-CHECKED manifest
trusted_manifest_ref is written only after the verifier compares an anchor’s decoded
canonical bytes against the anchor row’s own metadata (the seven row columns: seq, kind,
epoch, previous hash, manifest hash, instance, family — chain_anchors carries no
source column; the decoded source is hash-bound inside the manifest bytes and checked
against the topology), the topology, and the chain (per-tip prefix consistency, anchor
fetched BEFORE heads, one refetch before latching divergence — a newer anchor confirmed
mid-check is normal operation). A newly confirmed but never-checked anchor
authorizes nothing. Status consumes a manifest-age threshold that #1278’s cadence/SLO
tightens; the residual window — a rewrite strictly newer than the trusted manifest — is
exactly what periodic-tip cadence bounds.
The anchor role split
chain_anchor_append (and the pending→submitted/failed transitions) move to a dedicated
emitter role; canopy_chain_verify keeps the submitted→confirmed transition only
(verifier-confirmed, C5). One credential can no longer both fabricate and confirm an
anchor.
Status precedence, backlog inputs, HTTP mappings
breached > error > stale > unknown > verifying > healthy; stale derives at read time
from checkpoint activity stamps, scrub completed-cycle stamps, tail lag, and
trusted-manifest age; the backlog inputs (raw staged/parked from the #1207 snapshot,
inbox parks, DLQ depth) hold status at best verifying, and an input that has never
sampled successfully blocks healthy. HTTP: healthy/verifying → 200;
unknown/stale/error/breached → 503 — the #1245 fail-closed-by-status-code
posture, preserved on the same paths.
The manual verify becomes a crash-safe async job
202 = "job accepted" with a {job_id, poll_url} body over a guarded queue (enqueue/
claim/finalize/reap functions only — no raw DML; claim lease + heartbeat + attempts
stale-claim reclaim; closed public error codes; requester-scoped polling). Manual
coverage is all-or-nothing (coverage_incomplete, never a partial ok), and incident
resolution requires a revalidation run that COVERS the finding’s loop — enforced in the
guarded resolve, with the actor recorded from the session, never caller-supplied. The
old synchronous FTI verify path is deleted (pre-1.0 Changed).
Amendment 9 — chain-v2 verifier revisions: the unified chain namespace, the family lease, token confidentiality, proven health stamps, durable jobs, the number fence (#1205/#1206, 2026-08-01)
Status unchanged (still Accepted). A second external review of the verifier plan found integrity, fencing, and recovery gaps in the Amendment 8 bindings; accepted amendments are immutable, so each correction is a formal revision here. The revised byte-level contract is the v7 plan (chain-v2 verifiers).
The unified /v1/security/chain/* namespace (revises Amendment 8’s path preservation)
Amendment 8 said the fail-closed HTTP posture was "preserved on the same paths"; the
paths themselves are now REPLACED (pre-1.0 Changed). One namespace carries the whole
surface: GET /v1/security/chain/status?family=[&service=],
POST /v1/security/chain/verify, GET /v1/security/chain/verify-jobs/{id},
GET /v1/security/chain/attest?event_id=&family=[&service=]. The historical
GET /v1/security/verify-chain, POST /v1/security/fti/chain-verify, and
GET /v1/security/fti/chain-status are deleted. What is PRESERVED is the posture —
healthy/verifying → 200, everything else → 503 with a parseable body — and the
#1245 guarantee that a latched legacy FTI breach stays visible: the unified FTI status
arm ORs the v1 latched row into breached until #1279 drops the table (the earlier
classification of that read as a compat cut is withdrawn — it is a safety invariant).
Every API operation has a canopy CLI subcommand (ADR-007 parity).
The FAMILY lease orders the pass
loop_kind gains 'family': one family-level checkpoint row per (instance, family,
epoch) at shard_id 0, PK-distinct from shard rows by loop kind. Pass order is
fetch_topology (read-only; supplies the instance) → FAMILY lease → halt gate →
manual jobs → manifest + census under the family token → per-shard tail/scrub under
shard leases. The family lease structurally serializes the census and manifest check
(one replica per family per cadence), makes the holder the only servicer of that
family’s manual jobs, and is the authority for family-scoped writes: run recording
validates the FAMILY token; family-scoped findings (missing/unexpected heads, genesis
mismatch, manifest divergence, boundary-unavailable at init) latch under it with a
NULL shard. Scale-out is BY FAMILY — this trivially discharges the no-overlap
invariant at the pass level while shard tokens remain the write-fencing mechanism
underneath. The trusted manifest reference lives on the FAMILY row ONLY. The family
row carries no cursor at all (shape-CHECKed) — the deliberate, single exception to
Amendment 8’s "an existing checkpoint row can never hold a NULL cursor hash";
tail/scrub rows keep that rule unchanged.
Lease-token confidentiality + honest lease semantics
The verify role’s raw SELECT on chain_verification_checkpoints is REVOKED — it reads
a token-free view, so the ONLY way to hold a lease token is to have minted it via
acquire. Takeover is EXPIRY-ONLY for everyone (owner text is display-only; a restarted
process waits out its own lease). Acquire/advance take a bounded DURATION (seconds,
domain-checked in SQL); expiry is computed inside the locked function — no caller
clocks. A token stays valid PAST expiry until a takeover mints a successor:
correctness never reads the clock; expiry only opens the contest window, and a finding
produced by a long batch is never lost to it. The lease ≥ 3× statement-timeout rule is
a LIVENESS heuristic, not a correctness proof.
Health stamps are PROVEN, never asserted
Scrub cycle-start is a cursor-CAS inside acquire (the reset applies only if the stored
cursor equals the caller’s expected value — a delayed worker’s stale cycle-start loses
under the row lock, and the CAS stamps cycle_started_at). chain_checkpoint_advance
proves its stamps relationally: on scrub keys cycle_completed_at only when the
presented cursor EQUALS the stored target (seq and hash, checked in-function);
trusted_manifest_ref only when it references a CONFIRMED anchor row of the same
(instance, family), and only on the family key. The one non-proven stamp is named: on
the family key cycle_complete is the census-cadence marker — token-gated
observability with no relational witness, and no status rule reads it. Scrub staleness falls back to cycle_started_at when no cycle
has ever completed — a first cycle that never finishes goes stale. Error state is
per-scope: runs carry an optional shard, and a shard’s error clears only against that
shard’s own success stamp.
The detected-loop incident model + the evidence split
chain_incidents stores detected_loop_kind ∈ {tail, scrub, family} at latch — never
inferred from the kind. Resolution requires a MANUAL revalidation run (job-linked;
scheduled runs never resolve) of the DETECTED loop covering the incident’s scope,
enforced in the guarded resolve alongside the session-recorded actor. Dedup is a
UNIQUE NULLS NOT DISTINCT partial index over (instance, family, epoch, shard, kind,
detected loop) — family-scoped incidents with NULL position dedup correctly. The kind
vocabulary is a closed CHECK constraint; evidence is a bounded JSON object; the verify
role reads incidents only through an evidence-free view (the incident-admin role keeps
the full read).
Durable, token-claimed, target-scoped verification jobs
Job rows carry the durable work definition — requested loop
(tail | scrub | family-full), optional incident reference, and the instance/epoch/
target vector captured ONCE at first claim, so a reclaim resumes the SAME targets.
Claims are DB-minted-token-based (heartbeat/finalize validate the token — the
checkpoint ABA fix applied symmetrically) and target-scoped (each family task claims
only its own family/source). One active job per target (a second enqueue returns the
active job’s id → 409); a bounded queue refuses at capacity; enqueue is refused
entirely for unconfigured verifier targets (no phantom queue). Runs record
mode ∈ {scheduled, manual} + the job id: manual outcomes NEVER feed the status
machine in either direction. Reap has an in-function ≥ 7-day floor, terminal-only,
with incident-referenced rows exempt until resolved.
Single-snapshot archive ∪ live reads; the census covers the whole history
Every batch walk fetches archive ∪ live as ONE UNION ALL statement with a side tag —
one MVCC snapshot; a concurrent archive move can never tear a read, and a row present
on both sides latches as a duplicate. Rows are side-checked against the boundary
(wrong-side and archived-orphan rows latch). The structural census extends to the
archive side: identity-filtered whole-range counts, rows above the boundary, and
foreign instance/epoch/shard detection cover archived rows too. The byte budget always
admits at least one row; a single row above a hard 4 MiB ceiling (double the ingress
body cap) latches malformed_row.
The JSON number fence (#1285 closed)
RFC 8785 renders numbers through f64, so values that collide in f64 (2^53 vs 2^53 + 1)
canonicalize identically and hash recomputation alone cannot detect the mutation
class. The fence closes it: a raw number-token scanner in canopy-chain accepts a token
iff its DECIMAL VALUE equals the decimal value of the shortest round-trip
representation of its f64 — value-level, so PostgreSQL’s jsonb numeric reformatting
passes while every f64-collision mutation is caught. It runs verify-side over the
fetched payload text (violation = malformed_row) and intake-side over raw ingest
bodies and the staging consumer’s delivery bytes (no new collision-class value can
enter).
serde_json’s `arbitrary_precision feature is deliberately NOT used — cargo feature
unification would change number parsing workspace-wide.
Amendment 10 — chain-v2 external anchor authority: the enumerable transparency frontier, signed manifests, verifier confirmation (#1278, 2026-08-02)
Status unchanged (still Accepted). Amendment 6 deferred C5’s "the amendment pins the authority + credentials" to "a further amendment here when it lands"; this is it. A first external review of the anchor-authority plan found that rooting the external authority’s trust in mutable local database state defeats it against a privileged-DB rollback; the corrections are below. The byte-level design is the plan (chain-v2 anchor authority).
The authority is an INDEPENDENTLY ENUMERABLE transparency frontier
The authority is an S3-compatible object store reached through canopy-store (production: AWS S3
with versioning + Object Lock compliance-mode retention + split writer/reader/enumerator IAM + a
policy requiring conditional writes and denying delete/overwrite; devstack: Garage,
functional-not-adversarial). Its trust does NOT derive from local rows: the verifier ENUMERATES
the authority (prefix-scoped ListBucket) to learn the true external high-water mark, so a
local-DB rollback that hides newer objects is caught as external-ahead-of-local (a breach,
family-global anchor_integrity); the confirmer’s per-pass re-list also latches on a
beyond-tolerance discrepancy between an object’s LIVE LastModified and its stored
authority_time (a local-rewrite signal); read-time status-age stays on the landed local path and
is best-effort liveness only (see the status subsection below); and each object embeds the full
manifest, so an auditor with only the
bucket + the public key reconstructs the anchor chain with zero database access. Object keys carry
an unpredictable recovery token namespace ({prefix}/{instance}/{family}/{token}/{seq:020}),
not a guessable integer, so a leaked writer cannot preplant future keys; recovery from a burned
key is an operator-gated, idempotent, fenced token rotation (chain_anchor_recovery_rotate)
performed after the writer credential is rotated, journaled with actor + reason + incident.
Confirmation is EARNED — ordered, linked, fenced, version-pinned
The verifier confirms a submitted anchor only after: (1) anchor-chain linkage
(seq = prev+1, previous_anchor_hash = prev.manifest_hash, genesis sentinel), walking the
frontier strictly in seq order and halting at the oldest unresolved anchor; (2) chain CONTINUITY
(the current chain hash at the previous trusted tip seq still equals the previous manifest’s tip
hash, per shard, same epoch — tip-seq monotonicity alone does not prove continuity); (3) the full
anchor-vs-chain check on THAT anchor; (4) external evidence at the CANONICAL key (derived from the
row’s identity + recovery token, never by following external_ref, which must itself equal the
derived key, with external_authority equal to the configured id), fetched at the RECORDED
version (a new current version or delete-marker over it is a breach even with identical bytes);
(5) a semantic byte + JWS check (kid resolved from signing_key_history by the registered_at
window covering the object’s authority-set LastModified — a retired key verifies old anchors but
authorizes no new ones; NOT a seq window, which cannot cover genesis seq-1 and collides across the
three families' restarting seq spaces). The confirm transition is a CAS under the family lease fence (expected predecessor
checked fingerprint) — no DB transaction spans the S3 I/O, so a lease-lost verifier cannot
confirm. The emitter never confirms; the confirmer never emits (grant-enforced) AND runs as a
separate deployable (canopy-anchor-emitter) holding only the signing key + writer creds, so
a single process compromise cannot forge-and-self-confirm. Divergent external objects always reach
the verifier via an append-only chain_anchor_attempts record (recovery never overwrites
evidence).
Key identity, retention, status, and the honest attestation split
Anchor kid = canopy-chain-anchor:{RFC 7638 P-256 JWK thumbprint} (distinct from the
determination PEM-text scheme); ANCHOR_SIGNING_VERSION bumps to 2 as the frozen provisional
vectors are re-derived; the public key is retained in signing_key_history (program slug
chain-anchor); kid retirement is a registered_at-window check against the anchor OBJECT’s
authority-set time (LastModified — genesis-safe, cross-family-correct, non-forgeable by the
privileged-DB attacker) so a retired key still verifies old anchors but authorizes no new ones.
Object Lock retention is a per-jurisdiction, per-family ruleset value
(ADR-006 /
ADR-011), bounded below by the family’s federal
floor — #1278 introduces the ruleset key that #1208/#1247 reuse. Read-time status freshness is
BEST-EFFORT liveness (a frozen frontier / dead confirmer ages to stale in the honest case; a
full privileged-DB attacker who both rewrites the local freshness stamp and freezes the confirmer
is NOT defeated by read-time status — the anti-rollback guarantee is the confirmer’s
external-ahead-of-local enumeration + discrepancy latch, eventual and observer-dependent, plus the
independent auditor). Status gains a coverage-lag input (manifest_coverage_lagging) so a
compromised emitter emitting fresh anchors with frozen tips still degrades in the honest case;
CHAIN_MANIFEST_MAX_AGE_SECS tightens (default 6h, ≥ 3× emit interval); anchor_integrity
latches family-global and resolves only via a manual ok family run whose ok outcome is gated on
the external arm actually having run. Attestation is honest:
behavioral boot probes prove conditional-create, unconditional-overwrite-denial, and
credential-scope; control-plane facts (Object Lock, versioning, the bucket policy, split IAM) are
provisioning-time operator attestation — production account/bucket provisioning is a separate
deployment concern, out of #1278, which delivers the code, config, the attestation harness, and a
provisioning-requirements runbook. The epoch_closure (#1280) and archive/purge-boundary
(#1208/#1247) confirm-check arms are defined in their own issues; #1278 pins genesis + periodic_tip
(unsupported kinds submit + store but confirmation defers them).
Amendment 11 — anchor authority reframed on a WORM capability tier: supersedes Amendment 10 (#1278, 2026-08-02)
Status unchanged (still Accepted). A second external review of the anchor-authority design
(Amendment 10 + its byte-level plan) found that the "independently enumerable transparency frontier"
cannot deliver C5’s anti-truncation guarantee, and that several of its store-capability assumptions are
not exposed by the object_store 0.13.2 API canopy pins. Accepted amendments are immutable, so this
amendment SUPERSEDES Amendment 10 wholesale — the withdrawn claims are named below, the correct
elements are re-ratified, and the byte-level design moves to a distinctly-named replacement plan
(chain-v2 anchor authority — WORM-tier). The Amendment 10 plan
(chain-v2 anchor authority, v2) is Deferred, kept only as
the historical record Amendment 10 references. No anchor-authority code is written against Amendment 10.
Why Amendment 10’s model fails (the withdrawn claims)
Amendment 10 rooted trust in enumeration + signatures + conditional-create + authority LastModified.
That defeats a hidden-rollback attacker but NOT a truncation attacker: a privileged writer (or a
store admin) can delete the suffix of the chain and, on a store without WORM, recreate a shorter valid
suffix — signatures still verify, ListBucket returns exactly what remains, and LastModified is
attacker-influenced. Enumeration reports what is PRESENT, never what was REMOVED; the bucket alone has
no memory of the expected root. These Amendment 10 assertions are WITHDRAWN:
-
that enumeration ("external-ahead-of-local") + a
LastModified/authority_timediscrepancy latch is a sufficient anti-rollback guarantee — it is not anti-truncation, andLastModifiedis not a trustworthy witness; -
that "an auditor with only the bucket + the public key reconstructs the anchor chain" proves completeness — it proves only the integrity of the bytes that survive;
-
the store-capability assumptions the pinned API cannot satisfy: per-object retain-until through
PutOptions, version-pinned reads /ListObjectVersions/ delete-marker detection, andLastModifiedreturned from the write call (PutResultcarries only{e_tag, version}— authority time needs a post-PUT HEAD); -
that kid retirement keys off the object’s
LastModified— it keys off the SIGNEDissued_at(below), becauseLastModifiedis mutable and attacker-influenced.
What Amendment 10 got right and is RE-RATIFIED: the separate emitter deployable (arm split); the
append-only chain_anchor_attempts evidence rule; RFC 7638 anchor kid identity + ANCHOR_SIGNING_VERSION
→ 2; per-jurisdiction/per-family ruleset retention (ADR-006/011); coverage-lag status; and production
account/bucket/IAM + signing-identity PROVISIONING as a separate deployment concern (out of #1278).
The corrected trust model — a WORM capability tier
The load-bearing anti-truncation guarantee requires a WORM / Object-Lock store (AWS S3 / MinIO / Ceph
RGW in compliance mode, with versioning + a deny-delete/overwrite bucket policy) as the C5 append-only
authority: on such a store, deletion and truncation are impossible by construction. The CODE stays
portable (canopy-store over object_store, any backend), but ASSURANCE is TIERED:
| Tier | Backends | Guarantee |
|---|---|---|
Full (production) |
AWS S3 / MinIO / Ceph RGW — Object-Lock compliance + versioning + deny-delete policy |
C5 append-only: truncation impossible; anti-rollback holds against a privileged DB + writer attacker (bounded by the writer credential + WORM). Behavioral boot probes + out-of-band control-plane attestation. |
Conditional-only (devstack) |
Garage (conditional-create, no WORM) |
Integrity of present bytes + rollback detection vs a DB-only attacker; NO anti-truncation vs a store admin. Documented lower assurance; dev/test only. |
Local (dev) |
LocalFileSystem |
Functional only; no external-authority trust. Dev only. |
Future |
GCS / Azure |
Needs |
WORM enablement is CONTROL-PLANE: the pinned object_store API cannot set or read Object Lock, so it is
provisioned and attested out of band (IaC / operator attestation + a provisioning-requirements runbook).
Boot data-plane probes attest only BEHAVIORAL facts (create-if-absent enforced, unconditional overwrite
denied, delete denied, credential scope).
The auditor’s external memory — a pinned root, not just the bucket
Because the bucket cannot testify to what was removed, the trust root is: the WORM store (can’t-delete)
+ a PINNED genesis/root catalog (the genesis manifest hash + the authoritative chain_instance_id,
published out of band) + an authenticated instance-succession and key-transition lineage. An auditor
walks FORWARD from the pinned genesis over the WORM contents and detects truncation (tip below the
pinned/known tip) or substitution (linkage / signature failure). Open sub-decision, resolved in the
replacement plan + #1279: whether the pinned catalog’s authoritative home is the public repo release
record or an operator-signed out-of-band record — both preserve the guarantee.
Signed identity + issuance time; a forward-walk frontier
ANCHOR_MANIFEST_VERSION → 2: the signed preimage gains recovery_token (the object location is
signed, so an object cannot be replayed under a fresh token path) and issued_at (freshness, coverage,
and kid retirement ride SIGNED time, never the mutable LastModified). ANCHOR_SIGNING_VERSION → 2
(header freeze; KAT corpus regenerated + negative KATs). The confirmer/auditor derives each next
canonical key from the hash-linked chain and probes it by HEAD (seq+1, seq+2, … to the tip) — a
bounded FORWARD hash-walk, not an unordered list/offset scan (object_store list ordering is not
guaranteed and the S3 list path returns version=None, so an offset cursor can permanently miss
objects). A separate, capped, periodic prefix LIST detects foreign/spam keys but is NOT the frontier.
The remaining bindings (ratified; byte-level design in the replacement plan)
Confirmation is a CAS holding the checkpoint-row lock through commit (no TOCTOU) with the FULL
fingerprint (manifest hash, canonical key, external authority, version/etag, kid, issued_at,
attempt_id) and external JWS == stored == attempt; no DB transaction spans the store I/O; every remote
op is timeout-bounded. Emit is fenced (recovery_token == head under the head lock) + idempotent, rides
SIGNED coverage state (a store outage cannot append an anchor per tick), and HALTS the family on a
transient submission (no N+1 while N pending). Cross-DB genesis extends the existing crash-resumable
saga (Phase A target DB → Phase B canopy-security anchor DB → Phase C revalidate; installing until
chain_epoch_activate) with an idempotent genesis-confirm step invoked at #1279 — NOT a SQL fence
across databases. Key transitions are an externally-authenticated bundle registered through a narrow
SECURITY DEFINER function (no table-wide INSERT to the emitter); retirement is the registered_at
window vs the SIGNED issued_at. Incident gating is reachable (manual jobs run before the halt gate;
resolution is SQL-enforced against an external-check witness). The census uses a DEDICATED guarded
cursor (not the cursor-free family checkpoint row); retention is the ruleset value realized as the WORM
bucket-default (per-object retain-until is impossible via object_store).
C5 reconciled + alternatives
C5 (Amendment 5) requires an external APPEND-ONLY authority. A generic object store is NOT append-only; only a WORM-configured one is — so the "portable, no store features" reframe in Amendment 10 VIOLATED C5, and this amendment restores it: the WORM tier IS the C5 append-only authority, and portability is an assurance tier below it, not a substitute. Alternatives weighed (per the architectural-recommendation protocol): (a) crypto-only portable (Amendment 10) — REJECTED, cannot prove completeness; (b) external witness / transparency-log co-signer — defers the trust root to another append-only service with the SAME requirement one layer out, adding a network dependency + a second operator; held as future hardening, not the #1278 baseline; (c) WORM capability tier (this amendment) — the minimal model that delivers C5 with the pinned toolchain, portable code, and honest tiered assurance. The #1280 (epoch closure) and #1208/#1247 (archive/purge boundary) confirm-check arms remain defined in their own issues; #1278 pins genesis + periodic_tip.
Amendment 12 — superseded by ADR-041: the FTI hash chain + chain-v2 are retired for a general logging + redaction facility (epic &74, #1299, 2026-08-03)
ADR-041 supersedes this ADR’s hash-chain approach. The maintainer review that produced ADR-041 concluded that a cryptographic hash chain — and, a fortiori, the chain-v2 external-anchor authority of Amendments 5–11 — is special-cased tamper-evidence for one log type, duplicating (badly, and only for FTI) the off-box capture, tamper-evidence, retention, and alerting that every real deployment’s general logging facility already provides for all of its logs. The real, general need is per-field redaction — "ensure FTI never reaches the logs" is a special case of "ensure any jurisdiction-designated sensitive field never reaches the logs" — and canopy should ship the mechanism (redaction everywhere, safe defaults) while the deployment owns the policy and the external tamper-evidence/retention.
What ADR-041 supersedes here:
-
the original Decision (the live FTI chain over
fti_audit_log); -
Amendments 1, 3, 4 (the
audit_eventsv2 hash + the collapse to the single chain formula + the sealedbefore/afterverify under it); -
acceptance criteria C1–C6, including C5's external append-only authority;
-
the chain/anchor bindings of Amendments 5–11 (the entire chain-v2 line: substrate, transport, verifier, unified namespace, external anchor, and the WORM capability-tier reframe).
What survives (re-ratified by ADR-041, mechanics-only superseded): the non-chain obligations of C7 (per-jurisdiction retention, legal-hold, purge boundary — re-homed to the general audit-retention lifecycle, #1303) and C8 (per-record Pub 1075 §4 granularity, ADR-004 isolation, least-privilege). The FTI audit-log retention floor is corrected to 7 years (Pub 1075 AU-11) by ADR-041; the "5+ years" figure in this ADR’s Context is superseded.
What is unaffected: Amendment 2 (derivation edges reference FTI by id, not value) is an ADR-004 data-scrubbing property, independent of the chain, and stands.
This ADR’s accepted text (including Amendments 1–11) is left immutable per the ADR convention; ADR-041 is the operative decision. Retirement of the live chain surfaces is tracked, gated behind a proven replacement, as #1304. See the ADR-041 supersession map and epic &74.