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: hash_version SMALLINT NOT NULL DEFAULT 1 on audit_events + audit_events_archive, then ALTER COLUMN … SET DEFAULT 2.

Done (2026-06-21) — see As-built.

2

Cargo: serde_jcs + thiserror (deps), proptest (dev-dep).

Done (2026-06-21) — see As-built.

3

Hash: compute_event_hash_v1 (canonical-ts param), AuditHashError, AuditChainInputsV2, compute_event_hash_v2 (JCS), dispatcher.

Done (2026-06-21) — see As-built.

4

Insert path: deterministic ordering, SELECT $1::jsonb normalize, v2 hash, hash_version = 2.

Done (2026-06-21) — see As-built.

5

AuditEventRow.hash_version: i16.

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

  1. 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 SMALLINT column (DEFAULT 1 backfills history; SET DEFAULT 2 for future) lets compute_event_hash + verify_chain dispatch 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.

  2. 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; None vs Some("NONE") collisions). v2 mirrors DeterminationSnapshot::canonical_bytes: a #[derive(Serialize)] struct AuditChainInputsV2 hashed via Sha256::digest(serde_jcs::to_vec(&inputs)?) (RFC 8785) — named keys, None→JSON null, no sentinel, no boundary ambiguity, and metadata is a field (no separate metadata-hash).

  3. 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_service scopes fact-history selection and household_id scopes case-audit reads, so both must be hashed or a privileged rewrite could move/hide events from those views.

  4. JSONB round-trip closed by construction. The chain hashes metadata at 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.

  5. Verification is server-side / DB-level. verify_chain recomputes per the row’s hash_version (a DB column). The wire event_hash stays an opaque integrity token (it was never independently recomputable from the DTO — the DTO omits previous_hash/event_id/…), so hash_version is not exposed on the wire. No contract/OpenAPI change.

  6. Typed error. A local AuditHashError (thiserror) wraps the JCS serde_json::Error and the unknown-version case; insert maps it into sqlx::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).

  7. Deterministic ordering. clock_timestamp() is microsecond-precision; ties are possible. Insert’s previous-hash lookup and verify’s walk both order by created_at, id (UUIDv7 tie-break) so the chain order is deterministic.

Implementation

  1. Migration services/canopy-security/migrations/<ts>_add_audit_hash_version.sql — both audit_events + audit_events_archive: ADD COLUMN hash_version SMALLINT NOT NULL DEFAULT 1 then ALTER COLUMN hash_version SET DEFAULT 2. SPDX header; timestamp sorts after 20260603120000. DDL is not blocked by the append-only guard; DEFAULT 1 backfills via catalog metadata (no row UPDATE).

  2. Cargo services/canopy-security/Cargo.tomlserde_jcs.workspace = true
    thiserror.workspace = true (deps), proptest.workspace = true (dev-dep).

  3. 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); add AuditHashError, AuditChainInputsV2<'a>, compute_event_hash_v2(&inputs) → Result<String, AuditHashError> (format!("{:x}", Sha256::digest(serde_jcs::to_vec(&inputs)?))), and a compute_event_hash(version, &inputs) dispatcher (other ⇒ UnknownVersion).

  4. Insert (store/mod.rs insert_audit_event) — previous-hash lookup ORDER BY created_at DESC, id DESC LIMIT 1; SELECT $1::jsonb normalize; build AuditChainInputsV2; compute_event_hash(2, …).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; bind normalized metadata + hash_version = 2.

  5. Row model (store/models.rs) — pub hash_version: i16.

  6. Verify (store/mod.rs verify_chain) — walk ORDER BY created_at ASC, id ASC; per-row rebuild the struct + dispatch; AuditHashError → inner break; add the contiguous leading NULL-event_hash genesis-prefix skip (ADR-014 FTI precedent verify_fti_chain); preserve #[expect(clippy::assigning_clones)].

  7. Tests (in the store/mod.rs #[cfg(test)] module, reusing reset_chain_pool()
    sample_event() + the maintenance-window tamper pattern) — see Verification.

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

previous_hash: Option<&str>

prior row’s event_hash

event_id: EventEnvelopeId

serializes as its Uuid string

event_type: &str

the event type

timestamp: &str

canonical %Y-%m-%dT%H:%M:%S%.6f+00:00

user_id: Option<&str>

resolved actor sub

user_role: Option<&str>

resolved actor role / author_type

action: &str

claim/close/…

resource_type: &str

income/asset/expense/…

resource_id: Option<&str>

the fact_id

source_service: &str

scopes fact-history selection

household_id: Option<Uuid>

scopes case-audit reads

metadata: &Value

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 that compute_event_hash_v2 is invariant to metadata key 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, then SET DEFAULT 2); v2 = JCS over the typed AuditChainInputsV2 (12 fields incl. source_service + household_id + metadata); v1 kept byte-stable (timestamp pre-formatted by the caller, lifted out of compute_event_hash_v1); AuditHashError (thiserror) mapped to sqlx::Error::Encode at insert and to the inner chain-break at verify; insert normalizes metadata via SELECT $1::jsonb; insert/verify order by created_at, id; verify skips the leading NULL-event_hash prefix.

  • Quality-budget discipline (B3a, per maintainer direction): the v2 metadata struct field is an irreducible new serde_json::Value in src. Rather than raise the locked B3a floor, the increment was minimized (type inference on the insert let + inferred-type test closures/inline constructions, no serde_json::Value text in the new test code) and offset by typing the run_archive endpoint’s response into the new ArchiveResponse contract DTO (the untyped Json<serde_json::Value> sibling of the already-typed verify_chain). Net B3a stayed flat at 757 (LOCKED) — no lock raise. Breach-alert evidence was deliberately left serde_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 ArchiveResponse on POST /v1/security/archive (was an ad-hoc json! object, byte-identical) — security.json regenerated. The hash_version chain-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.

Edit this page · default