Plan: Attributed Fact-Mutation Events — T1-5 (#673, epic &56)

On this page
NOTE

Implements T1-5 (#673) under epic &56, governed by ADR-027 §4 (fact mutations are attributed; attribution rides in the typed event payload — the EventEnvelope is NOT extended) + §8 (raw identity values never in payloads), ADR-018 (the transactional outbox), and ADR-007 (CLI parity). Depends on T1-4 (#672, the version corpus as the sole fact store — Done). This plan is the approved execution plan; it went through a Plan-agent design + a 3-reviewer adversarial pass + the user’s own deep review (all findings folded in below). Not yet implemented.

Status

Step Description Status

(a) contracts

New crates/canopy-contracts-persons/src/events.rs — typed payload structs {Income,Asset,Expense}ClaimedEvent + IncomeClosedEvent + per-fact *BeforeWindow types; pub mod events; in lib.rs.

Done (2026-06-19) — commit a2a4198

(b) store + (c) emission

append_*version return AppendOutcome { version_id, before: Vec<*BeforeWindow> } (pub(crate)); close_income_version returns Vec<IncomeBeforeWindow>; *_before helper maps the whole under-lock snap. canopy-persons events.rs publisher fns + Extension(publisher) on the 4 fact handlers + emit staged in the same tx before commit; stale module docstring rewritten. (b) and (c) folded into one commit — a stored before window nothing reads is dead code under -D dead_code, so capture + the emission consuming it are one indivisible change; claim handlers delegate to persist_and_publish*_claim helpers to stay under the 40-line ceiling.

Done (2026-06-19) — commit 0cb2337

(c-sec) audit index

canopy-security event_parsing.rs indexes fact events by fact_id + actor from nested author.sub/author_type (explicit parse_event_type arms) + unit tests.

Done (2026-06-19) — commit a7bb93e

(d) tests

Unit serialization tests (contracts, in commit a) + integration outbox-staging tests incl. multi-window correction / gap-start correction / future-effective close / idempotent re-close / no-PII raw-key guard. All 9 integration tests run green against a refreshed devstack.

Done (2026-06-19) — commit 2e2b608

(e) docs

Antora Events Published section + services index correction + plan Status flip + as-built note + CHANGELOG; api-docs --update (no diff) + quality-budgets (no movement).

Done (2026-06-19) — this commit

Context

T1-4 (#672, merged) made the valid-time version corpus the sole fact store: facts are written through POST …/{income,assets,expenses}/claims and the income close DELETE …/income/claims/{fact_id}. Those writes emit ZERO events todayservices/canopy-persons/src/events.rs only publishes person/household events. So a fact mutation (who changed what, when, from what value) is invisible to the audit ledger — the exact gap ADR-027 §4 names, and the compliance hook for Pub 1075 attribution + IEVS-as-lead (7 USC §2025(e)). T1-5 closes it and unblocks T1-6 (#674) — the scoped change-history endpoint that consumes these events.

Each fact claim emits one fully-attributed event (typed author / claim_source / claim_status / before / after in the payload) through the existing ADR-018 transactional outbox, atomically with the version write. The income close emits a complete event (the superseded before windows + close_date) with author: None (the human actor on a service-to-service DELETE awaits the ADR-019 on-behalf-of plumbing — a documented bounded limitation; faking it via a spoofable query/header param would be false attribution). canopy-security’s audit parser is updated so these events index by fact_id + author, not by person_id with no actor.

before/after semantics (a fact_id can have MULTIPLE current windows)

before is the COMPLETE set of superseded accepted windows (the whole snap, each as {valid_from, valid_to, value}), NOT a single value — a multi-window correction, a gap-start correction that overlaps later windows, and a future-effective close all supersede more than one (or a non-from-covering) window, so a single Option<value> would lose state or silently report nothing. after is the single new claim value over [valid_from, valid_to). A close event is emitted iff !snap.is_empty() (the true idempotent no-op suppression); a claim event always fires (a new-fact claim has before = []).

Scope (D1 — emit only where a firing site exists today; no dead code)

SHIP: income.claimed, asset.claimed, expense.claimed (the 3 claim handlers) + income.closed (the close handler). DEFER, with documented reasons:

  • .accepted / .rejectedT1-9 (#677): no accept/reject handler exists; IEVS Proposed leads don’t exist until T1-8. Emitting them now is untestable dead code (the same reason accept/reject was re-sliced out of Slice 2).

  • asset.closed / expense.closed#562: assets/expenses have no close endpoint yet (only income has the D10 close primitive). They land with the asset/expense close primitives in #562, mirroring close_income_version.

  • household_member.* → already covered by the existing (non-attributed) household.member_added / household.member_removed events; re-attributing them (no Author on membership today) is deferred (T1-9 may surface it).

  • Batched finalizeT1-7 (#675): finalize lives in canopy-applications and fans out per-fact claim_income HTTP calls (Slice 3), so each fact emits one income.claimed. Per-fact events are correct and audit-only today (no subscriber re-determines on fact events, grep-confirmed), so N-per-finalize is safe unbatched.

income.closed is a deliberate vocabulary extension beyond ADR-027 §4’s {claimed,accepted,rejected} list (that list predates the D10 close primitive added in T1-4 Slice 3). ADRs are immutable once accepted → NO in-place ADR-027 edit, and one event name does not warrant a new ADR — so, mirroring how the D10 close primitive itself was documented (this plan’s as-built note, no ADR), income.closed is recorded in the as-built note + the Antora Events Published page + CHANGELOG.

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

  • Outbox (canopy-mq): EventEnvelope::new(source, event_type, payload: serde_json::Value) (envelope.rs:18, NO actor fields; sets timestamp = Utc::now() itself → events need no separate payload timestamp). publisher.publish_tx(&mut tx, &envelope) → Result<(),PublishError> (publisher.rs:90) INSERTs into event_outbox within the caller’s tx (the column stores the FULL envelope; the nested event payload is payload→'payload'). OutboxDrainer relays to the canopy.events topic exchange (routing_key = event_type).

  • Existing publisher pattern (services/canopy-persons/src/events.rs, SOURCE="canopy-persons"): pub async fn publish_person_created(tx: &mut Transaction<'_,Postgres>, publisher: &Publisher, id) → anyhow::Result<()>EventEnvelope::new(SOURCE,"person.created",json!({…​}))publisher.publish_tx(tx,&e). The module docstring ("Events carry IDs only — no PII … per ADR-004") is stale and must be rewritten.

  • Handlers (services/canopy-persons/src/api/mod.rs) — ALL own the tx and commit after the store call, so emission stages in the SAME tx with no store-tx change. CRITICAL: the 4 fact handlers do NOT currently take Extension(publisher) (only person/household handlers do) — it must be ADDED to each (order: after State(state), matching create_person; Publisher already imported; utoipa #[utoipa::path] does NOT enumerate extractors → no OpenAPI drift).

    • claim_income/claim_asset/claim_expense: open tx → version_id = append_*_version(&mut tx, person_id, fact_id, &req) → commit → ClaimResponse. In scope at emit: person_id, fact_id, returned version_id, req.author (clone), req.source, req.value, req.valid_from, req.valid_to, auto_accept_status(Some(&req.author)).

    • close_income_claim: Pathperson_id,fact_id, close_date = resolve_as_of(…​), require_fact_ownership, open tx → close_income_version(&mut tx, person_id, fact_id, close_date) → commit → 204. DELETE carries no per-worker author.

  • Store (services/canopy-persons/src/store/income_versions.rs; asset/expense mirror): snapshot_and_supersede(…​) → sqlx::Result<Vec<IncomeSnap>> returns the overlapped current-accepted versions (value cols + valid_from/valid_to) captured FOR UPDATE under the advisory lock — this IS the before data, race-free. Close supersedes ALL windows overlapping [close_date, ∞); a correction supersedes all overlapping [valid_from, valid_to). A fact can legitimately hold multiple concurrent windows (fact_version_writes_test.rs).

  • Contracts (crates/canopy-contracts-facts/src/lib.rs): Author #[serde(tag="author_type", rename_all="snake_case")] (Worker{sub}/ Applicant{household_id}/System); ClaimStatus + VerificationSource (snake_case); all Serialize+Deserialize+Clone. auto_accept_status(Some(&Author)) → ClaimStatus. is_none_or available. Decimal serializes as a string workspace-wide (rust_decimal serde-str feature) → no per-field attribute. Reuse IncomeFactValue/AssetFactValue/ExpenseFactValue as the before/after value types (DRY; do NOT define parallel EventValue structs) — the snap value columns map 1:1.

  • canopy-security (event_parsing.rs): wildcard # subscriber persists EVERY event. parse_event extracts resource_id from [id, resource_id, person_id, household_id, application_id, determination_id] (so a fact event without a fact-aware change indexes by person_id, NOT fact_id) and user_id only from top-level [user_id, created_by, …] (the nested author.sub is MISSED → no actor). This REQUIRES the c-sec parser change. parse_event tolerates author:null + extra fields; all parsed fields are Option (no NOT-NULL violation).

  • Gates: SPDX on new .rs; clippy too_many_lines=40 HARD -D (clippy.toml); quality budgets fail-on-regression (B3a/B3b regex is the literal serde_json::Value NOT to_value/from_value; B3a excludes crates/canopy-contracts-; B8 counts clock::now/today not Utc::now); cargo xtask api-docs --update drift gate; pre-push = full cargo xtask validate. Test harness: tests/fact_version_writes_test.rs (acquire_service_token, PersonsClient, infrastructure_available, versions_pool), canopy_test_lib::poll_until, crates/canopy-mq/tests/outbox_drainer_test.rs (poll event_outbox).

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

Branch feat/fact-authoring-t1-5-attributed-events. MR labels: type::feature priority::high program::snap service::persons service::security workflow::in-progress (NO service::shared-crates — canopy-contracts-persons is a persons-domain contract). MR description Closes #673 + a note that income.closed extends the §4 vocabulary (documented in the as-built note, not an ADR). Per-commit precommit ritual + a phased-issue progress comment on #673.

(a) contracts — feat: attributed fact-event payload types (T1-5 #673)

New crates/canopy-contracts-persons/src/events.rs (SPDX), deriving Debug, Clone, PartialEq, Serialize, Deserialize (NO ToSchema — not HTTP DTOs):

  • IncomeBeforeWindow { valid_from: NaiveDate, valid_to: Option<NaiveDate>, value: IncomeFactValue } (the superseded window + value). Mirror AssetBeforeWindow / ExpenseBeforeWindow.

  • IncomeClaimedEvent { person_id, fact_id, version_id, author: Author, claim_source: VerificationSource, claim_status: ClaimStatus, valid_from, valid_to, before: Vec<IncomeBeforeWindow>, after: IncomeFactValue } (before is a Vec — all superseded windows; empty for a new fact). Mirror Asset/Expense.

  • IncomeClosedEvent { person_id, fact_id, author: Option<Author>, close_date: NaiveDate, before: Vec<IncomeBeforeWindow> } (author = None for the DELETE; before non-empty — no event when the close superseded nothing).

  • Add pub mod events; to lib.rs. pub structs → no dead_code warning even unused.

(b) store — feat: capture before-value under the lock; return it (T1-5 #673)

In each store/{income,asset,expense}_versions.rs: add pub(crate) struct AppendOutcome { pub(crate) version_id: Uuid, pub(crate) before: Vec<*BeforeWindow> } (pub(crate), not module-privateappend_*version are pub fns in a pub module, so a private return type is E0446). Change append*_version → sqlx::Result<AppendOutcome>; change close_income_version from sqlx::Result<()>sqlx::Result<Vec<IncomeBeforeWindow>> (ALL superseded windows; empty = no-op). Add a private helper {income,asset,expense}_before(snap) → Vec<*BeforeWindow> that maps the WHOLE snap (every superseded accepted window), NOT a from-covering find (the snap is the complete before-state). Caller fallout this commit (pure refactor, no emission yet): handlers destructure the new returns + discard before/Vec. Budgets untouched (no new serde_json::Value text, no gated-clock).

(c) emission — feat: emit attributed fact events from persons handlers (T1-5 #673)

services/canopy-persons/src/events.rs: rewrite the stale docstring; add 4 publisher fns (publish_income_claimed etc.) that build the typed event internally (keeps handler call-sites one line) → EventEnvelope::new(SOURCE, "income.claimed", serde_json::to_value(&ev)?)publish_tx. services/canopy-persons/src/api/mod.rs: add Extension(publisher) to the 4 handlers; claim_* emit always (before may be []); close emits if !before.is_empty() — both before tx.commit(). LOC guard: claim handlers are at ~36-37 lines; after cargo clippy -p canopy-persons — -D warnings, if too_many_lines fires, lift the tx-body into a private persist_and_publish_* helper.

(c-sec) audit index — feat: index attributed fact events by fact_id + author (T1-5 #673)

canopy-security’s wildcard subscriber persists EVERY event; as written it indexes a fact event by person_id (not fact_id) with NO actor. In services/canopy-security/src/event_parsing.rs: add explicit parse_event_type arms (income.claimed→(claim,income) etc.); prepend "fact_id" to the resource_id candidate list; extract the actor from nested author.sub (→ user_id)
author_type (→ user_role) via a small extract_nested_string helper (falling back to the existing top-level lists). null author (close) → user_id/user_role None. Unit tests: an income.claimed envelope → action=claim/resource=income/ resource_id=fact_id/user_id=author.sub/user_role=worker; income.closed with author:null → resource_id=fact_id/user_id=None. Independent of (a)-(c) (reads JSON generically).

(d) tests — test: assert attributed fact events stage in the outbox (T1-5 #673)

Unit (contracts events module #[cfg(test)]): to_value of an IncomeClaimedEvent has author.author_type="worker", claim_status="accepted_verified", after.amount a JSON string (Decimal-as-str, no float), before an array; IncomeClosedEvent has author:null. No-PII guard (raw-key, not typed-decode): serialize a fully-populated event to serde_json::Value, assert the key set is EXACTLY the schema (no ssn/name/dob). Integration (new tests/fact_event_emission_test.rs, devstack-gated, poll event_outbox, decode payload→'payload' as sqlx::types::Json<IncomeClaimedEvent> to avoid any serde_json::Value text → B3b-safe): 1 income.claimed attributed (before == []); 2 income correction carries before+after; 3 multi-window correction carries ALL before windows; 4 correction starting in a gap reports the overlapped later window (NOT empty); 5 asset/expense claimed; 6 income.closed (author null, before = removed window); 7 future-effective close carries the future window; 8 idempotent re-close emits exactly ONE income.closed.

(e) docs — docs: attributed fact events — Antora + plan + CHANGELOG (T1-5 #673)

Antora api/canopy-persons.adoc Events Published section: add the 4 event types
payload-field lines; correct the stale "Events carry IDs only — no PII". The bounded agent index (services cheat-sheet / its successor): correct the parallel "payloads carry IDs/status/timestamps only" line. NO ADR change (see Scope). Flip this plan’s Status cells → Done (YYYY-MM-DD) — !MR; add an as-built NOTE (scope shipped, the income.closed vocabulary extension, the close author: None bounded limitation, the deferrals). CHANGELOG == Unreleased. cargo xtask api-docs --update → expect no diff; cargo xtask quality-budgets → expect no movement (if B3b moved, the test introduced an untyped serde_json::Value → fix the test, do NOT --write-lock).

Decisions resolved from the review pass

Decision Resolution

Handlers lack Extension(publisher)

ADD to all 4 (after State); no utoipa drift; Publisher already imported.

before shape / capture

before: Vec<*BeforeWindow> = the WHOLE under-lock snap mapped to {window+value} (NOT a single from-covering value — one fact_id can have multiple current windows). REUSE *FactValue. Claim always emits (before may be []); close emits iff !snap.is_empty().

audit index (canopy-security)

NEW commit (c-sec): index fact events by fact_id + actor from nested author.sub/author_type; without it the audit row is "income/person-id/no actor" — hollow attribution.

Decimal serialization

bare Decimal → string (workspace serde-str); NO per-field attribute.

close attribution

author: None (DELETE has no worker sub; never synthesize Author::System; a spoofable query/header actor would be FALSE attribution). Documented bounded limitation — human-actor on close awaits ADR-019 on-behalf-of plumbing.

clippy 40-line ceiling

extract before (append) + publisher builds event internally + fallback persist_and_publish helper if clippy flags a claim handler.

B3a/B3b/B8 budgets

untouched — to_value/from_value/typed sqlx::Json<…Event> avoid the serde_json::Value regex; no new gated-clock.

income.closed vs ADR-027 vocab

ship it (audit-completeness); documented in the as-built note + Antora + CHANGELOG — NO ADR edit (immutable) and no new ADR for one event name.

event volume / batched finalize

per-fact events are audit-only (no re-determination subscriber); batching deferred to T1-7.

CLI parity

already satisfied (income claim + income claim-delete + asset/expense claim exist); asset/expense claim-delete await #562’s close endpoints — no T1-5 CLI change.

Verification

Per-commit: cargo build + cargo clippy -p canopy-persons -p canopy-contracts-persons -p canopy-security --all-targets -D warnings (watch too_many_lines on the 3 append fns + 3 claim handlers + parse_event) + nextest on touched crates. After (c)/(d): cargo xtask dev refreshcargo nextest run -p canopy-persons --test fact_event_emission_test (devstack-gated). After (e): cargo xtask api-docs --update (no diff) + cargo xtask quality-budgets (no movement) → full cargo xtask validate. Manual smoke: canopy income claim … then SELECT routing_key FROM event_outbox shows income.claimed; canopy income claim-delete … shows one income.closed; repeat delete → no second event.

Follow-ups (no new issues unless noted)

  • T1-6 (#674) — scoped change-history endpoint; the first consumer of these events.

  • T1-7 (#675) — applications finalize authors applicant claims (income/assets/expenses) per-fact, unbatched: the batched-finalize question is resolved with no summary event (the per-fact *.claimed events are audit-only).

  • T1-9 (#677) — IEVS Proposed→accept/reject → the .accepted/.rejected events (their firing site).

  • #562 — asset/expense web editors + asset.closed/expense.closed events (their close primitives).

Edit this page · default