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) — c094d8d.

(a) contracts

canopy-contracts-applicationsFinalizeAsset + FinalizeExpense DTOs (mirror FinalizeIncome; decimal-string amounts) + [serde(default)] assets/expenses fields on FinalizeRequest. Extend the roundtrip.rs proptest suite to the (previously-omitted) finalize DTO family + a [serde(default)] default-deserialization unit test.

Done (2026-06-19) — cd1a95f5.

(b) client

canopy-applications PersonsClientclaim_asset / claim_expense mirroring claim_income (paths::CLAIM_ASSET/CLAIM_EXPENSE).

Done (2026-06-19) — 77a939f (folded with (c): a binary-only crate makes the unused pub methods dead_code until (c) uses them).

(c) emission

finalize_draft — pre-validate all income/asset/expense person_index + amounts before any cross-service write (orphan-window fix); extract three per-kind async fn authoring helpers (income loop moves in); author assets/expenses as Author::applicant / SelfAttestation / origin:"finalize"; register FinalizeAsset/FinalizeExpense in the OpenAPI schema list.

Done (2026-06-19) — 77a939f.

(d) tests

finalize_test.rs — a devstack-gated test that finalizes income+asset+expense and asserts (via three per-kind typed reads of the canopy-persons outbox keyed on author.household_id) one attributed AcceptedUnverified applicant-authored event per kind, with Author::Applicant{household_id} (structurally proving the §9 no-portal-session hygiene).

Done (2026-06-19) — 10f0f76 (verified live: 7 finalize tests run, 0 skipped).

(e) docs

Antora api/canopy-applications.adoc (finalize accepts/authors assets/expenses) + this plan Status flip + master-plan reconciliation (intro, T1-6/T1-7 rows, batching prose) + T1-5 plan batching line + CHANGELOG; api-docs --update + quality-budgets (no movement).

Done (2026-06-19) — the docs commit; OpenAPI snapshot regenerated; budgets flat.

NOTE
ADR-007 API/CLI parity is satisfied without new surface: finalize is an existing HTTP endpoint (no 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_request deliberately sends income: [] 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 *.claimed events 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 D10 income.closed extension 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 first persons.create_*) checks every income/asset/expense person_index < 1 + household_members.len() and every amount/value parses to Decimal (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 fn helpers author_{income,asset,expense}_claims (they .await persons.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_draft shrinks to three .await? calls.

  • Register FinalizeAsset/FinalizeExpense in the OpenAPI components(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 the asset.claimed/expense.claimed payloads (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_request to send them (the deliberate non-goal above).

  • T1-8 (#676) — the worker fact-authoring UI, the real worker-driven producer of asset/expense claims.

Edit this page · default