T1-6 — Scoped Fact Change-History Endpoint (#674)
On this page
Epic &56 / Track 1, T1-6 (#674). Expose the transaction-time change-history of a person’s eligibility facts (income / asset / expense) from canopy-security’s append-only audit ledger — the attributable, reconstructable record ADR-027 §4 requires for appeals + QC + Pub 1075 — built over the attributed fact events T1-5 (#673) already emits. The history endpoint has a real producer NOW; this slice is its first consumer.
- Status
- Context
- The central design decision (query axis + where the household scope lives)
- Source-confirmed ground truth (do not re-recon)
- Scope (D1 — emit/expose only where a producer exists; no dead code)
- Implementation (one MR, commits a→f; each independently build-green)
- (a) contracts —
feat: fact change-history DTO (T1-6 #674) - (b) store —
feat: query fact change-history from the audit ledger (T1-6 #674) - (c) endpoint —
feat: GET fact-history endpoint (T1-6 #674) - (d) BFF scoped read + case-detail UI —
feat: worker-scoped household fact-history + UI (T1-6 #674) - (e) CLI —
feat: canopy security fact-history command (T1-6 #674, ADR-007) - (f) tests + docs —
test/docs: fact change-history (T1-6 #674)
- (a) contracts —
- Decisions resolved (for review)
- Verification
- Follow-ups (no new issues unless noted)
Status
| Step | Description | Status |
|---|---|---|
(a) contracts |
|
Done (2026-06-19) — |
(b) store |
canopy-security |
Done (2026-06-19) — |
(c) endpoint |
canopy-security |
Done (2026-06-19) — |
(d) BFF scoped read + UI |
canopy-web — a worker-scoped read path that resolves the household’s members, gates on case-access + |
Done (2026-06-19) — |
(e) CLI |
ADR-007 parity — |
Done (2026-06-19) — |
(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 |
Done (2026-06-19) — the (f) commit (3-test devstack integration suite green live; Antora + CHANGELOG + Status flip; budgets flat). |
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_idfrommetadata,resource_type). It reaches into no other service. -
canopy-web (the worker BFF) owns household composition — it already loads every member’s
MemberFullbundle for the case-detail page (the:batchGet//fullexpansion, #626) and owns the worker session
in_program_scopegate (#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 onmetadata(idx_audit_events_metadata) →metadata @> '{"person_id":"…"}'is index-served. Indexes also onresource_type,event_timestamp. Hash chain ordered bycreated_at(clock_timestamp(), strictly increasing under thepg_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, andmetadata= the fullIncomeClaimedEvent(person_id, fact_id, version_id, author, claim_source, claim_status, valid_from, valid_to, before[], after). Forincome.closed,action="close",user_id=NULL(closeauthor:null),metadata=IncomeClosedEvent(person_id, fact_id, author:null, close_date, before[]). asset/expense mirror withresource_typeasset/expense(close: income only today — asset/expense close is #562). Oneresource_typefilter returns a fact type’s FULL history (claims + corrections + close);actiondistinguishes. -
Existing read surface (
api/mod.rs:70-148):list_eventsetc., allis_service() || require_admin();AuditListParamsfilters 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_eventsorders byevent_timestamp DESC(store/mod.rs:186-219). -
Response DTO home:
canopy-contracts-security/src/events.rs(AuditEventcarriesmetadata: serde_json::Valuealready —#[schema(value_type=Object)]; contracts crates are B3a-exempt, sobefore/afterasserde_json::Valuein the new DTO is budget-safe).paths.rsholds the route constants. -
BFF substrate (canopy-web):
SessionData.in_program_scope(program)/program_in_scope(session.rs:172-190);PersonsClient/MemberFull/household_fullalready loaded for case-detail (#626);audit_log.rsis the precedent for a worker-gated read that calls securitywith_service_identity. -
Gates: SPDX on new
.rs; clippytoo_many_lines=40; quality budgets (B3a-exempt contracts; the canopy-security store query must use typedsqlx::types::Json<…>decode or readmetadataonce — avoid a NEWserde_json::Valueliteral 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 = fullcargo 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
Proposedproducer until the IEVS adapter (verified: no propose/accept/reject endpoint, no seed path; the claim handlers always deriveaccepted_*). 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-attributedhousehold.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/EnumStringfor 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/afteropaque per-kind shapes —#[schema(value_type=Object)];version_id/actor_*Optionbecause a close carries noversion_idandauthor: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/store — list_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 existingAuditEventRowviaquery_as(chain order;$2bound as text —PersonIdis#[serde(transparent)](canopy-common/src/id.rs:41) so it lands as a top-level JSON string in the storedmetadata(= the event payload clone,store/mod.rs:112), and atextparam tojsonb_build_objectbecomes a JSON string →@>matches). Index: the GIN onmetadataserves the@>containment; PG bitmap-ANDs it with theresource_type/source_servicebtree indexes — adequate at audit volume, no new index.recorded_aton the entry = the row’screated_at(the transaction-time the audit row was committed, ADR-027 §4 — NOTevent_timestamp/envelope-creation). -
As-built: a private
project(person_id, AuditEventRow) → FactChangeEntrynavigates the row’s already-typedmetadatavalue (AuditEventRow.metadatais the only untyped-JSON field in canopy-security src) with method calls only —fact_idfromresource_id;actor_sub/actor_rolefrom the nestedauthor.sub/author.author_type;version_id/claim_source/claim_statusfrom top-level keys; andbefore/afteraremetadata.get("before"/"after") .cloned().unwrap_or_default()(a close carries noafter→ JSONnull). This adds no newserde_json::Valueliteral to canopy-security src (no view struct, noto_value), so B3a stays flat — verified withquality-budgets(the regex counts comments too, so the type name is kept out of new doc comments). The earlier draft sketched a typedsqlx::types::Json<…View>decode; navigating the existingAuditEventRow.metadatais 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:
-
GEThandlerfact_change_history(Extension(claims), State, Pathperson_id, resource)→claims.is_service() || claims.require_admin()?(house auth); parseresource→FactResourceKind(400 on unknown);store::list_fact_change_history→Json<Vec<FactChangeEntry>>.#[utoipa::path(get, …, responses(200 body = Vec<FactChangeEntry>, 400, 401, 403))]; register onroute_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) ANDsession.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 endpointwith_service_identityconcurrently (futures::future::join_all— N members is small, parallel keeps latency flat), merge + sort byrecorded_at, into the composedVec<FactChangeEntry>. One typedSecurityClient::fact_historytest-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_untilthe threeaudit_eventsrows 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 themetadata→>'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.adoccanopy-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 + |
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 ( |
Ordering |
|
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 refresh → cargo 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).