T2-1 A1 — Address valid-time versioning (canopy-persons) (#683)
On this page
Epic &56 / Track 2, T2-1 (#683), slice A1. T1-3/T1-4 (#671/#672) gave canopy-persons
an append-only, valid-time-versioned fact corpus for income/assets/expenses
(ADR-027 §3). Addresses
were left out — still a flat, mutable, event-less, un-versioned table. ADR-027 §3 names
address as a time-varying fact that must be valid-time-versioned; A1 fulfils that, mirroring
the proven income machinery. T2-1 is sliced: A1 — addresses (this MR), A2 —
household_members (next), Half B — determination supersession (canopy-snap
canopy-eligibility, ADR-028 §57, the closing MR). A1 ships Relates to #683 (not Closes).
Scope boundary
A1 is the valid-time versioning of addresses in canopy-persons: a new address_versions
table + testable backfill, an authored claim/close pipeline, attributed (street-redacted)
events, and as-of reads — a near-mechanical mirror of income_versions. OUT of scope (each
a follow-up or another slice): household_members versioning (A2); determination
supersession (Half B, closes #683); dropping the legacy addresses table + backfill
function (the CONTRACT step — kept this MR for backfill testability, a tracked follow-up
mirroring T1-4’s dedicated drop migration); addresses in the T1-6 typed change-history
(the per-resource history endpoint’s resource enum + worker UI — the generic audit capture
DOES land here); a worker-portal address editor (none exists today); an address_type-aware
"primary residential" selection (needs a canonical type enum — A1 only guarantees a
deterministic first row).
Status
| Step | Description | Status |
|---|---|---|
(plan) |
This execution plan + nav entry. |
Done (2026-06-21) — |
migration + contracts + store |
|
Done (2026-06-21) — the A1 implementation commit. |
api + events + downstream reads |
|
Done (2026-06-21) — the A1 implementation commit. |
canopy-security + seed + consumers |
|
Done (2026-06-21) — the A1 implementation commit. |
tests |
|
Done (2026-06-21) — the A1 implementation commit. |
docs |
|
Done (2026-06-21) — the A1 implementation commit. |
Context
The income/asset/expense valid-time corpus (T1-3/T1-4) is the proven pattern: per-fact
_versions tables (version_id/fact_id/person_id/valid_from/valid_to/superseded_at
+ provenance columns), a non-overlap GiST EXCLUDE over (fact_id, daterange) where
current-accepted, the snapshot-and-supersede + remnant re-tile correction algorithm, attributed
.claimed/income.closed outbox events, and an as-of read filtered to
determination_statuses(). The addresses table predates all of it: flat, mutable, active
soft-delete, no provenance, no events, no as-of read. A1 brings addresses to parity.
The shared substrate (store/fact_versions.rs: compute_remnants, lock_fact,
author_columns, reconstruct_provenance, determination_statuses, FactReadError) is reused
unchanged; store/income_versions.rs is the line-by-line template. Addresses differ only in
(a) the value columns, (b) no numeric value (no negative guard, no value CHECK), and (c) a
street-redacted event value (PII decision below).
Decisions
| Decision | Resolution |
|---|---|
EXPAND, don’t drop |
T1-3 created the version tables + a |
Schema |
One forward-only migration (ADR-016) sorting after |
Identity |
|
Backfill |
Idempotent |
Address-event PII (street-redacted) |
The |
Contracts |
|
Store |
|
API |
Add |
Export |
Explicit rule: "all current windows" ( |
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/<ts>_create_address_versions.sql: extension →CREATE TABLE address_versions→ 2 indexes →backfill_address_versions_v1()→SELECTit once. Keep the legacyaddressestable (comment: frozen, dropped in the CONTRACT follow-up). -
Contracts —
addresses.rs/events.rs/paths.rsper the Decisions. -
Store —
store/address_versions.rs(mirror income +list_all_current_by_persons+ deterministic order); register instore/mod.rs; deletestore/addresses.rs; removeAddressRow+ re-export instore/models.rs. -
Events —
publish_address_claimed+publish_address_closed. -
API — claim/close/
persist+list_addressesconversion + removeadd_address+map_fact_read_errpub(crate)+routes()/#[openapi]/17→19. -
Downstream reads —
batch.rs→list_by_persons_as_of;export.rs→list_all_current_by_persons+ import flip +super::map_fact_read_err+sample_address()provenance: None. -
canopy-security — two
parse_event_typearms + fullparse_eventtests. -
Seed — repoint the four address inserts; add
address_versionsto the reset list + integration check. -
Consumers — test-lib client (
claim_address/close_address);persons_test+batch_expansion_test+ the insta snapshot redaction;roundtrip.rsarbs (removearb_create_address/create_address_roundtrip!; addaddress_fact_value/address_claim_requestroundtrips;arb_addressgainsprovenance). -
Tests — the income test set mirrored for addresses + the backfill test (incl.
active=false→ closed) + export-projection tests (multi-row portability, deduped FOIA) + the PII value-leak assertion (no street literal anywhere in the serialized payload). -
Docs —
data-models/apicanopy-persons; CHANGELOG (Added/Changed/Removed); master plan A1 sub-note; this plan → Done + As-built; the stale-doc sweep (ClaimResponsedoc, contractslib.rssummary,require_fact_ownershipdoc);api-docs --update.
Verification
cargo build -p canopy-persons -p canopy-contracts-persons -p canopy-security -p canopy-test-lib
-p canopy-seed; 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. cargo xtask docs plan-lint
cargo xtask check-docs. After cargo xtask dev refresh: confirm seeded addresses appear via
GET /v1/persons/{id}/addresses and the canopy-web case-detail address display still renders.
Full pre-push battery (cargo xtask validate + e2e + cargo doc). Load-bearing assertions: the
overlap EXCLUDE rejects a second current-accepted version; 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; the address event payload contains no street value anywhere; the FOIA
export dedups multi-window facts.
As-built notes
Built as planned (plan commit 603af68 + one implementation commit), Relates to
#683. The plan held; the only deviations are mechanical, recorded here so the
plan↔code diff stays zero.
-
list_by_person_as_ofis a direct single-person query, not the income delegate pattern. The plan said "mirrorincome_versions`"; income’s `list_by_person_as_ofdelegates to the multi-person read +.remove(&id) .unwrap_or_default(). Thatunwrap_or_defaultwould have pushed the LOCKED B5 budget 309→310. Per the offset-don’t-raise discipline the new debt was minimized: the single-person read is written as a directperson_id = $1query that.collect()`s into the `Result<Vec<Address>, _>— no HashMap, nounwrap_or_default. B5 stays flat at 309 (and the single-person path is marginally cheaper). All other budgets flat (B3a 757 — zero newserde_json::Value; the typed value reuses the existingproposed_value JSONB). -
AddressEventValueis a separate coarse type, not a field subset ofAddressFactValue. To keep the street out of the event while keeping it in storage, the event payload uses a distinctAddressEventValue(address_type/city/state/zip/county_fips) with animpl From<&AddressFactValue>that dropsline_1/line_2. The store’sAddressSnapkeeps the full value (remnant re-tiling preserves the street); onlyaddress_before/from_claimproject to the coarse event value. The redaction is asserted at BOTH layers: the contracts unit test + the outbox-payload-as-text integration test assert the street literal appears nowhere in the serialized event. -
Export uses "all current windows" (
list_all_current_by_persons), FOIA deduped. The bulk export reads every non-superseded accepted version (no date filter) — strictly ⊇ the prioractive = trueset, so a legal export loses no address; the FOIA projection dedups identical coarse rows (street + dates dropped), portability keeps one row per live window. -
Legacy
addressestable kept frozen (EXPAND only). The destructive CONTRACT-phase drop +backfill_address_versions_v1()removal is a filed follow-up (kept so the backfill-transformation test runs on the ephemeral schema, which applies every migration to head). -
Cold-start flake note. The post-
dev refreshwarm battery is green; the unrelatedfact_change_history_testtrio cold-flaked once on broker poll-timeout immediately after the container recreate and passed on the warm re-run (the documented devstack cold-start pattern) — not a regression (A1 does not touch that endpoint). -
Folded-in seed fix (
determination_snapshotsTRUNCATE guard). The forced reseed (this MR editstools/canopy-seed/src, in theSEED_DIRShash) surfaced a pre-existing, unrelated infra bug: the canopy-seedrender_{snap,caps,wic}TRUNCATE … _determinations … CASCADEis blocked by the append-onlydetermination_snapshotstrigger (T1-10/T2-4) because the seed didn’t open thecanopy.snapshot_maintenancewindow, and the loader swallowed the abort (WARN-and-continue), leaving snap/caps/wic stale on every reseed. Diagnosed by five independent contextless reviewers (consensus: the *generator is correct; the seed render + loader were not), proven by a byte-identical caps/wic seed diff (my change is addresses-only) + a live reproduction. Fixed in-MR (user decision, given it blocked the push + is a repo-wide reseed bug):SET LOCAL canopy.snapshot_maintenance = 'on'in the three program renders + a fail-loud loader. The two pre-existingcaps/wiccase-detail e2e specs (which depend on the freshly-seeded program data) now pass.
Follow-ups
File each as a separate GitLab issue and /relate #683:
-
chore:CONTRACT — drop the legacyaddressestable +backfill_address_versions_v1()once A1 has baked (forward-only migration, mirroring T1-4’s drop migration). -
feat:address change-history — extend the T1-6 fact-history resource enum (+ worker UI) to addresses, if product needs the per-resource address timeline (the generic audit capture already lands in A1). -
feat:primary-address selection — a canonicaladdress_typeenum + anaddress_type-aware "primary residential address" read, so canopy-web’s case-detail shows the residential (not an order-arbitrary) address. A1 only guarantees a deterministic first row.