ADR-018: Report Attachments and Partner Signer Keys under ADR-017
On this page
Status
Accepted (2026-04-24). Implementation lands via three addenda before stateless-intake plan Step 6 (see Implementation scope below).
Decision drivers confirmed during review:
-
Multi-signer is compliance-mandated, not an ergonomic preference. The applicable mandatory-reporting statute identifies individual people as the accountable reporting party, not their organizations. A collapsed one-key-per-partner design cannot satisfy "Dr. Jones signed this at 14:03"; the statute requires it. Per-user
kid → pubkeyshape must be preserved. -
Attachments belong with their parent record regardless of where the upload originates. The Question "who receives the HTTP request" is separate from "who owns the data." Reports live in cases; attachments attach to reports; ownership follows. Every edge (craig-intake for partners, craig-web for caseworkers, future constituent/foster-parent/provider portals) is a peer forwarder to cases, each handling its own authn/validation. See Edge-forwarder pattern below.
Context
ADR-017 said "craig-intake becomes domain-stateless — the entire store/ module goes away." Implementation of Step 6 surfaced two tables that don’t fit the "just move reports to cases" framing:
1. intake.report_attachments
Metadata for files uploaded alongside a report: (id, report_id, file_name, content_type, file_size, object_key, uploaded_at). The object_key points at Garage/S3 (via craig-store / ADR-12-ish object-storage plumbing); the metadata row lives in the intake DB. Used by the public-report + partner-submit flows to attach medical records, photos, court papers, etc.
The problem: report_id FKs public_reports which, post-ADR-017 migration, is empty on the intake side. Reports live in cases.reports. An attachment’s report pointer is now dangling.
2. intake.signer_keys
Per-user JWS signing pubkeys for partner organizations (ADR-010):
CREATE TABLE signer_keys (
id UUID PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id), -- scope to a partner
user_identifier TEXT NOT NULL, -- the person signing
display_name TEXT NOT NULL,
public_key_jwk TEXT NOT NULL,
algorithm TEXT NOT NULL DEFAULT 'ES256',
key_id TEXT NOT NULL UNIQUE, -- the `kid` in the JWS header
status TEXT NOT NULL DEFAULT 'pending' -- pending / approved / revoked
...
);
The design is intentionally many-keys-per-partner: an API key represents an organization (hospital), while signer keys represent individual people within that organization (5 doctors with 5 keys sharing one api_key). JWS header carries the kid; intake verifies by kid → pubkey lookup.
ADR-017 Step 2 already added partner_api_keys.jws_public_jwk in craig-security, which is one pubkey per api_key — a strict collapse of the legacy shape. That’s a different product contract than the legacy design.
The problem: collapsing to one-pubkey-per-key loses per-user identity on signed requests. In child welfare, forensic traceability often wants "Dr. Jones signed this report at 14:03" — not just "Grady Hospital signed this report." The legacy multi-signer model supports that. The ADR-017 Step 2 collapse silently dropped it.
Forces
-
ADR-017 direction: intake should be domain-stateless. Keeping either table in intake preserves a DB and violates the architectural promise.
-
Per-user audit trail: state regulators + legal discovery frequently need the specific signer on a partner-submitted report, not just the organization. Georgia’s CICC process explicitly expects this.
-
Migration surface: we already shipped
partner_api_keys.jws_public_jwkin Step 2. Walking that back to a separate table means a second schema migration in craig-security. -
Edge-layer simplicity: whatever design we pick, craig-intake is a thin edge. Verification must be a fast path (under a millisecond in the hot cache case).
-
Partner developer experience: partners currently register multiple keys per api_key and include
kidin the JWS header. A breaking change to this API would force every partner to re-integrate.
Decisions
Attachments: move to craig-cases alongside reports
Create cases.report_attachments (same shape as the legacy intake table). FK report_id points at cases.reports. Every edge service forwards uploads to cases — see Edge-forwarder pattern below.
Rationale:
-
Attachments are per-report metadata. Ownership follows the parent record. Keeping them in intake while reports are in cases is exactly the data-dam pattern ADR-017 rejected.
-
The object-store blob stays where it is (Garage/S3). Only the metadata row moves.
-
craig-cases already owns the
Reportlifecycle and already exposes similar object-store-backed concerns (contact_attachments, court-order documents). The pattern is proven. -
A single cases endpoint is callable from any number of edges — craig-intake (partners), craig-web (caseworkers), future portals — without the awkward "cross-post into intake’s DB from an unrelated service" shape we’d get if attachments stayed in intake.
Migration: Step 5 (already shipped) didn’t carry report_attachments forward. An additive migration in cases creates the table; the existing migration tool (tools/craig-migrate-stateless-intake) gains an attachments pass that copies intake.report_attachments → cases.report_attachments with idempotency on (report_id, object_key). Blobs in Garage stay put — they’re addressed by object_key which doesn’t change.
Signer keys: move to craig-security, preserve the multi-signer shape
Create a new craig-security.partner_signer_keys table mirroring the legacy shape:
CREATE TABLE partner_signer_keys (
id UUID PRIMARY KEY DEFAULT uuidv7(),
partner_id UUID NOT NULL REFERENCES partners(id) ON DELETE RESTRICT,
user_identifier TEXT NOT NULL,
display_name TEXT NOT NULL,
public_key_jwk JSONB NOT NULL,
algorithm TEXT NOT NULL DEFAULT 'ES256',
key_id TEXT NOT NULL UNIQUE, -- `kid` in the JWS header
status TEXT NOT NULL DEFAULT 'approved',
approved_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_partner_signer_keys_kid_active
ON partner_signer_keys (key_id)
WHERE status = 'approved' AND revoked_at IS NULL;
Scope to partner_id (not api_key_id) so a partner can rotate their api_key without re-registering all their signer keys — a correctness improvement over the legacy api_key_id FK.
Roll back partner_api_keys.jws_public_jwk: this column was added in ADR-017 Step 2 as the one-pubkey-per-key shortcut. Remove it in favor of the new table. The existing attach_jwk endpoint on partner_api_keys is retired; new endpoints on partner_signer_keys replace it:
-
POST /v1/security/partners/{id}/signer-keys— register a new signer key -
GET /v1/security/partners/{id}/signer-keys— list (by partner) -
PUT /v1/security/partners/{id}/signer-keys/{key_id}/approve— admin approval -
PUT /v1/security/partners/{id}/signer-keys/{key_id}/revoke— revoke -
GET /v1/security/signer-keys/by-kid/{kid}— the hot-path lookup intake calls per signed request. Returns{ partner_id, user_identifier, display_name, pubkey_jwk, algorithm }or 404.
Intake’s PartnerAuthClient (from Step 6) gains a second lookup: after validating the API key, if the request carried a JWS header, it calls /signer-keys/by-kid/{kid} to fetch the pubkey. This lookup is cached in-process; ADR-045 (#747) later split that cache into a short positive (approved-key) TTL and a longer negative (404) TTL to bound the revocation-staleness window.
Migration: existing migration tool gains a signer_keys pass: intake.signer_keys → security.partner_signer_keys, resolving the legacy api_key_id FK to the corresponding partners.id via the legacy → security partner mapping established in Step 5. Idempotent via key_id UNIQUE.
Breaking-ish change: partner_api_keys.jws_public_jwk in Step 2 was Proposed/Accepted-but-unused; removing it before any production traffic is zero-impact. The attach_jwk endpoint has no external callers (craig-web admin UI is Step 7, still unshipped).
Edge-forwarder pattern
Recurring pattern introduced by ADR-017 and extended here: an edge service owns authn/authz + input validation for one class of user (partners, caseworkers, constituents, foster parents, providers, …). A back-office service owns the data and the business-rule invariants. Edge services forward validated requests to back-office services over HTTP; back-office services never know which edge a request originated from, only that the caller is authenticated for the resource.
For reports + attachments, today’s and near-future edges:
| Edge | Auth class it validates | Forwards to |
|---|---|---|
craig-intake |
Partner API key + optional JWS (ADR-010) |
cases |
craig-web |
Caseworker OIDC JWT (keycloak) |
cases + security |
Constituent portal (planned — phase 11) |
Constituent OIDC JWT |
cases |
Foster-parent portal (planned) |
Foster-parent OIDC JWT |
cases + placement |
Provider portal (planned) |
Provider OIDC JWT |
cases + exchange |
All of them hit the same POST /v1/cases/reports/{id}/attachments on craig-cases. Cases doesn’t care which edge called it — it trusts the service-to-service auth header and owns the invariant "attachments must reference a valid report." This is the same shape nova-api uses in OpenStack: many authenticators, one data owner.
The pattern generalizes. Any future "user class X wants to interact with domain Y" question has a default answer: edge for X, back-office for Y, HTTP forwarding between.
Alternatives considered
Keep attachments in craig-intake (reject)
Preserves the edge-upload model but means intake has a DB. Every argument for stateless intake (ADR-017) applies — this is the design-dam pattern. Rejected.
Collapse signer_keys to one-per-partner-key (reject)
The simpler model shipped in Step 2. Loses per-user signing identity, which is a real child-welfare forensic requirement. Forces partners to issue one api_key per signing user, fragmenting their integration. Rejected based on practitioner feedback (admin burden on partner orgs, audit-trail loss).
Move signer_keys to a new dedicated craig-signing-keys service (reject)
OpenStack-purist "one service per domain" answer. Premature for v0.1 — identity and signing keys are both authN/authZ concerns; craig-security owning both is the natural consolidation we already picked for partners. Spinning up a separate service adds deployment overhead without a clear payoff. Rejected for now; revisit if signer-key management grows enough surface to warrant it (rotation policies, HSM integration, federated signing, etc.).
Put signer_keys in the partner_api_keys table as a JSONB array (reject)
Would preserve multi-key-per-partner without a new table. But losing the relational kid → key index makes the hot path much slower, and per-row status (approved / revoked) becomes JSONB-patching. Rejected — small simplification isn’t worth the semantic mess.
Consequences
- Positive
-
-
Intake achieves full domain-stateless status — no DB at all for craig-intake. Matches ADR-017’s promise.
-
Attachments co-locate with reports — joined queries for full case materials happen in one service.
-
Per-user signing identity preserved — forensic + audit requirements met.
-
Signer-key scope to partner (not api_key) lets partners rotate keys without re-registering signers — correctness improvement.
-
craig-securityconsolidates identity + credentials + signing keys — consistent Keystone-analog.
-
- Negative
-
-
Two extra additive migrations:
cases.report_attachmentsandsecurity.partner_signer_keys. -
partner_api_keys.jws_public_jwkadded in Step 2 is retired before any use. Minor churn. -
tools/craig-migrate-stateless-intakegains two more passes (attachments + signer_keys). -
craig-intake’s attachment endpoints become proxies, adding one internal HTTP hop per attachment operation. Acceptable; same cost as the report read-through proxy.
-
- Neutral
-
-
Partner-facing JWS signing contract (presenting
kidin header + detached signature) is unchanged. No SDK work. -
Public-web attachment upload path is unchanged from the client’s perspective (POST multipart to intake); internal topology differs.
-
Implementation scope (adds to stateless-intake plan)
Does not create a new plan. The changes slot into the existing plan as scope additions on Steps 2 (post-hoc), 5 (migration), 6 (refactor), 7 (admin UI):
-
Addendum to Step 2 (craig-security) — add
partner_signer_keystable + endpoints; retirepartner_api_keys.jws_public_jwkcolumn. Ships as its own MR before Step 6. -
Addendum to Step 3 (craig-cases) — add
report_attachmentstable. Ships as its own MR before Step 6. -
Addendum to Step 5 (migration tool) — add
attachments+signer_keyspasses. Ships in the same MR as the Step 2/3 addenda or immediately after. -
Step 6 proper — now includes deleting
store/attachments.rs+store/signer_keys.rs+api/signer_keys.rsalongside the original scope. Intake fully stateless. -
Step 7 (craig-web) — partner-admin UI now also covers signer-key registration/approval/revocation.
Target date for the full ADR-018 rollout: Step 6 no longer lands until the three addenda ship. Steps 7–12 continue to depend on Step 6 as before.
Amendment — #786 (2026-07-18): approval transition is hard fail-closed
The PUT …/signer-keys/{key_id}/approve admin transition (and every other craig-security
authz gate) no longer falls back to a static admin role check on an authz-engine error: the
cold-start authz_check_or_admin_fallback helper is retired. An engine fault now surfaces as
500 (never an admit), and a healthy engine’s deny is authoritative. The S2S hot paths intake
depends on (partners/verify, signer-keys/by-kid) fail closed with the same honest classes
(fault → 500 instead of the former 403 masquerade).