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 → pubkey shape 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.

Related: ADR-017 (stateless intake direction), ADR-010 (partner JWS integrity — the legacy signer-keys design).

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_jwk in 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 kid in 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 Report lifecycle 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_attachmentscases.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_keyssecurity.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-security consolidates identity + credentials + signing keys — consistent Keystone-analog.

Negative
  • Two extra additive migrations: cases.report_attachments and security.partner_signer_keys.

  • partner_api_keys.jws_public_jwk added in Step 2 is retired before any use. Minor churn.

  • tools/craig-migrate-stateless-intake gains 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 kid in 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):

  1. Addendum to Step 2 (craig-security) — add partner_signer_keys table + endpoints; retire partner_api_keys.jws_public_jwk column. Ships as its own MR before Step 6.

  2. Addendum to Step 3 (craig-cases) — add report_attachments table. Ships as its own MR before Step 6.

  3. Addendum to Step 5 (migration tool) — add attachments + signer_keys passes. Ships in the same MR as the Step 2/3 addenda or immediately after.

  4. Step 6 proper — now includes deleting store/attachments.rs + store/signer_keys.rs + api/signer_keys.rs alongside the original scope. Intake fully stateless.

  5. 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).

Edit this page · latest