T2-5 — Audit Chain-Hash Hardening (actor + before/after tamper-evidence) (#686)
On this page
Epic &56 / Track 2, T2-5 (#686) — an ADR-014
amendment. T1-5 (#673) made every worker fact-mutation emit an attributed event that
canopy-security persists to the append-only audit_events hash chain; T1-6 (#674) surfaces
that change-history. But the chain hash today covers only
previous_hash · event_id · event_type · canonical_timestamp — the actor, action,
resource, and the before/after values (in metadata) are not hashed, so a
privileged row rewrite could flip claim_status, rewrite an amount, or re-attribute an
action without breaking the chain. T2-5 extends the audit_events hash to a v2
formula covering all of those, per-row versioned so historical rows stay verifiable.
Scope boundary
T2-5 is audit_events chain-hash hardening (the non-FTI, all-events chain in
canopy-security): a per-row-versioned v2 hash that makes the worker fact change-history
cryptographically tamper-evident. OUT of scope (named): re-hashing/upgrading historical v1
rows (intentionally never — the chain is per-row versioned); the FTI fti_audit_log chain
(ADR-014 §2 — already covers actor/action/resource; before/after is not an FTI concept);
the derivation graph + per-rule versioning (T2-2 #679); appeals snapshot-replay (T2-8
#681); extending tamper-evidence to breach_alerts / other tables.
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-21) — T2-5 audit-hardening plan + nav. |
1 |
Migration: |
Done (2026-06-21) — see As-built. |
2 |
Cargo: |
Done (2026-06-21) — see As-built. |
3 |
Hash: |
Done (2026-06-21) — see As-built. |
4 |
Insert path: deterministic ordering, |
Done (2026-06-21) — see As-built. |
5 |
|
Done (2026-06-21) — see As-built. |
6 |
Verify path: ordered walk + per-row version dispatch + leading-NULL-prefix skip. |
Done (2026-06-21) — see As-built. |
7 |
Tests: mixed v1/v2, 5 tamper cases, boundary-collision, unknown-version, float round-trip, proptest. |
Done (2026-06-21) — see As-built. |
8 |
Docs: ADR-014 Amendment 1, data-models, api, CHANGELOG, master-plan flip. |
Done (2026-06-21) — see As-built. |
Epic: &56 · Issue: #686 · Deps: T1-5 (#673, Done) ·
ADR: ADR-014 (amended) ·
Branch: feat/fact-authoring-t2-5-audit-hardening
Context
The audit_events chain (services/canopy-security/src/store/mod.rs) is the canopy-wide,
non-FTI tamper-evidence chain. Its compute_event_hash hashes only
previous_hash · event_id · event_type · canonical_timestamp. T1-5’s attributed fact
events store the actor in user_id/user_role (+ nested metadata.author), the
action/resource in their columns, and the before/after fact values in
metadata.before/metadata.after — none of which the hash covers. The DB-layer
canopy_audit_append_only_guard() trigger blocks ungated UPDATE/DELETE, but the hash is
the cryptographic, server-side-verifiable backstop (defense in depth). Making the
change-history tamper-evident was explicitly deferred to T2-5 in T1-6’s as-built notes.
Decisions
-
Per-row versioning is mandatory. The chain formula is append-only and load-bearing; changing it retroactively would un-verify every existing row. A new
hash_version SMALLINTcolumn (DEFAULT 1 backfills history;SET DEFAULT 2for future) letscompute_event_hash+verify_chaindispatch per row. v1 = the current formula, bytes unchanged; new rows are v2. Mixed v1/v2 chains verify. Zero churn for history is a hard gate. -
v2 = JCS over a typed struct, not a delimiter-free concat. A no-delimiter concatenation with a
"NONE"null-sentinel is genuinely ambiguous (field-boundary shifts;NonevsSome("NONE")collisions). v2 mirrorsDeterminationSnapshot::canonical_bytes: a#[derive(Serialize)] struct AuditChainInputsV2hashed viaSha256::digest(serde_jcs::to_vec(&inputs)?)(RFC 8785) — named keys,None→JSONnull, no sentinel, no boundary ambiguity, andmetadatais a field (no separate metadata-hash). -
v2 covers the full reader-visible + scoping set. Fields:
previous_hash,event_id,event_type,timestamp(canonical),user_id,user_role,action,resource_type,resource_id,source_service,household_id,metadata.source_servicescopes fact-history selection andhousehold_idscopes case-audit reads, so both must be hashed or a privileged rewrite could move/hide events from those views. -
JSONB round-trip closed by construction. The chain hashes
metadataat insert and re-hashes at verify; a non-integer float could round-trip through Postgres JSONB differently. 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 the identical bytes — for any JSON, including floats. -
Verification is server-side / DB-level.
verify_chainrecomputes per the row’shash_version(a DB column). The wireevent_hashstays an opaque integrity token (it was never independently recomputable from the DTO — the DTO omitsprevious_hash/event_id/…), sohash_versionis not exposed on the wire. No contract/OpenAPI change. -
Typed error. A local
AuditHashError(thiserror) wraps the JCSserde_json::Errorand the unknown-version case; insert maps it intosqlx::Error::Encode(Box::new(e)), verify maps it to its inner(id, String)chain-break — `verify_chain’s public signature is unchanged (historic-signature preservation). -
Deterministic ordering.
clock_timestamp()is microsecond-precision; ties are possible. Insert’s previous-hash lookup and verify’s walk both order bycreated_at, id(UUIDv7 tie-break) so the chain order is deterministic.
Implementation
-
Migration
services/canopy-security/migrations/<ts>_add_audit_hash_version.sql— bothaudit_events+audit_events_archive:ADD COLUMN hash_version SMALLINT NOT NULL DEFAULT 1thenALTER COLUMN hash_version SET DEFAULT 2. SPDX header; timestamp sorts after20260603120000. DDL is not blocked by the append-only guard;DEFAULT 1backfills via catalog metadata (no row UPDATE). -
Cargo
services/canopy-security/Cargo.toml—serde_jcs.workspace = true
thiserror.workspace = true(deps),proptest.workspace = true(dev-dep). -
Hash (
store/mod.rs) —compute_event_hash_v1(prev, event_id, event_type, canonical_ts: &str)keeps the current update sequence verbatim (the.format(…)lifts to the caller; bytes unchanged); addAuditHashError,AuditChainInputsV2<'a>,compute_event_hash_v2(&inputs) → Result<String, AuditHashError>(format!("{:x}", Sha256::digest(serde_jcs::to_vec(&inputs)?))), and acompute_event_hash(version, &inputs)dispatcher (other ⇒ UnknownVersion). -
Insert (
store/mod.rsinsert_audit_event) — previous-hash lookupORDER BY created_at DESC, id DESC LIMIT 1;SELECT $1::jsonbnormalize; buildAuditChainInputsV2;compute_event_hash(2, …).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; bind normalizedmetadata+hash_version = 2. -
Row model (
store/models.rs) —pub hash_version: i16. -
Verify (
store/mod.rsverify_chain) — walkORDER BY created_at ASC, id ASC; per-row rebuild the struct + dispatch;AuditHashError→ inner break; add the contiguous leading NULL-event_hashgenesis-prefix skip (ADR-014 FTI precedentverify_fti_chain); preserve#[expect(clippy::assigning_clones)]. -
Tests (in the
store/mod.rs#[cfg(test)]module, reusingreset_chain_pool()
sample_event()+ the maintenance-window tamper pattern) — see Verification. -
Docs — ADR-014 Amendment 1,
data-models/canopy-security.adoc,api/canopy-security.adoc,CHANGELOG.adoc, the master plan, and this plan’s Status.
Hash v2 inputs
AuditChainInputsV2<'a> (#[derive(Serialize)]), hashed as
format!("{:x}", Sha256::digest(serde_jcs::to_vec(&inputs)?)):
| Field | Source |
|---|---|
|
prior row’s |
|
serializes as its Uuid string |
|
the event type |
|
canonical |
|
resolved actor sub |
|
resolved actor role / author_type |
|
claim/close/… |
|
income/asset/expense/… |
|
the |
|
scopes fact-history selection |
|
scopes case-audit reads |
|
JSONB-normalized; covers before/after + author/claim_source/claim_status/version_id |
v1 (hash_version = 1) is the legacy previous_hash · event_id · event_type ·
canonical_timestamp concat, reached only for historical rows.
Verification
-
cargo build -p canopy-security;cargo test -p canopy-security
set -a; source .ports.env; set +a; cargo nextest run -p canopy-security(devstack-gated). -
Tests:
mixed_v1_v2_chain_verifies(zero-churn); tamper detection for actor/before-after/claim_status/source_service/household_id;v2_hash_has_no_boundary_collisions;unknown_hash_version_breaks_chain;float_in_metadata_round_trips; the 3 existing chain tests green under v2; a proptest thatcompute_event_hash_v2is invariant tometadatakey reordering. -
cargo xtask quality-budgets(expect flat),cargo xtask docs plan-lint,cargo xtask check-docs, full pre-push battery (validate+ e2e +cargo doc). No wire change → no OpenAPI delta.
As-built notes
-
Built exactly as planned. Per-row
hash_version(DEFAULT 1 backfill, thenSET DEFAULT 2); v2 = JCS over the typedAuditChainInputsV2(12 fields incl.source_service+household_id+metadata); v1 kept byte-stable (timestamp pre-formatted by the caller, lifted out ofcompute_event_hash_v1);AuditHashError(thiserror) mapped tosqlx::Error::Encodeat insert and to the inner chain-break at verify; insert normalizes metadata viaSELECT $1::jsonb; insert/verify order bycreated_at, id; verify skips the leading NULL-event_hashprefix. -
Quality-budget discipline (B3a, per maintainer direction): the v2 metadata struct field is an irreducible new
serde_json::Valuein src. Rather than raise the locked B3a floor, the increment was minimized (type inference on the insertlet+ inferred-type test closures/inline constructions, noserde_json::Valuetext in the new test code) and offset by typing therun_archiveendpoint’s response into the newArchiveResponsecontract DTO (the untypedJson<serde_json::Value>sibling of the already-typedverify_chain). Net B3a stayed flat at 757 (LOCKED) — no lock raise. Breach-alertevidencewas deliberately leftserde_json::Value: it is intentionally heterogeneous per detection rule, so typing it to the current rule’s shape would be a regression, not a paydown. -
OpenAPI: the only wire change is the new typed
ArchiveResponseonPOST /v1/security/archive(was an ad-hocjson!object, byte-identical) —security.jsonregenerated. Thehash_versionchain-integrity work is DB-only (no wire/DTO change). -
Tests: 18 chain tests green (mixed v1/v2 zero-churn; tamper detection for actor/before-after/claim_status/source_service/household_id; boundary-collision; unknown-version; float round-trip; the JCS key-order proptest; plus the 3 pre-existing chain tests under v2). The 3 T1-6 fact-history integration tests are unaffected (one transient cold-start poll-timeout on the post-rebuild run; green on a warm devstack).
Follow-ups
-
T2-2 (#679) derivation-edge graph + per-rule versioning.
-
T2-8 (#681) appeals snapshot-replay + overpayment recalc.