canopy-persons API Reference
On this page
Overview
Cross-link: canopy-persons Data Model (#419)
Central person and household data management. All program services reference persons by ID rather than duplicating demographic data.
- Base URL
- Authentication
-
Bearer token (Keycloak RS256 JWT)
- Minimum role
-
caseworker
- Swagger UI
- Database
-
canopy_persons
Receiver contract (OIDC S-persons, #1428 / ADR-043 §C)
canopy-persons is the fourth service on the ADR-043 receiver contract
(canopy_auth::ReceiverContract) — user-only enforcement only on a hard
service-only data service. There is no hop-2 route (persons is not a
program service); the data plane and the applications-scoped finalize
surface stay require_service_caller behind the exchanged_gate —
except GET /v1/persons/{id}, which since #1441 is
require_service_or_portal (portal:persons:read): the portal’s
first-name read arrives citizen-class, and since #1442 must carry the
signed ownership claim binding the session’s own submitting person. See
the tanf API page for the bearer-shape and
guard-family description.
-
require_user_only(["data_steward"])on redact-fact, redact-ssn, and compensate-finalize-orphan;require_user_only(["admin", "quality_control"])on the bulk export. Service bearers are always 403; underCANOPY_PERSONS__ENFORCE_USER_ONLY_ROUTES=trueonly an exchanged per-target token (aud=canopy-personsexactly) carrying the named role passes. -
The azp allowlist is DELIBERATELY
canopy-web-exchangeronly (least privilege — persons is not in the orchestrator’sEXCHANGE_TARGETS, so no other exchanger legitimately mints persons-audience tokens). -
Attribution via
EffectiveUseron the redact event actors and the export SSN-access audit/payload actor. -
Operator tooling note: raw password-grant tokens 403 under enforcement. Since #1501 both tools exchange natively —
cargo xtask sweep-finalize-orphans --applyand the canopy-cli crypto-shred commands RFC 8693-exchange the minted/stored bearer foraud=canopy-personsbefore the POST (knobs + one-mint flow: the finalize-orphan-sweep runbook).
Finalize header tag (ADR-038)
The person / household / member / income / asset / expense create + claim
endpoints accept an optional finalize tag carried in request headers, used by the
canopy-applications finalize saga to make each write idempotent + recoverable at
the persons layer (a transactional receipt — never the generic idempotency
middleware, which is at-least-once-on-crash and caches plaintext PII). The tag
rides in headers because the claim DTOs are deny_unknown_fields.
Headers (all three present, or all absent):
| Header | Meaning |
|---|---|
|
The finalize operation id (the reserved application id), a UUID. |
|
The operation generation (integer ≥ 1; bumped on each aborted re-submit). |
|
The caller’s opaque per-step receipt key (e.g. |
When present, the handler — in the write’s own transaction — (1) gates the
(operation_id, generation) FOR SHARE (an absent or cancelled generation →
409); (2) claims a receipt keyed on (operation_id, generation, step_key); a
first write proceeds and stages its outbox events held (ADR-039, drainable
only once the operation releases them), a replay returns the stored entity
(the original person_id / household_id / fact_id), writing nothing. A
finalize-tagged write is applications-only: any other service caller → 403;
a partial or malformed tag → 400. The tag is deliberately absent from the
OpenAPI schema (it is an internal applications↔persons contract, gated by
service-token authz — auditable here, not a hidden control). The generation is
registered / cancelled via the internal finalize-operations endpoints (MR2).
Persons
POST /v1/persons
Create a person. SSN is encrypted at rest via AES-256-GCM before storage.
Request: CreatePerson
{
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1990-05-15",
"ssn": "123456789",
"gender": "female",
"disability_status": "none"
}
Response (201): Person object. The full SSN is never returned — only ssn_last_four (e.g. "6789") is exposed.
GET /v1/persons
List persons with keyset pagination and infix name search, newest first.
Query parameters: limit (page size), after_created_at + after_id
(keyset cursor — the previous page’s last (created_at, id); omit both for the
first page), search (infix ILIKE on first/last name, pg_trgm-GIN-indexed;
minimum 2 characters — a shorter query returns 400). Results are ordered
created_at DESC, id DESC. #1209 (scale audit H4) replaced the former offset
with this keyset cursor and added the server-side minimum-length gate.
Households
POST /v1/households
Create a household.
Request: CreateHousehold
{
"effective_date": "2026-01-01",
"name": "Doe household"
}
Response (201): Household object.
GET /v1/households/{id}
Get a household with all members.
Query: as_of (optional) — the valid-time anchor (T2-1 A2, #683). Absent /
empty / now (case-insensitive) is the current read (today); a YYYY-MM-DD value
reads the membership in effect on that date from household_member_versions; a
malformed value returns 400.
Response (200): HouseholdWithMembers — the member list (each HouseholdMember
carrying its relationship + the additive provenance: Option<Provenance>) is the
current-accepted membership versions valid on as_of. Since T2-1 A2 (#683)
membership is valid-time-versioned (household_member_versions), so a future-effective
or already-closed membership is omitted; writes go through /members/claims (below).
POST /v1/households/{id}/members/claims
Author (claim) a household membership — the authored versioned-write path (ADR-027),
mirroring the per-person /claims endpoints. T2-1 A2 (#683). The membership lands as
an attributed, valid-time-versioned, non-overlapping row in household_member_versions,
including retroactive corrections. Because the URL is household-scoped, the subject
person_id rides in the body (not the path).
Request: MemberClaimRequest (person_id + value: {relationship} + the
provenance inputs source / author / origin + valid_from / valid_to? /
fact_id?). As with the other claims, the server derives claim_status (worker →
accepted_verified, applicant → accepted_unverified) and owns recorded_at;
fact_id absent = a new membership (server-generated handle, ADR-025), present = a
valid-time correction (a supplied fact_id must already belong to the path
household_id — an ownership probe, 404 on a wrong pair; and its stored person_id
must match the body person_id — a correction may not reassign a membership to a
different person, 422 on a mismatch). Membership has no numeric value, so the 422s here
are a System author or a body person_id/household_id that violates an FK
(23503 → 422). A double-add — an overlapping membership for the same
(household_id, person_id) via a new fact_id, or a correction extending one fact to
overlap another — trips the per-(household_id, person_id) non-overlap EXCLUDE → 409.
Response (201): ClaimResponse (version_id, fact_id, claim_status). Emits an
attributed household.member_claimed event (the full MemberFactValue rides the
payload — relationship is not PII, so unlike the street-redacted address events no
coarse projection is applied — alongside household_id + the before/after windows).
DELETE /v1/households/{id}/members/claims/{fact_id}
Close (remove) a household membership as of a date (T2-1 A2 #683 — the membership remove
primitive; mirrors the income/address close). Query: as_of (optional, default
today) — the close date. Supersedes the current accepted version and re-tiles only the
left remnant [valid_from, close_date), so the membership drops from as-of≥close_date
reads while history before the close date is preserved. Emits
household.member_closed (full value) only when the close superseded ≥1 window.
Idempotent 204; a fact_id not owned by the path household_id is 404.
Batch Expansion (#626)
Fetch a household (or an arbitrary set of people) plus every member’s full
sub-resource bundle — income, assets, expenses, addresses — in a single call.
Backed by a set-based store core (expand_persons) that issues a fixed five
queries regardless of member count, replacing the per-member N+1 HTTP fan-out
the income tab, eligibility orchestrator, and CMS-416 reporter previously paid.
SSN is decrypted Rust-side, so MemberFull.person carries ssn_last_four and
never the raw ciphertext.
GET /v1/households/{id}/full
Get a household with every member fully expanded.
Query: as_of (optional) — the valid-time anchor (T1-4 Slice 3, #672).
Absent / empty / now (case-insensitive) is the current read (today); a
YYYY-MM-DD value reads the facts in effect on that date from the version
corpus; a malformed value returns 400. The eligibility orchestrator forwards
the determination’s as_of here, so a retroactive determination reads the facts
that were in effect then.
Response (200): HouseholdFull — { household, members: [MemberFull] },
each MemberFull carrying the member’s relationship; the household’s
membership roster itself is now an as-of valid-time read (the current-accepted
household_member_versions valid on as_of — members joined the version corpus in
T2-1 A2, #683), and each member’s income / assets / expenses / addresses are the
current-accepted versions valid on as_of (carrying provenance — addresses joined
the version corpus in T2-1 A1, #683). Returns 404 if the household is unknown.
A household member is included regardless of person.active — membership
is authoritative, so a member whose person record was soft-deleted (without the
membership being cleaned up) is still returned, preserving household_size for
determination. This differs from :batchGet below, which skips soft-deleted
persons.
POST /v1/persons:batchGet
Get a PROJECTED bundle for an arbitrary set of people (person-keyed; used by the CMS-416 reporter to page the Medicaid roll). Capped at 500 IDs per request (422 on overflow). Missing or soft-deleted IDs are omitted.
Since #1223 (ADR-001 Amendment 1 §B4) the request carries a REQUIRED
projection field mask: the person core (names, DOB, demographics) always
returns; each listed group — person_ssn / income / assets / expenses /
addresses — is additionally fetched. Unprojected groups come back
empty/null, cost zero queries and zero payload, and — for person_ssn — the
sealed SSN is NEVER decrypted and NO Pub-1075 ssn.accessed event is emitted.
The empty mask is the CMS-416 shape: one DOB per member (the old full-bundle
fetch moved 3–6GB and staged ~2M spurious ssn.accessed events per GA run).
Since #1203 the request also carries an OPTIONAL as_of valid-time anchor
(YYYY-MM-DD): the projected facts are read from the version corpus as of
that date. Absent/null = today — the interactive-caller semantics, not a
compat shim; the reporting run pipeline pins as_of = period end so a
resumed extract reads the facts in effect for the report period.
Request: BatchGetPersonsRequest
{ "person_ids": ["uuid", "uuid"], "projection": ["income"], "as_of": "2026-06-30" }
Response (200): Vec<MemberFull> — each with relationship: null (no
household context for a person-keyed query).
POST /v1/households:batchGet
Get the COMPACT as-of membership roster for a set of households in one call
(#1203, D5 row 1) — the report pipeline’s replacement for the per-household
GET /full walk. Capped at 500 IDs per request (422 on overflow);
duplicates collapse to one entry; the response follows first-occurrence
request order. Service callers only (§B4 bulk-read posture) — 403
for worker JWTs.
Deliberately NOT HouseholdWithMembers/HouseholdFull: no household core,
no valid-time windows, no provenance, and no person core — each member
is just { person_id, relationship }, so a 500-household response stays
bounded under the 2MiB idempotency-replay cache. Because no sealed envelope
is opened (no SSN, no DOB), the endpoint stages zero Pub-1075
ssn.accessed events — membership is not a §B4 sealed group.
Absent semantics: a household that is missing or active = false is
simply ABSENT from the result — consumers diff the requested id set against
the response (the reporting fold maps each absence to a per-universe-row
skipped_orphan). An ACTIVE household with no current-accepted membership
on as_of is PRESENT with an empty members roster — an honest empty
answer, distinct from absence. Membership comes from the valid-time
household_member_versions corpus (the same as-of read as GET /full),
in two fixed set-based queries (active-household probe + membership
corpus read).
Request: BatchGetHouseholdsRequest — as_of is REQUIRED (every bulk
caller states its read date; the reporting runs pin the period end).
{ "household_ids": ["uuid", "uuid"], "as_of": "2026-06-30" }
Response (200): Vec<HouseholdMembershipSlim> —
[{ household_id, members: [{ person_id, relationship }] }].
Facts: income, assets, expenses (the version corpus)
Since T1-4 Slice 3 (#672) the version corpus ({income,asset,expense}_versions)
is the sole fact store — the legacy income/assets/expenses tables, their
create/update/delete endpoints, and the one-time backfill are dropped. Facts are
authored through /claims (below) and read as-of valid-time.
GET /v1/persons/{id}/{income,assets,expenses}
List a person’s facts of that kind as-of today (the current-accepted version
valid today per fact, claim_status-filtered to determination-feeding —
Proposed/Rejected never appear). Each row carries provenance (author / source /
claim_status / origin / recorded_at), and id is the stable fact_id (it
survives corrections — the edit/remove handle). For a historical read, use
GET /v1/households/{id}/full?as_of=…. (The legacy POST/PUT/DELETE on these
paths were removed — writes go through /claims.)
Since T2-6 MR8 (#687, ADR-036) the
PII value columns are crypto-shred-sealed at rest (income amount/employer_name,
asset value/description, expense amount, address line_1/line_2); the read
opens them server-side. A redacted fact (its DEK shredded — see Redaction
below) still appears in the read (the version row is append-only) but with its
value fields null and redacted: true + redacted_at set — an expungement is
auditable, never silently dropped. Structural discriminators
(income_type/frequency/address_type/city/state/zip/…) survive
redaction. Consumers computing eligibility MUST skip a redacted fact (the
orchestrator does so before a determination).
GET /v1/persons/{id}/addresses
Since T2-1 A1 (#683) addresses are valid-time-versioned (address_versions),
exactly like the other facts. This GET lists a person’s addresses as-of today
(the current-valid version per fact), each carrying provenance, with id =
the stable fact_id. A future-effective or already-closed address is not
"current" and is omitted. Writes go through /addresses/claims (below); the flat
POST /v1/persons/{id}/addresses + the CreateAddress DTO were removed. The bulk
export reads all current windows (not as-of-today) so no held address is lost.
Authored fact claims + close (T1-4, #672 / epic &56)
The authored versioned-write path (ADR-027): a worker- or applicant-authored fact
lands as an attributed, valid-time-versioned, non-overlapping row in the
{income,asset,expense,address}_versions corpus (addresses since T2-1 A1 #683),
including retroactive corrections — and feeds determinations immediately (the
read flip is live; Slice 3).
The request carries provenance inputs — source (a VerificationSource,
snake_case), author ({"author_type":"worker","sub":…} or
{"author_type":"applicant","household_id":…} — System is rejected 422 per
ADR-027 §1), and an optional origin. The server derives claim_status
(worker → accepted_verified, applicant → accepted_unverified) and owns
recorded_at; the client never supplies status / recorded_at / proposed_value.
fact_id absent = a new fact (server-generated handle, ADR-025); present = a
valid-time correction over [valid_from, valid_to) (supersede-overlapping
re-tile-unaffected-remnants + insert, serialised by a per-fact_id advisory
lock). A supplied fact_id must already belong to the path person_id (an
ownership probe — a wrong pair is 404, ADR-027).
Minimum role: service-class caller. Response (201): ClaimResponse
(version_id, fact_id, claim_status). 422 on System author or negative
amount/value; 409 on a (post-lock) overlap conflict.
POST /v1/persons/{id}/income/claims
Request: IncomeClaimRequest (value: {income_type, amount, frequency, employer_name?} + the provenance inputs + valid_from / valid_to? / fact_id?).
POST /v1/persons/{id}/assets/claims
Request: AssetClaimRequest (value: {asset_type, value, description?} + provenance inputs + window + fact_id?). The verification state is the derived claim_status.
POST /v1/persons/{id}/expenses/claims
Request: ExpenseClaimRequest (value: {expense_type, amount, frequency} + provenance inputs + window + fact_id?).
POST /v1/persons/{id}/addresses/claims
Request: AddressClaimRequest (value: {address_type, line_1, line_2?, city, state, zip, county_fips?} + provenance inputs + window + fact_id?). T2-1 A1 (#683). Addresses have no numeric value, so the only 422 here is a System author (never a negative amount). The emitted address.claimed event carries a coarse, street-redacted value (address_type/city/state/zip/county_fips — never line_1/line_2): the precise street is privacy-sensitive (the canopy-mq guard is top-level-keys-only, so the street is kept out of the outbox / audit ledger entirely; the full value persists only in address_versions, per ADR-004 / ADR-027 §8).
Honors the ADR-038 finalize step tag like the income/asset/expense claims (#1137): an X-Canopy-Finalize-*-tagged write is receipted (address:{i} — replay returns the original fact, writing nothing), its address.claimed stages held until the operation’s release, and a cancelled generation compensates the address fact (version superseded, address_version DEK shredded) alongside the rest of the graph.
DELETE /v1/persons/{id}/income/claims/{fact_id}
Close (remove) an income fact as of a date (T1-4 Slice 3 — the remove primitive).
Query: as_of (optional, default today) — the close date. Supersedes the
current accepted version and re-tiles only the left remnant [valid_from,
close_date), so the fact drops from as-of≥close_date determination reads while
history before the close date is preserved. Idempotent 204; a fact_id not
owned by person_id is 404. (A bounded-valid_to correction would re-tile a
right remnant and NOT remove — hence the dedicated close.)
DELETE /v1/persons/{id}/addresses/claims/{fact_id}
Close (remove) an address fact as of a date (T2-1 A1 #683 — the address remove
primitive; mirrors the income close). Query: as_of (optional, default
today). Emits address.closed (coarse, street-redacted) only when the close
superseded ≥1 window. Idempotent 204; a fact_id not owned by person_id is
404.
accept/reject of automated (Proposed) leads is the automated-source
arm of the claim model and lands with its producer — the IEVS adapter (T1-9). A
first-class asset/expense close verb lands with their /claims editors (#562).
Redaction (crypto-shred, T2-6 #687)
Privileged, irreversible expungement of sealed PII (ADR-036).
Each op tombstones the relevant DEK in redaction_keys (overwriting wrapped_dek
with a zero sentinel + stamping shredded_at) so the sealed plaintext becomes
permanently unrecoverable, while the append-only version rows are left intact —
the fact then reads with redacted: true and null value leaves.
Minimum role: data_steward only — a dedicated, privileged role; admins do
NOT auto-hold it (separation of duties; ADR-036 Decision M). A
require_user_only route (#1428): service-class bearers are always 403; under
enforce_user_only_routes the bearer must be an exchanged per-target token
carrying the role. A blank reason is
400; the shred + a plaintext-free *.redacted audit event commit in one
transaction (ADR-018); re-redacting is idempotent (tombstones 0 rows, still 200).
POST /v1/persons/{id}/facts/{kind}/{fact_id}/redact
Redact one fact (kind ∈ income | asset | expense | address): shreds the
fact’s per-fact DEK, so every version of the fact reads redacted. Request:
RedactRequest ({ reason }). Response (200): { fact_id, kind, redacted_at }.
404 for an unknown kind or a fact_id not owned by the person; 400 on a blank
reason; 403 without data_steward. The sub-resource …/redact form is required
(axum/matchit 0.8 rejects the AIP-136 …:redact colon).
POST /v1/persons/{id}/redact-ssn
Redact a person’s SSN: shreds only the per-person SSN DEK — date_of_birth
(a separate DEK) and the person’s facts are unaffected (ADR-036 Decision D).
After redaction ssn_last_four reads null. Request: RedactRequest.
Response (200): { person_id, redacted_at }. 404 for an unknown person; 400
on a blank reason; 403 without data_steward.
Finalize control surface (ADR-038, MR2)
Internal, applications-only endpoints the finalize saga drives around its
X-Canopy-Finalize-*-tagged create/claim writes. All four
require a service token (require_service_caller) and specifically
canopy-applications (any other service → 403); a non-positive generation →
400. Every request/response is PII-free (operation/generation ids, opaque step
keys, entity-kind tags, and stable ids only).
POST /v1/internal/finalize-operations/{op}/{gen}/register
Open the (op, gen) generation gate (state = active) so tagged writes are
accepted; idempotent upsert. Response (200): RegisterResponse — an
already-cancelled generation reports cancelled (the caller must bump the
generation for a fresh attempt, never reuse it).
POST /v1/internal/finalize-operations/{op}/{gen}/release
Un-hold every event the operation staged (hold_operation_id = NULL) once the
application has committed, so the drainer publishes them. Idempotent. Response
(200): ReleaseResponse (released = rows un-held).
POST /v1/internal/finalize-operations/{op}/{gen}/cancel
Compensate an aborted operation, in one transaction: (1) mark the generation
cancelled (taking the row’s write lock, which serializes with the FOR SHARE a
tagged writer holds — a stale in-flight write commits its receipt first or is
refused); (2) re-inventory the receipts after the mark; (3) drop every still-held
event for (op, gen) (downstream never sees a compensated operation); (4) per
receipted entity, under a per-fact / per-person / per-household lock: crypto-shred
the inventoried DEK + deactivate an exclusively finalize-owned entity, or
quarantine (leave intact, record for a data steward) one a later non-finalize
write shares — the per-fact DEK is shared across a fact’s versions, so shredding a
shared fact would destroy the correction’s value. Quarantine is terminal and never
blocks the operation reaching aborted. Idempotent + resumable. Response (200):
CancelResponse (events_dropped, compensated[], quarantined[]).
Finalize-orphan compensation (ADR-038, MR9)
POST /v1/households/{household_id}/compensate-finalize-orphan
Compensate one pre-saga orphaned finalize graph — a household with a
finalize-authored self membership that no application references and no
finalize receipt covers (the pre-epic-&71 finalize_draft crash residue the
cargo xtask sweep-finalize-orphans tool discovers; see the
operator runbook).
Minimum role: data_steward (ADR-036 Decision M — a human steward surface,
NOT the applications-only service surface above; admins do not inherit it). A
require_user_only route (#1428) — same posture as the redact routes.
In one transaction, under the household advisory lock: verify the household
exists (else 404); verify the finalize self-membership provenance (else
409 — ambiguous graphs are steward-manual, never auto-shredded); inventory
every finalize-authored entity reachable from the household (memberships →
member persons → their finalize income/asset/expense facts); refuse if ANY
finalize receipt references the graph (409 — saga-era, owned by the
finalize reconciler’s cancel path); then compensate each entity through the
same shred-or-quarantine machinery as cancel — crypto-shred the inventoried
DEK + deactivate/supersede an exclusively finalize-owned entity, quarantine
(leave intact, report) one shared with non-finalize data, including a person
holding a current finalize membership in a different household. Idempotent —
a replay re-walks the graph and no-ops per already-compensated entity.
Response (200): OrphanCompensationResponse (household_id,
compensated[], quarantined[] — PII-free).
Persons Export
GET /v1/export/persons
User-only (admin or quality_control) bulk export with FOIA / portability disclosure modes (#1428: service bearers 403; enforced routes require an exchanged per-target token).
Minimum role: admin or quality_control (a require_user_only route)
Query parameters:
| Parameter | Meaning |
|---|---|
|
Inclusive start of the export window (RFC 3339 date-time). Defaults to 24h before |
|
Exclusive end of the export window (RFC 3339 date-time). Defaults to "now". |
|
Output format: |
|
Row cap, default 10 000, hard cap 50 000. |
|
Disclosure mode: |
|
Required when |
In foia mode each row is a FoiaPerson (year-only birth date, street redacted via FoiaAddress; names retained). In portability mode each row is a PersonWithAddresses (full Person joined to full Address rows for the single named subject).
Response (200): full or FOIA-redacted records (CSV or JSON per format). Returns 400 for an invalid window/mode or a missing person_id when mode=portability, and 403 when the caller lacks the admin role.
Error Codes
| Code | Meaning |
|---|---|
400 |
Validation failure (missing required fields, invalid enum values, invalid export window/mode, or missing |
401 |
Missing or invalid JWT |
403 |
Insufficient role (e.g. non-admin caller on |
404 |
Person, household member, income row, sub-resource, or (on redact) an unknown fact |
The current OpenAPI contract does not declare a 409 on any operation; duplicate-SSN and household-membership conflict handling is not surfaced as a distinct response code in the generated schema. Treat conflict semantics as 400 until/unless a 409 is added to the contract.
Events Published
-
person.created,person.updated,person.deleted -
household.created -
household.member_claimed,household.member_closed(T2-1 A2 #683) — attributed membership writes; replace the old non-attributedhousehold.member_added/household.member_removed(renamed, no aliases — pre-1.0, no backward compat) -
income.claimed,asset.claimed,expense.claimed(T1-5 #673) — attributed fact writes -
income.closed(T1-5 #673) — the D10 close primitive’s audit record -
fact.redacted,ssn.redacted(T2-6 #687) — a data steward crypto-shredded a fact value / a person’s SSN. Plaintext-free typed payloads (FactRedactedEvent/SsnRedactedEvent):{person_id, [kind, fact_id,] author, reason, redacted_at}— never the redacted value. Because the fact’s audit-ledgerbefore/afterare sealed under the same per-fact DEK (below), the persons redaction expunges the audit copy too — there is no cross-service fan-out (ADR-036 §5 as-built), so canopy-security simply recordsfact.redactedas an ordinary audit row. -
ssn.accessed(T2-6 #687, Pub-1075) — staged on every genuine plaintext SSN open (the 7persons_to_wiresites: create/get/list/update,:batchGet— only whenperson_ssnis projected (#1223 §B4), household-full, FOIA/portability export), one per decrypted person, fail-closed through the outbox. Plaintext-free typedSsnAccessedEvent:{person_id, actor_sub, purpose, source_service}wherepurposeis the enum{case_view, search, batch_lookup, foia, portability}; a redacted SSN readsNoneand fires nothing.
Person / household events carry IDs only — no PII (SSN, name, DOB) per ADR-004.
The fact events (T1-5) carry typed attribution + fact values in the payload
(ADR-027 §4 — the EventEnvelope is not extended with actor fields). A *.claimed
payload is {person_id, fact_id, version_id, author, claim_source, claim_status,
valid_from, valid_to?, before, after}: author is the internally-tagged actor
({"author_type":"worker","sub":…} / {"author_type":"applicant","household_id":…});
before is the complete set of superseded accepted windows (each {valid_from,
valid_to?, value}) — empty for a new fact, possibly many for a multi-window correction;
after is the new value. Since T2-6 MR9 (#687, ADR-036) the income/asset/expense value
leaves ride sealed: value.amount/value.value is a SealedDecimal and
employer_name/description a SealedValue envelope ({v,alg,dek_id,ct}) — the SAME
envelope persisted at rest, copied verbatim (the structural *_type/frequency stay
plaintext) — so the audit ledger holds ciphertext under the fact DEK and a redaction
expunges both copies in one shred. income.closed is {person_id, fact_id, author,
close_date, before} with author null (a service-to-service DELETE carries no worker
subject; the human actor awaits the ADR-019 on-behalf-of plumbing) and a non-empty
before (the event fires only when the close superseded ≥1 window). Raw identity
(SSN/name/DOB) and FTI/IEVS match data are never in a payload (ADR-027 §8 / ADR-004); the
canopy-mq publisher’s restricted-field guard is the backstop. canopy-security indexes
these by fact_id + the nested author.sub.