T2-1 A2 — Household-member valid-time versioning (canopy-persons) (#683)
On this page
Epic &56 / Track 2, T2-1 (#683), slice A2. T1-3/T1-4 (#671/#672) gave canopy-persons
an append-only, valid-time-versioned fact corpus for income/assets/expenses
(ADR-027 §3), and A1
(#683, merged 50ad5f65) extended it to addresses. household_members were left out —
still a flat, mutable, active-soft-delete table with bare-JSON household.member_added/
member_removed events (no attribution), no provenance, no as-of read. ADR-027 §3 names
household membership a time-varying fact that must be valid-time-versioned; A2 fulfils that.
T2-1 is sliced: A1 — addresses (done), A2 — household_members (this MR), Half B —
determination supersession (canopy-snap + canopy-eligibility, ADR-028 §57, the closing MR).
A2 ships Relates to #683 (not Closes). User-confirmed direction: full claim/close
mirror (uniform authored-fact contract) + no pre-1.0 backward compat — strip legacy.
Scope boundary
A2 is the valid-time versioning of household_members in canopy-persons: a new
household_member_versions table + testable backfill, an authored claim/close pipeline,
attributed household.member_claimed/member_closed events, an as-of household read, and —
because membership feeds Person.household_id — an as-of-aware person projection so the
denormalised household_id stays consistent with the as-of household reads at every as_of.
Membership is household-scoped (not person-scoped like income/address): the ownership
correction key is household_id, and the non-overlap EXCLUDE is per-(household_id, person_id)
(a person may be in two different households at once, never the same one twice).
OUT of scope (each a follow-up or another slice): determination supersession (Half B,
closes #683); dropping the legacy household_members table + backfill function (the
CONTRACT step — kept this MR for backfill testability, a tracked follow-up mirroring A1’s
#890); versioning the households entity itself (name/effective window — not a per-person
time-varying fact); membership provenance in MemberFull (the /full bundle keeps the
relationship string; provenance is on HouseholdMember via GET /households/{id}); members
in the T1-6 typed change-history (resource enum + worker UI — the generic audit capture
DOES land here).
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-21) — the plan commit. |
migration + contracts |
|
Done (2026-06-22) — the A2 implementation commit. |
store + events |
|
Done (2026-06-22) — the A2 implementation commit. |
api + as-of-aware person projection |
|
Done (2026-06-22) — the A2 implementation commit. |
canopy-security + canopy-web + seed + consumers |
|
Done (2026-06-22) — the A2 implementation commit. |
tests |
|
Done (2026-06-22) — the A2 implementation commit. |
docs |
|
Done (2026-06-22) — the A2 implementation commit. |
Context
The income/asset/expense/address valid-time corpus is the proven pattern: per-fact _versions
tables, a non-overlap GiST EXCLUDE where current-accepted, the snapshot-and-supersede + remnant
re-tile correction algorithm, attributed .claimed/*.closed outbox events, and an as-of read
filtered to determination_statuses(). The household_members table predates all of it: flat,
mutable, active soft-delete, no provenance, no events, no as-of read. A2 brings membership to
parity. The shared substrate (store/fact_versions.rs: compute_remnants, lock_fact,
author_columns, reconstruct_provenance, determination_statuses, FactReadError) is reused
unchanged; store/address_versions.rs is the line-by-line template.
Membership differs from income/address in three deliberate ways, each handled below: it is
household-scoped, its non-overlap key is (household_id, person_id), and its versioned
value (relationship) is not PII (no coarse event projection — the full MemberFactValue
rides the event, like income). And because a person’s membership is denormalised onto every
person read as Person.household_id, A2 also makes that person→household JOIN as-of-aware.
Decisions
| Decision | Resolution |
|---|---|
EXPAND, don’t drop |
A2 creates |
Full claim/close mirror (user-confirmed) |
The member API + events become the same authored claim/close contract every other fact uses. Breaking path + event rename. No pre-1.0 backward compat (user directive): the old |
Schema |
One forward-only migration (ADR-016) |
Identity + scope |
|
Backfill |
Idempotent |
Contracts |
|
Store |
|
As-of-aware person projection (the HIGH consistency fix) |
|
API |
Add |
Events / audit |
|
Seed |
Repoint the four |
Quality budgets |
No new |
OpenAPI |
|
Implementation
Single MR, Relates to #683 (the split work does not close the issue — Half B closes it).
The (plan) commit is first (this .adoc + nav). Each commit build-green; per-commit the
pre-commit token gate + a fresh J1–J8 subagent over the staged diff, reported as text.
-
Migration —
migrations/20260624000000_create_household_member_versions.sql: extension →CREATE TABLE household_member_versions→ 3 indexes →backfill_household_member_versions_v1()→SELECTit. Keep the legacyhousehold_memberstable (comment: frozen, dropped in the CONTRACT follow-up). -
Contracts —
households.rs/events.rs/paths.rsper the Decisions (+ the new imports). -
Store —
store/household_member_versions.rs(mirror address, household-scoped, snap carriesperson_id); register instore/mod.rs;households.rsdrop add/remove +get_with_members(as_of);store/models.rsdropAddMemberre-export + removeHouseholdMemberRow+ itsFrom. -
Events —
publish_member_claimed+publish_member_closed. -
API — claim/close/
persist/require_member_ownership+get_household(_full) as-of + remove add/remove +routes()/#[openapi]/19→19 +map_claim_pg_error23503→422. -
Downstream reads (as-of-aware person projection) —
persons.rshousehold_join(placeholder)helper +update()one-off +as_ofparam on the five reads +resolve_as_ofpub(crate)+batch.rs/export.rsthreadas_of+ grep every other caller. -
canopy-security — REPLACE the two
parse_event_typearms (no aliases) + fullparse_eventtests. -
canopy-web —
audit/mod.rsREPLACE the humanize keys (no aliases);api/members.rsclaim/close bodies. -
Seed — repoint the four member inserts (mirror the address seed block, active-only); reset list + integration check.
-
Consumers — applications (
persons_client+ the 2 finalize sites); cli (cmd/household+main+cli_test); test-lib (claim_member/close_memberclient + thegivenDSL). -
Tests — the income/address test set mirrored for members + the per-
(household_id, person_id)overlap test + backfill test (incl.active=false→closed + add→remove→re-add disjoint) + 23503→422 + person_id-mismatch→422 + the as-of-consistency suite + contracts roundtrip/snapshots. -
Docs —
data-models/apicanopy-persons; CHANGELOG (Added/Changed/Removed); master plan A2 sub-note; this plan → Done + As-built; stale-doc sweep;api-docs --update.
Verification
cargo build -p canopy-persons -p canopy-contracts-persons -p canopy-security -p canopy-test-lib
-p canopy-seed -p canopy-applications -p canopy-web -p canopy-cli; set -a; source .ports.env;
set +a; cargo nextest run -p canopy-persons -p canopy-contracts-persons -p canopy-security
(devstack-gated). cargo xtask quality-budgets (expect flat; ratchet DOWN if the removal lowers
one). cargo xtask api-docs --update + commit the persons.json delta + confirm the path-count
assertion (19). cargo xtask docs plan-lint + cargo xtask check-docs. After cargo xtask dev
refresh: confirm seeded members appear via GET /v1/households/{id} + /full and the
canopy-web case-detail household display renders. Full pre-push battery (cargo xtask validate
--skip-docker + e2e + cargo doc + k6 smoke). Load-bearing assertions: the
per-(household_id, person_id) EXCLUDE rejects an overlapping membership; a correction re-tiles
remnants; a close drops the fact from as-of-after reads but not as-of-before; the backfill
produces the expected provenance + disjoint re-add windows; Person.household_id matches the
as-of household read at the same as_of (and /full?as_of=PAST hydrates as-of-PAST).
As-built notes
Built as planned (plan commit 7f7c731 + one implementation commit), Relates to
#683. The plan held; the deviations are mechanical, recorded here so the
plan↔code diff stays zero.
-
MemberSnapcarriesperson_idin its SELECT (the deliberate household-scoped divergence). Income/address snaps omitperson_id(their ownership key, passed as a param). The member ownership key ishousehold_id, sohousehold_member_versions.rs::MemberSnapSELECTsperson_idandreinsert_remnantscopiess.person_id— soclose_member_versionneeds noperson_idparam, andsnapshot_and_supersedekeysWHERE fact_id = $1 AND household_id = $N. -
As-of-aware person projection — A2 touched the person read endpoints. To keep
Person.household_idconsistent with the as-of household reads (the HIGH consistency finding),HOUSEHOLD_JOINbecame ahousehold_join(placeholder)helper readinghousehold_member_versions … daterange @> $N, andget/list_by_ids/list/list_for_export/updategained anas_ofparam.update()is a self-contained one-off (its inline subquery, not the helper).resolve_as_ofwas madepub(crate)soapi/export.rs::export_personsreaches it for its two person reads viasuper::resolve_as_of— B8 preserved (no secondclock::today()).batch.rs::expand_persons_as_ofthreads itsas_oftolist_by_ids. Two residual splits stay (pre-existing, by design): multi-household membership projects to onehousehold_idviaLIMIT 1; the unversionedactive = truehouseholds-entity read can 404 a household the JOIN resolves. -
No PII projection (unlike address A1).
relationshipis not PII, so thehousehold.member_claimed/member_closedevents carry the fullMemberFactValue(the income pattern).publish_member_claimedcarries an#[expect(clippy::too_many_arguments)]— the household-scopehousehold_idpushes it to 8 args past the 7-arg lint (mirrors `reconstruct_provenance’s precedent). -
Shared
map_claim_pg_errorgained23503(FK) → 422. The member claim’s client-supplied bodyperson_idmakes a bad FK an ordinary client mistake (income/address inherit the same correct mapping for a bad pathperson_id— a strict hardening). -
No-backward-compat (user directive). The old
POST/DELETE /memberspaths, theAddMemberDTO, and thehousehold.member_added/member_removedevent names
theirparse_event_type/humanize_eventarms were deleted, not aliased. -
Legacy
household_memberstable kept frozen (EXPAND only). The destructive CONTRACT drop +backfill_household_member_versions_v1()removal is a filed follow-up (kept so the backfill test runs on the ephemeral schema). -
Quality budgets flat. No new
serde_json::Value(theproposed_value JSONBreuses A1’sSTRUCTURAL-VALUEcarve-out);list_by_household_as_ofis a direct query (nounwrap_or_default). ADR-027 unchanged — A2 fulfils §3.
Follow-ups
File each as a separate GitLab issue and /relate #683:
-
chore:CONTRACT — drop the legacyhousehold_memberstable +backfill_household_member_versions_v1()once A2 has baked (forward-only migration; fold into / sibling of A1’s #890). -
feat:household-member change-history — extend the T1-6 fact-history resource enum (+ worker UI) to members, if product needs the per-resource membership timeline (the generic audit capture already lands in A2). -
on-behalf-of attribution for member close —
MemberClosedEvent.authorisNoneuntil the ADR-019 on-behalf-of plumbing lands (shared limitation with income/address close); track with the existing on-behalf-of follow-up.