T1-7 — Applications Finalize Authors Applicant Asset/Expense Claims (#675)
On this page
Epic &56 / Track 1, T1-7 (#675). Make the applicant-portal finalize step author
self-reported assets and expenses into the canopy-persons version corpus —
mirroring the income authoring T1-4 Slice 3 already ships — as auto-accepted
AcceptedUnverified claims attributed to the applicant (the finalize-resolved
household_id, ADR-027 §9), emitting the attributed asset.claimed /
expense.claimed events T1-5 already wired.
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-19) — |
(a) contracts |
|
Done (2026-06-19) — |
(b) client |
|
Done (2026-06-19) — |
(c) emission |
|
Done (2026-06-19) — |
(d) tests |
|
Done (2026-06-19) — |
(e) docs |
Antora |
Done (2026-06-19) — the docs commit; OpenAPI snapshot regenerated; budgets flat. |
canopy finalize CLI verb exists), and the
canopy {income,asset,expense} claim CLI verbs already ship from T1-4. The
PersonsClient methods are internal Rust, not a new public endpoint.
Context
T1-4 Slice 3 (#672, merged) made the canopy-persons version corpus the sole
fact store — the legacy income/assets/expenses tables were deleted and facts are
written only through POST /v1/persons/{id}/{income,assets,expenses}/claims. T1-5
(#673, merged ad976b99) made every claim emit an attributed *.claimed event
through the ADR-018 outbox.
The applicant-portal materialise-at-finalize step
(finalize_draft, ADR-026)
already creates the applicant person + household + members and authors the
applicant’s self-reported income through the corpus
(persons.claim_income(…) with Author::applicant(household_id) /
SelfAttestation / origin:"finalize", auto-accepted AcceptedUnverified). It
authors no assets and no expenses: the FinalizeRequest contract has no such
fields and PersonsClient has no claim_asset/claim_expense. T1-7 closes that
gap by mirroring the income path exactly, preserving the ADR-027 §9 privacy
boundary (the applicant author carries the finalize-resolved household_id, not a
portal-session handle).
Scope decision
SHIP: the FinalizeRequest asset/expense fields, the two PersonsClient claim
methods, the finalize authoring, an integration test, docs — canopy-applications
and the applications contract crate only.
-
NO portal change — and the end-to-end portal→asset-fact path is an explicit NON-GOAL. The apply wizard collects dollar estimates (monthly income, liquid assets, rent, utilities) only to build the expedited-screening quick-check (7 CFR 273.2(i));
build_finalize_requestdeliberately sendsincome: []and no assets/expenses, because an applicant quick-check estimate is not a verified eligibility fact — a worker captures the real amounts later (the principle that already drove income’s empty array). So real portal submissions will not author asset/expense facts via this MR — identical to income today. T1-7 makes finalize capable of authoring applicant asset/expense claims when a caller sends them; the producers are the integration test, the seed, and the worker fact-authoring UI (T1-8, 676). Promoting the portal quick-check estimates to claims is a separate, deliberate product decision, not delivered here. The new fields are[serde(default)], so every JSON caller keeps working and no Rust struct-literal consumer breaks. -
NO batched/summary finalize event. Income already ships unbatched per-fact; the per-fact
*.claimedevents are audit-only (no re-determination subscriber), so N-per-finalize is safe, and a no-subscriber summary event would violate the same "no dead code" principle (D1) T1-5 used. The "batched into a bounded event set" language in the master plan / ADR-027 §132 Consequences is stale and reconciled in (e) (no ADR edit — ADRs are immutable; this mirrors how the D10income.closedextension was documented). ADR-027 §9, the binding decision, is purely the privacy boundary and says nothing about batching.
Implementation
(a) contracts
crates/canopy-contracts-applications/src/finalize.rs: add FinalizeAsset
(person_index, asset_type, value decimal-string, optional description) and
FinalizeExpense (person_index, expense_type, amount decimal-string,
frequency), mirroring FinalizeIncome (same derives incl. utoipa::ToSchema);
add [serde(default)] pub assets: Vec<FinalizeAsset> + [serde(default)] pub
expenses: Vec<FinalizeExpense> to FinalizeRequest. Amounts stay decimal strings
(the contract crate is decimal-free; the service parses them).
Tests: extend crates/canopy-contracts-applications/tests/roundtrip.rs (which
claims round-trip coverage for every DTO but currently omits the finalize
family) with arb_* generators + roundtrip! entries for the new and pre-existing
finalize DTOs; add a #[serde(default)] default-deserialization unit test (a body
without assets/expenses yields empty vecs).
(b) client
services/canopy-applications/src/persons_client.rs: add claim_asset and
claim_expense mirroring claim_income (the CLAIM_ASSET/CLAIM_EXPENSE paths,
service-identity auth, the shared parse).
(c) emission
services/canopy-applications/src/api/mod.rs:
-
Pre-validate all facts before any cross-service write. A pure
validate_finalize_facts(&FinalizeRequest)run right after the program validation (before the firstpersons.create_*) checks every income/asset/expenseperson_index < 1 + household_members.len()and every amount/value parses toDecimal(else 422). This closes the orphan-window — a malformed fact no longer 422s after the person/household rows are created (ADR-026 §5) — and strictly improves income’s existing post-creation check. -
Extract three private
async fnhelpersauthor_{income,asset,expense}_claims(they.awaitpersons.claim_*), moving the income loop in and mirroring it for assets/expenses (Author::applicant(household_id)/SelfAttestation/origin:"finalize"/valid_from = today/ open-ended).finalize_draftshrinks to three.await?calls. -
Register
FinalizeAsset/FinalizeExpensein the OpenAPIcomponents(schemas)list (utoipa does not auto-discover nested schemas) and add the contract imports.
§9 hygiene is structural: Author::applicant(household_id) carries the household
id only; finalize never sees a portal JWT (the portal calls with its service
token).
(d) tests
services/canopy-applications/tests/finalize_test.rs: extend finalize_body()
with one asset + one expense; add an outbox_pool() helper on the canopy-persons
database (the events are in the persons outbox, not the applications one); a
devstack-gated test finalizes income+asset+expense and, via three separate typed
per-kind reads of the persons outbox keyed on author.household_id, asserts one
income.claimed + asset.claimed + expense.claimed each decoding to
Author::Applicant{household_id} (structurally proving no portal-session
identity), claim_status = AcceptedUnverified, value matching. Skips cleanly
unless both the finalize client and the outbox pool are available.
(e) docs
Antora api/canopy-applications.adoc (finalize accepts + authors assets/expenses;
the 422 description gains asset/expense index/amount); flip this plan’s Status
cells to Done; reconcile the master plan
(Plan: Worker Fact Authoring and Provenance) — intro Track-1 progress,
the T1-7 + verified-stale T1-6 status rows (T1-6’s Proposed-claim feed was
re-sliced to T1-9 #677), the MR-summary prose, and the stale batching line; flip
the T1-5 plan’s contradictory "bounded summary event" line; CHANGELOG Added
entry; api-docs --update + quality-budgets (no movement).
Verification
Per commit: cargo build + cargo clippy -p canopy-contracts-applications -p
canopy-applications --all-targets — -D warnings + nextest on touched crates.
After (c)/(d): cargo xtask dev refresh then cargo nextest run -p
canopy-applications --test finalize_test (devstack-gated; confirm it ran, not
skipped). After (e): api-docs --update (additive diff) + quality-budgets (no
movement) + full cargo xtask validate (the pre-push gate).
Follow-ups
-
A
test:follow-up issue (related to #675): extend T1-5’s income-only no-PII raw-key event guard to theasset.claimed/expense.claimedpayloads (a T1-5 omission, out of this slice’s crate scope). -
Optional future portal task: if the product decides applicant self-reported asset/expense estimates should be promoted to claims at finalize, wire
build_finalize_requestto send them (the deliberate non-goal above). -
T1-8 (#676) — the worker fact-authoring UI, the real worker-driven producer of asset/expense claims.