Data Export API (FOIA, audit, portability)

On this page

Overview

Three bulk-export endpoints satisfy data-disclosure obligations:

  • GET /v1/export/audit-events (canopy-security) — admin audit dump covering Pub 1075 §9 access-log review and CMS-aligned audit-trail requests. No PII concerns; the audit log itself is metadata about who read what.

  • GET /v1/export/persons (canopy-persons) — FOIA disclosure (default) or citizen-data-portability (mode=portability). Two distinct disclosure shapes; see Person-record FOIA redaction.

  • GET /v1/export/determinations (canopy-snap) — bulk SNAP determinations for federal reporting cross-checks and quality-control case sampling.

All three require the admin role (the plan specifies "admin or quality_control"; the QC role is not yet implemented — it’ll OR-into the existing guard when added). Each export call publishes a *.export.requested event so the export operation itself is captured in the audit chain — the wildcard subscriber persists it like any other audit event.

Person-record FOIA redaction

mode=foia (default) projects the Person record to a public-records- safe shape:

Field Released? Rationale

id, first_name, last_name

Names appear in court orders / public records; not exempt under the Georgia Open Records Act personal-privacy carve-outs.

birth_year

✓ (year only)

Full DOB is exempt under the personal-privacy carve-out; year-only is the standard de-identification for actuarial / demographic disclosure.

language_preference

Not personally identifying.

active, created_at

Operational metadata, not PII.

ssn_last_four, date_of_birth (full), gender, race, ethnicity, citizenship_status, disability_status, middle_name, suffix, updated_at

Personal-privacy exemption (Georgia OCGA 50-18-72(a)(20) and HIPAA-adjacent disability/race fields). The Person API model already truncates SSN to last-4; the FOIA shape drops it entirely.

mode=portability&person_id=<uuid> is the citizen-data-portability shape: full record returned for the named data subject only. Used when an applicant exercises their right to receive their own data — not for public records requests.

mode=portability requires person_id (no bulk portability dump).

Address join

Both modes return the person’s active addresses joined to the record (#347, shipped 2026-05-04):

Field FOIA released? Rationale

address_type, city, state, zip, county_fips

City/state/zip survive at the FOIA level — these are part of the public address record (voter registration, court filings) and the county FIPS code is jurisdictional metadata, not personal.

line_1, line_2

Street address is exempt under the personal-privacy carve-out; an attacker combining street + birth_year + name would have material to re-identify.

effective_date, end_date

Timing-sensitive metadata that fingerprints a record even after PII is removed (e.g. a unique move-in date).

id, created_at, updated_at

✗ (FOIA only)

Implementation IDs / audit timestamps. Released in portability mode where the data subject is entitled to their full record.

In portability mode every Address column is released — full street, unit number, dates, and the address row’s identifier. Callers can re-issue the same export and detect address changes by ID.

JSON vs CSV shape for multi-address persons

Per the issue’s AC 2 design decision:

  • JSON: addresses nest as addresses: […​] on the person object. A person with N addresses gets an N-element array; a person with no active addresses gets an empty array. JSON consumers traverse via the nested path and don’t need to dedupe by person.id.

  • CSV: flattened to one row per (person, address) pair. Person columns repeat across that person’s address rows. A person with zero addresses still emits one row with empty trailing address columns — we never lose person data from the export, even when the address join is empty. CSV consumers that want one person row should GROUP BY on id after import.

Audit-event export

GET /v1/export/audit-events returns the raw audit chain within the caller-specified window. No redaction: the audit log is metadata about who accessed what, not the protected data itself.

Expected callers: federal auditors during the annual Pub 1075 §9 review, CMS for HIPAA breach analysis, internal QC for compliance investigations.

Determinations export

GET /v1/export/determinations returns SNAP determinations finalized within the requested window. Includes benefit_amount, status, signature (the signed JWS proving determination authenticity per ADR-002), and program_service_version.

Expected callers: FNS-QC sampling, federal reporting cross-checks against canopy-reporting’s FNS-388 / FNS-7176 outputs, internal audit sampling.

Determinations carry no SSN or DOB and are not subject to the Person- record FOIA redaction calculus. Callers combining determinations with PII for cross-program reporting are responsible for downstream redaction.

Common request shape

All three endpoints accept the same query parameters:

Param Meaning

from

Inclusive start of the window (RFC 3339 / ISO 8601). Defaults to 24 hours before to.

to

Exclusive end of the window. Defaults to "now".

format

json (default) or csv. Equivalent to setting Accept: application/json or Accept: text/csv. The query param wins when both are set with conflicting values.

limit

Row cap. Default 10 000, hard cap 50 000. Consumers needing more paginate by re-querying with a different from / to.

mode (persons only)

foia (default) or portability. portability requires person_id.

person_id (persons only)

Required when mode=portability. UUID of the data subject.

Responses

  • 200 OK with body in the requested format. CSV responses carry a Content-Disposition: attachment; filename="…​" header for browser-driven exports.

  • 400 Bad Request for invalid time windows (from >= to) or missing person_id in portability mode.

  • 403 Forbidden for non-admin callers.

Audit of the export

Every export call publishes one of:

  • audit.export.requested (canopy-security)

  • persons.export.requested (canopy-persons; carries mode field and person_id in portability mode)

  • snap.export.requested (canopy-snap)

with payload fields actor, from, to, format, row_count. The canopy-security wildcard subscriber persists each event into the audit chain. Querying the same chain to trace export activity is itself an export — recursive but consistent.

A failure to publish the *.export.requested event is logged at WARN level but does not fail the request: the caller is already authenticated as an admin, the data is visible elsewhere, and the persisted chain still holds the original audit events. Losing the per-export audit row is degraded-but-not-catastrophic.

Implementation references

  • services/canopy-security/src/api/export.rs

  • services/canopy-persons/src/api/export.rs

  • services/canopy-snap/src/api/export.rs

  • Plan: Operational Infrastructure — Step 11

Edit this page · default