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

http://localhost:8002/v1

Authentication

Bearer token (Keycloak RS256 JWT)

Minimum role

caseworker

Swagger UI

http://localhost:8002/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; under CANOPY_PERSONS__ENFORCE_USER_ONLY_ROUTES=true only an exchanged per-target token (aud=canopy-persons exactly) carrying the named role passes.

  • The azp allowlist is DELIBERATELY canopy-web-exchanger only (least privilege — persons is not in the orchestrator’s EXCHANGE_TARGETS, so no other exchanger legitimately mints persons-audience tokens).

  • Attribution via EffectiveUser on 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 --apply and the canopy-cli crypto-shred commands RFC 8693-exchange the minted/stored bearer for aud=canopy-persons before 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

X-Canopy-Finalize-Operation

The finalize operation id (the reserved application id), a UUID.

X-Canopy-Finalize-Generation

The operation generation (integer ≥ 1; bumped on each aborted re-submit).

X-Canopy-Finalize-Step

The caller’s opaque per-step receipt key (e.g. person:0, income:2).

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.

GET /v1/persons/{id}

Get a single person by ID.

PUT /v1/persons/{id}

Update a person’s demographic data.

DELETE /v1/persons/{id}

Delete a person.

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: BatchGetHouseholdsRequestas_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 inputssource (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.

NOTE
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 (kindincome | 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[]).

GET /v1/internal/finalize-operations/{op}

A PII-free view of the operation’s generations + receipts for the reconciler. Response (200): FinalizeOperationView.

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

from

Inclusive start of the export window (RFC 3339 date-time). Defaults to 24h before to.

to

Exclusive end of the export window (RFC 3339 date-time). Defaults to "now".

format

Output format: json (default) or csv.

limit

Row cap, default 10 000, hard cap 50 000.

mode

Disclosure mode: foia (default, redacted) or portability (full record; requires person_id).

person_id

Required when mode=portability. Restricts the export to the named data subject only.

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 person_id in portability mode)

401

Missing or invalid JWT

403

Insufficient role (e.g. non-admin caller on GET /v1/export/persons; the redact / redact-ssn ops require data_steward)

404

Person, household member, income row, sub-resource, or (on redact) an unknown fact kind / fact not owned by the person

NOTE

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-attributed household.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-ledger before/after are 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 records fact.redacted as an ordinary audit row.

  • ssn.accessed (T2-6 #687, Pub-1075) — staged on every genuine plaintext SSN open (the 7 persons_to_wire sites: create/get/list/update, :batchGet — only when person_ssn is projected (#1223 §B4), household-full, FOIA/portability export), one per decrypted person, fail-closed through the outbox. Plaintext-free typed SsnAccessedEvent: {person_id, actor_sub, purpose, source_service} where purpose is the enum {case_view, search, batch_lookup, foia, portability}; a redacted SSN reads None and 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.

Edit this page · default