Plan: Attributed Fact-Mutation Events — T1-5 (#673, epic &56)
On this page
- Status
- Context
- Scope (D1 — emit only where a firing site exists today; no dead code)
- Source-confirmed ground truth (do not re-recon)
- Implementation (one MR, commits a→e; each independently build-green)
- (a) contracts —
feat: attributed fact-event payload types (T1-5 #673) - (b) store —
feat: capture before-value under the lock; return it (T1-5 #673) - (c) emission —
feat: emit attributed fact events from persons handlers (T1-5 #673) - (c-sec) audit index —
feat: index attributed fact events by fact_id + author (T1-5 #673) - (d) tests —
test: assert attributed fact events stage in the outbox (T1-5 #673) - (e) docs —
docs: attributed fact events — Antora + plan + CHANGELOG (T1-5 #673)
- (a) contracts —
- Decisions resolved from the review pass
- Verification
- Follow-ups (no new issues unless noted)
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 |
Done (2026-06-19) — commit |
(b) store + (c) emission |
|
Done (2026-06-19) — commit |
(c-sec) audit index |
canopy-security |
Done (2026-06-19) — commit |
(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 |
(e) docs |
Antora Events Published section + services index correction + plan Status flip + as-built note + CHANGELOG; |
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 today —
services/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/.rejected→ T1-9 (#677): no accept/reject handler exists; IEVSProposedleads 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, mirroringclose_income_version. -
household_member.*→ already covered by the existing (non-attributed)household.member_added/household.member_removedevents; re-attributing them (noAuthoron membership today) is deferred (T1-9 may surface it). -
Batched finalize → T1-7 (#675): finalize lives in canopy-applications and fans out per-fact
claim_incomeHTTP calls (Slice 3), so each fact emits oneincome.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; setstimestamp = Utc::now()itself → events need no separate payload timestamp).publisher.publish_tx(&mut tx, &envelope) → Result<(),PublishError>(publisher.rs:90) INSERTs intoevent_outboxwithin the caller’s tx (the column stores the FULL envelope; the nested event payload ispayload→'payload').OutboxDrainerrelays to thecanopy.eventstopic 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: afterState(state), matchingcreate_person;Publisheralready 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 thebeforedata, 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); allSerialize+Deserialize+Clone.auto_accept_status(Some(&Author)) → ClaimStatus.is_none_oravailable.Decimalserializes as a string workspace-wide (rust_decimalserde-strfeature) → no per-field attribute. ReuseIncomeFactValue/AssetFactValue/ExpenseFactValueas 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_eventextractsresource_idfrom[id, resource_id, person_id, household_id, application_id, determination_id](so a fact event without a fact-aware change indexes byperson_id, NOTfact_id) anduser_idonly from top-level[user_id, created_by, …](the nestedauthor.subis MISSED → no actor). This REQUIRES the c-sec parser change.parse_eventtoleratesauthor:null+ extra fields; all parsed fields areOption(no NOT-NULL violation). -
Gates: SPDX on new .rs; clippy
too_many_lines=40HARD-D(clippy.toml); quality budgets fail-on-regression (B3a/B3b regex is the literalserde_json::ValueNOTto_value/from_value; B3a excludescrates/canopy-contracts-; B8 countsclock::now/todaynotUtc::now);cargo xtask api-docs --updatedrift gate; pre-push = fullcargo 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(pollevent_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). MirrorAssetBeforeWindow/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=Nonefor the DELETE;beforenon-empty — no event when the close superseded nothing). -
Add
pub mod events;to lib.rs.pubstructs → 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-private — append_*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 |
ADD to all 4 (after State); no utoipa drift; Publisher already imported. |
|
|
audit index (canopy-security) |
NEW commit (c-sec): index fact events by |
Decimal serialization |
bare |
close attribution |
|
clippy 40-line ceiling |
extract |
B3a/B3b/B8 budgets |
untouched — |
|
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 refresh → cargo 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
*.claimedevents are audit-only). -
T1-9 (#677) — IEVS Proposed→accept/reject → the
.accepted/.rejectedevents (their firing site). -
#562 — asset/expense web editors +
asset.closed/expense.closedevents (their close primitives).