T1-6 — Scoped Fact Change-History Endpoint (#674)

On this page

Status

Step Description Status

(a) contracts

canopy-contracts-securityFactChangeEntry DTO (one history row: action, actor_sub, actor_role, claim_source, claim_status, fact_id, version_id, before, after, recorded_at, event_hash) + FactResourceKind enum (income/asset/expense) + the paths constants. Display methods (action_label / has_before / before_summary / after_summary) added in (d) so the BFF projects the opaque before/after without an untyped-JSON decode (the crate is B3a-exempt).

Done (2026-06-19) — 658c1ac (DTO) + display methods folded into the (d) commit 4be4f2d6.

(b) store

canopy-security storelist_fact_change_history(pool, person_id, resource_kind) querying audit_events by source_service='canopy-persons' AND resource_type=$kind AND metadata @> {"person_id":…}, ordered by created_at (chain order); a private project(person_id, AuditEventRow) that navigates the existing AuditEventRow.metadata value (method calls only, no new untyped-JSON literal — B3a flat) into FactChangeEntry.

Done (2026-06-19) — ecda6434 (folded with (c); a query nothing calls is dead code).

(c) endpoint

canopy-security GET /v1/security/persons/{person_id}/fact-history/{resource} (the canopy-security house auth — is_service OR require_admin) → Vec<FactChangeEntry>; #[utoipa::path] + OpenAPI snapshot.

Done (2026-06-19) — ecda6434 (live-verified; OpenAPI snapshot regenerated).

(d) BFF scoped read + UI

canopy-web — a worker-scoped read path that resolves the household’s members, gates on case-access + in_program_scope, fans out to the canopy-security endpoint per member, composes the ordered household history, AND renders it as a case-detail "Change history" section (the UI half of API/CLI/UI parity — the READ capability). The fact-AUTHORING editors (write) stay T1-8.

Done (2026-06-19) — 4be4f2d6 (BFF route + composed section + resource sub-tabs + e2e; household_id percent-encoded into the upstream URL per a pre-commit review nit).

(e) CLI

ADR-007 parity — canopy security fact-history --person <id> --resource <kind> (kind = income, asset, or expense) over the new endpoint.

Done (2026-06-19) — 05af4c8.

(f) tests + docs

Integration (author claim → correction → close via the persons API, poll the audit row, GET the history, assert ordered claim/correction[before+after]/close) + store/projection units + canopy-web scope-gate unit + an e2e spec for the case-detail Change-history section (Playwright, light+dark); Antora (canopy-security api page + Events/endpoints + the canopy-web case-detail section) + plan Status flip + CHANGELOG; api-docs --update + quality-budgets (no movement).

Done (2026-06-19) — the (f) commit (3-test devstack integration suite green live; Antora + CHANGELOG + Status flip; budgets flat).

NOTE
API/CLI/UI parity (project norm) is satisfied within this slice for the change-history READ: API = the canopy-security endpoint + the canopy-web BFF read path; CLI = canopy security fact-history; UI = the case-detail Change-history section. The fact-AUTHORING (write) capability has its own parity and is T1-8’s scope — not deferred read-UI, a genuinely different capability.

Context

T1-5 (#673, merged ad976b99) made canopy-persons emit attributed income/asset/expense.claimed + income.closed events through the ADR-018 outbox, and made canopy-security’s wildcard subscriber index them by fact_id (resource_id) + the nested author.sub/author_type (user_id/user_role), persisting the full typed payload (author / claim_source / claim_status / before / after) in the audit_events.metadata JSONB, hash-chained (ADR-014). So the change-history data exists today; T1-6 exposes it.

ADR-027 §4 (the bitemporal split): canopy-persons is the system of record for current valid-time facts; canopy-security is the transaction-time change history — "a scoped change-history query endpoint … returns the transaction-time history. The UI may show only the latest value; the endpoint must exist for appeals and QC. canopy-security stays an append-only ledger, not a correctness-path read (ADR-028 freezes determination inputs)." Integrity posture is Track-1 attributable, not yet tamper-evident — the ADR-014 chain hashes only previous_hash || event_id || event_type || timestamp, NOT the actor or before/after; extending it is T2-5 (#686), out of scope here. T1-6 returns the existing hash columns opaque so an auditor can re-verify out-of-band, and changes nothing about the chain.

The Proposed-claim inbox feed is NOT in this slice. ADR-027 §2’s "Proposed claims surface in the existing pending-verifications / IEVS-alerts panel" requires a producer of Proposed claims — the IEVS adapter — which does not exist until T1-8/T1-9. Building the feed now is an always-empty, contract-unsettled display path (the same dead-code reason accept/reject was re-sliced out of T1-5 under ADR-013). It is re-sliced to T1-9 (#677) — built with its producer — recorded on #674 + #677. T1-6 = the change-history endpoint, which has a real producer now.

The central design decision (query axis + where the household scope lives)

canopy-security queries on the axis it actually stores; the canopy-web BFF composes the household-scoped view.

The fact events are person-scoped: their payload carries person_id
fact_id, not household_id (income/assets/expenses are per-person facts; a worker claim carries no household ref — only an applicant author carries household_id, ADR-027 §9). So audit_events.household_id is NULL for every fact event (the parser’s dedicated household_id extractor reads a top-level household_id field the fact events don’t have — confirmed event_parsing.rs:68). The only household→facts path is therefore household → persons (canopy-persons owns this) → metadata→>'person_id'.

ADR-027 §4 illustrates the capability as GET /v1/security/household/{id}/{resource}. Realizing that literal URL inside canopy-security would force canopy-security to call canopy-persons to resolve household→persons on every history read — coupling the append-only audit ledger to the persons household model for a non-correctness read, and giving canopy-security its first outbound service dependency. The architecturally correct realization (ADR-001 service isolation
"canopy-security stays a ledger"):

  • canopy-security exposes GET /v1/security/persons/{person_id}/fact-history/{resource} — querying by the identifiers it stored (person_id from metadata, resource_type). It reaches into no other service.

  • canopy-web (the worker BFF) owns household composition — it already loads every member’s MemberFull bundle for the case-detail page (the :batchGet / /full expansion, #626) and owns the worker session
    in_program_scope gate (#632). It resolves household→members, gates the worker, fans out per member to the canopy-security endpoint, and composes the ordered household view.

This honors ADR-027 §4’s intent (a scoped, queryable transaction-time history exists for appeals/QC) and its constraint (canopy-security stays an isolated ledger), at the cost of the literal household-keyed URL. Flag for plan review: if the architect requires the literal security/household/{id}/… URL in canopy-security, the slice pivots to canopy-security→canopy-persons resolution (the less-isolated option) — decide before coding.

Caseworker-scoping lives at the BFF (Track-1 honest limitation). Every existing canopy-security endpoint authes is_service() || require_admin() — canopy-security cannot see the individual worker identity, because the ADR-019 on-behalf-of (X-Canopy-Actor) plumbing is not wired into service→service calls yet (the same gap that makes the T1-5 income.closed author: None). So the canopy-security endpoint keeps the house auth (service/admin), and "caseworker-scoped" is enforced at the canopy-web BFF (the worker’s case-access + in_program_scope gate). Documented bounded limitation; true in-service worker-scoping arrives with ADR-019. canopy-security remains an internal endpoint behind the BFF gate, exactly like the existing Audit section read (canopy-web/src/api/audit_log.rs calls security with_service_identity).

Source-confirmed ground truth (do not re-recon)

  • Audit table (migrations/20260326000000_create_security_tables.sql:4-19
    20260402000001_add_hash_chain.sql + 20260601000010_add_household_id…): audit_events(id, event_id, event_type, source_service, action, resource_type, resource_id, user_id, user_role, ip_address, household_id, metadata JSONB, event_timestamp, received_at, created_at, previous_hash, event_hash). Append-only DB trigger (20260603120000). GIN index on metadata (idx_audit_events_metadata) → metadata @> '{"person_id":"…"}' is index-served. Indexes also on resource_type, event_timestamp. Hash chain ordered by created_at (clock_timestamp(), strictly increasing under the pg_advisory_xact_lock(1) insert lock — store/mod.rs:43-121).

  • What a fact event stores (T1-5): for income.claimed, resource_type="income", action="claim", resource_id=fact_id, user_id=author.sub, user_role=author.author_type, and metadata = the full IncomeClaimedEvent (person_id, fact_id, version_id, author, claim_source, claim_status, valid_from, valid_to, before[], after). For income.closed, action="close", user_id=NULL (close author:null), metadata = IncomeClosedEvent (person_id, fact_id, author:null, close_date, before[]). asset/expense mirror with resource_type asset/expense (close: income only today — asset/expense close is #562). One resource_type filter returns a fact type’s FULL history (claims + corrections + close); action distinguishes.

  • Existing read surface (api/mod.rs:70-148): list_events etc., all is_service() || require_admin(); AuditListParams filters by source_service / event_type / action / household_id / from / to (NOT person_id / resource_id) — so T1-6 needs a NEW store query. list_audit_events orders by event_timestamp DESC (store/mod.rs:186-219).

  • Response DTO home: canopy-contracts-security/src/events.rs (AuditEvent carries metadata: serde_json::Value already — #[schema(value_type=Object)]; contracts crates are B3a-exempt, so before/after as serde_json::Value in the new DTO is budget-safe). paths.rs holds the route constants.

  • BFF substrate (canopy-web): SessionData.in_program_scope(program) / program_in_scope (session.rs:172-190); PersonsClient/MemberFull / household_full already loaded for case-detail (#626); audit_log.rs is the precedent for a worker-gated read that calls security with_service_identity.

  • Gates: SPDX on new .rs; clippy too_many_lines=40; quality budgets (B3a-exempt contracts; the canopy-security store query must use typed sqlx::types::Json<…> decode or read metadata once — avoid a NEW serde_json::Value literal in canopy-security src beyond the existing count); cargo xtask api-docs --update (the new #[utoipa::path] adds a path — snapshot WILL change, regenerate); pre-push = full cargo xtask validate.

Scope (D1 — emit/expose only where a producer exists; no dead code)

SHIP: the person-scoped change-history endpoint (canopy-security) + the BFF-composed scoped household read (canopy-web) + CLI + tests + docs.

DEFER, recorded on the tracker:

  • Proposed-claim inbox feed → T1-9 (#677) — no Proposed producer until the IEVS adapter (verified: no propose/accept/reject endpoint, no seed path; the claim handlers always derive accepted_*). Built with its producer.

  • Fact-AUTHORING (write) UI → T1-8 (#676) — the editors to add/correct/close facts via the case-detail are a separate (write) capability with its own API/CLI/UI parity. T1-6 ships the change-history READ UI (the case-detail "Change history" section); T1-8 ships the authoring editors. (NOT a deferred read-UI — the read UI is in this slice, step (d), to keep parity.)

  • Tamper-evident chain (actor + before/after hash) → T2-5 (#686) — ADR-027 §4 Track-2 ADR-014 amendment; T1-6 is attributable-not-tamper-evident by design.

  • household_member. history — those events are the existing non-attributed household.member_; not in the fact-history resources (income/asset/expense).

Implementation (one MR, commits a→f; each independently build-green)

Branch feat/fact-authoring-t1-6-change-history off fresh main (it will carry the pending CLAUDE.md upstream-contribution commit 4f28548 already on local main). MR labels: type::feature priority::high service::security service::web workflow::in-progress. Closes #674. SPDX on new .rs. Per-commit: the precommit ritual (PRECOMMIT_TOKEN + a fresh Explore J1–J8 pass) + a phased progress comment on #674.

(a) contracts — feat: fact change-history DTO (T1-6 #674)

canopy-contracts-security/src/events.rs (or a new fact_history.rs module):

  • FactResourceKind { Income, Asset, Expense } (#[serde(rename_all="snake_case")], strum::Display/EnumString for the path param + CLI parse).

  • FactChangeEntry { action: String, fact_id: Uuid, version_id: Option<Uuid>, actor_sub: Option<String>, actor_role: Option<String>, claim_source: Option<String>, claim_status: Option<String>, before: serde_json::Value, after: serde_json::Value, recorded_at: DateTime<Utc>, event_hash: Option<String> } (Serialize+Deserialize+ToSchema; before/after opaque per-kind shapes — #[schema(value_type=Object)]; version_id/actor_* Option because a close carries no version_id and author:null). Roundtrip proptest.

  • paths.rs: FACT_CHANGE_HISTORY = "/v1/security/persons/{person_id}/fact-history/{resource}".

(b) store — feat: query fact change-history from the audit ledger (T1-6 #674)

canopy-security/src/storelist_fact_change_history(pool, person_id: Uuid, kind: &str) → sqlx::Result<Vec<FactChangeEntry>>:

  • SELECT * FROM audit_events WHERE source_service='canopy-persons' AND resource_type=$1 AND metadata @> jsonb_build_object('person_id', $2::text) ORDER BY created_at ASC, decoded into the existing AuditEventRow via query_as (chain order; $2 bound as text — PersonId is #[serde(transparent)] (canopy-common/src/id.rs:41) so it lands as a top-level JSON string in the stored metadata (= the event payload clone, store/mod.rs:112), and a text param to jsonb_build_object becomes a JSON string → @> matches). Index: the GIN on metadata serves the @> containment; PG bitmap-ANDs it with the resource_type/source_service btree indexes — adequate at audit volume, no new index. recorded_at on the entry = the row’s created_at (the transaction-time the audit row was committed, ADR-027 §4 — NOT event_timestamp/envelope-creation).

  • As-built: a private project(person_id, AuditEventRow) → FactChangeEntry navigates the row’s already-typed metadata value (AuditEventRow.metadata is the only untyped-JSON field in canopy-security src) with method calls only — fact_id from resource_id; actor_sub/actor_role from the nested author.sub/author.author_type; version_id/claim_source/claim_status from top-level keys; and before/after are metadata.get("before"/"after") .cloned().unwrap_or_default() (a close carries no after → JSON null). This adds no new serde_json::Value literal to canopy-security src (no view struct, no to_value), so B3a stays flat — verified with quality-budgets (the regex counts comments too, so the type name is kept out of new doc comments). The earlier draft sketched a typed sqlx::types::Json<…View> decode; navigating the existing AuditEventRow.metadata is simpler and equally budget-safe, so the as-built took that path.

(c) endpoint — feat: GET fact-history endpoint (T1-6 #674)

canopy-security/src/api/mod.rs:

  • GET handler fact_change_history(Extension(claims), State, Pathperson_id, resource)claims.is_service() || claims.require_admin()? (house auth); parse resourceFactResourceKind (400 on unknown); store::list_fact_change_historyJson<Vec<FactChangeEntry>>. #[utoipa::path(get, …, responses(200 body = Vec<FactChangeEntry>, 400, 401, 403))]; register on route_path(paths::FACT_CHANGE_HISTORY).

  • cargo xtask api-docs --update → the canopy-security snapshot gains one path.

(d) BFF scoped read + case-detail UI — feat: worker-scoped household fact-history + UI (T1-6 #674)

canopy-web:

  • A read path GET /cases/{household_id}/fact-history/{resource} (following the existing /cases/{household_id} case-detail route family) — gate: worker can access this case (existing case-access) AND session.in_program_scope(program) (the fact resources are cross-program generic facts — gate on the case, not a single program; if a per-program gate is wanted, default to "any in-scope program on the case", decide in review). Resolve household→members (household_full/batchGet, already loaded for case-detail), fan out per member to the canopy-security endpoint with_service_identity concurrently (futures::future::join_all — N members is small, parallel keeps latency flat), merge + sort by recorded_at, into the composed Vec<FactChangeEntry>. One typed SecurityClient::fact_history test-lib method (B7 untyped-client budget is 0 — add it typed).

  • UI (the parity half): a case-detail "Change history" section (an Askama template + an HTMX-loaded partial off the read path, matching the existing case-detail section pattern — e.g. the Audit section, api/audit_log.rs) rendering the ordered entries (actor, action, source/status, before→after, timestamp) grouped by fact. Read-only display; the fact-AUTHORING editors (add/correct/close) are T1-8. Light + dark.

(e) CLI — feat: canopy security fact-history command (T1-6 #674, ADR-007)

canopy CLI — security fact-history --person <uuid> --resource <income|asset|expense> → calls the canopy-security endpoint (service token), prints the ordered entries. ADR-007 parity for the new read endpoint.

(f) tests + docs — test/docs: fact change-history (T1-6 #674)

  • Integration (canopy-security/tests/fact_change_history_test.rs, devstack-gated): via the persons API (the real producer) author an income claim → a correcting claim → a close on one fact; poll_until the three audit_events rows land (async subscriber); GET the canopy-security endpoint; assert the ordered entries — claim (before [], after v1), claim (before=[v1], after=v2, the correction), close (actor null, before=[v2]). A second person isolates the metadata→>'person_id' filter (no cross-person bleed). asset + expense claim each surface once.

  • Unit: the store projection (claimed vs closed shape → entry, null author); the canopy-web scope gate (out-of-scope worker → denied); contracts roundtrip.

  • E2E (Playwright, the UI parity gate): a case-detail "Change history" section spec — author facts via the seed/API, open the case, assert the section renders the ordered attributed entries (actor, before→after); light + dark.

  • Docs: api/canopy-security.adoc (the new endpoint + the attributable-not-tamper-evident posture + the BFF-composes-household note); services.adoc canopy-security line (publishes nothing new — it now exposes fact change-history); plan Status flips; CHANGELOG.adoc == Unreleased; api-docs --update (snapshot delta committed) + quality-budgets (flat).

Decisions resolved (for review)

Decision Resolution

Query axis

person_id + resource_type (what canopy-security stores); household view composed at the BFF. NOT a canopy-security→canopy-persons household resolution (ADR-001 isolation; ledger purity). Literal ADR-027 §4 household URL realized BFF-side. Plan-review fork.

Caseworker-scoping

Enforced at the canopy-web BFF (case-access + in_program_scope); canopy-security keeps house auth (`is_service()

require_admin()`) — it can’t see worker identity until ADR-019 (mirrors T1-5 close-author). Documented limitation.

Proposed-claim feed

Re-sliced to T1-9 (#677) — no producer until the IEVS adapter; building it now = dead/empty code (ADR-013, mirrors the T1-5 accept/reject re-slice). Recorded on #674 + #677.

Tamper-evidence

Out of scope — Track-1 attributable-not-tamper-evident; the actor + before/after hash extension is T2-5 (#686). Endpoint returns the existing hash columns opaque for out-of-band re-verify.

before/after typing

Returned opaque (serde_json::Value, per-kind shapes) in the contracts DTO (B3a-exempt); the store reads them via a typed metadata view so no new serde_json::Value literal lands in canopy-security src.

Ordering

created_at ASC (the hash-chain insert order, strictly increasing) — stable chronological history. Not event_timestamp (envelope creation, could tie across a fast claim+close).

UI parity

T1-6 ships the change-history READ UI (case-detail "Change history" section) so the READ capability has API+CLI+UI parity in-slice. The fact-AUTHORING (write) editors are a separate capability → T1-8 (#676).

Query axis isolation (OpenStack)

canopy-security stays a self-contained leaf — it calls NO other service (the household→persons composition lives in canopy-web, the stateless BFF). Each service stands alone (no audit-ledger→canopy-persons dependency).

Verification

Per-commit: cargo build + cargo clippy -p canopy-contracts-security -p canopy-security -p canopy-web --all-targets -D warnings + nextest on touched crates. After (c): cargo xtask api-docs --update (snapshot delta). After (d): cargo xtask dev refreshcargo nextest run -p canopy-security --test fact_change_history_test (devstack-gated, must actually RUN — not skip). After (f): cargo xtask quality-budgets (flat) + full cargo xtask validate. Force-merge per the standing CI directive after green local validate; git ls-remote verify; close #674 with the closing comment; update the epic
.

Follow-ups (no new issues unless noted)

  • T1-9 (#677) — the Proposed-claim inbox feed (re-sliced here) lands with its IEVS producer.

  • T1-8 (#676) — the fact-AUTHORING (write) editors UI; it sits alongside the T1-6 change-history READ section on the case-detail.

  • T2-5 (#686) — extend the ADR-014 chain hash to cover actor + before/after (tamper-evident change history).

Edit this page · default