ADR-017: Stateless Intake — craig-cases Owns Report Lifecycle, craig-security Owns Partner Identity

On this page

Status

Accepted (2026-04-22). Implementation tracked in the stateless-intake plan.

Supersedes ADR-016 (Mooted).

Related: ADR-003 (RabbitMQ topology — unchanged role), ADR-004 (BFF pattern — craig-web composes views across services), ADR-005 (Keycloak OIDC — caseworker auth unchanged), ADR-008 (jurisdiction form config — workflow configurability expands), ADR-009 (mobile offline — precedent for local-outbox pattern), ADR-010 (JWS integrity — pubkey lookup relocates), ADR-014 (shared reqwest client — intake’s forwarder reuses).

Context

craig-intake today is a stateful service: it holds an intake.reports table, runs a screening state machine (pending / screened_in / screened_out / converted), stores a referral_id back-pointer to craig-cases after conversion, and runs a 96-line synchronous cross-service HTTP handler (POST /api/internal/reports/{id}/convert) to coordinate the two services' state transitions. Partner identity — API keys, optional JWS public keys (ADR-010), rate limits — lives in the same intake DB in integrated mode, or in a JSON config file (ConfigApiKeyLookup) in standalone mode.

Three forces pushed a re-examination of this shape:

1. The state intake holds is a mirror, not a source of truth

Every terminal or near-terminal state intake tracks (screened_in, converted, referral_id) reflects a decision or outcome that belongs conceptually to case-management work. Intake’s converted status only exists to echo what craig-cases just did. Its referral_id is a pointer to a record cases owns. Its screening disposition is a decision caseworkers make in a caseworker-facing workflow, not an intake-side concern.

Use-case audit:

Use case Needs intake-side state?

Prevent double-conversion

No — craig-cases can enforce via unique constraint on referrals.intake_report_id

"Show me this report’s disposition" in intake UI

No — query cases with the report id

Intake dashboard ("N reports converted this week")

No — query cases; it’s the authoritative event

Audit trail of the conversion

No — the conversion IS the referral creation; audit belongs in cases

AFCARS / NCANDS reporting export

No — cross-service join at export time; craig-reporting already aggregates

Caseworker notes on the conversion

No — belong on the referral, not the intake report

No load-bearing use case for intake-side state survived the audit.

2. Practitioner feedback described a chain, not two silos

Frontline/administrative feedback during ADR-016 review was unambiguous: the work is a chain. An intake’s meaning depends on prior intakes for the same family, the cases those led to, and cross-family relationships. Staff need to see the chain in one place — "data lake, not data dam." Specific points:

  • NCANDS federal reporting requires screened-out data in the system of record, not just screened-in data. Any split that pushes screened-out into a separate store creates an export-time cross-service join for regulated data.

  • Disposition is not binary. There are at least four disposition paths (screened-in, screened-out, screened-out-and-referred, Information & Referral), three of which involve ongoing caseworker action after the disposition is recorded. None of these can live in a separate store from case-management work without forcing staff to bounce between systems.

  • Multi-party decision chains exist (e.g., Georgia’s CICC initial decision followed by county override). A single record needs to be editable by multiple parties with appropriate authority over time — classic single-source-of-truth requirement.

  • Partners submit; they do not curate. Partner hospitals, schools, and law enforcement don’t need access to the chain. Their interaction ends at "report submitted." That means the partner-facing API surface and the caseworker-facing data model have genuinely different users and can legitimately live in different services.

3. OpenStack analogy: edge-tier vs data-tier

CRAIG’s stated architectural north-star is the OpenStack project (each service independent, HTTP/RMQ communication, Keystone-centralized identity). Through that lens, today’s intake plays a confused role:

  • Its partner-facing edge concerns (JWS signature verification, API key auth, CAPTCHA, rate limiting, schema validation, idempotency) are analogous to OpenStack’s nova-api — a stateless request-handling tier.

  • Its data concerns (reports table, state machine, audit trail) are analogous to OpenStack’s nova-conductor — a stateful data-owning tier.

In OpenStack these two tiers are processes within the same service (Nova), sharing a schema and codebase, scaled independently via deployment topology. CRAIG has them as two separate services with an artificial data seam between them.

Two ways to resolve the confusion: (a) merge intake into cases as one service with an edge-tier process and a data-tier process (service consolidation); (b) keep intake as a separate service but remove its data tier entirely, making it a pure edge (domain-stateless). Option (a) is a larger refactor and blurs partner integration concerns (auth, rate limiting, JWS) into case management. Option (b) preserves partner integration as a discrete service surface while fixing the data-ownership confusion.

This ADR adopts option (b).

Decision

Top-level shape

  1. craig-intake becomes domain-stateless. No reports table. No screening state machine. No converted/referral pointers. Intake is a partner-facing HTTP edge service that validates (schema, CAPTCHA, rate limit, API key, optional JWS per ADR-010), authenticates partners against craig-security, and forwards validated submissions via HTTP to craig-cases.

  2. craig-cases owns the full report lifecycle. The reports table relocates to cases. Screening, disposition (four paths), decision chain for multi-party overrides, conversion to referral, investigation, and case become internal state transitions within a single service — atomic, single-source-of-truth, no distributed-transaction surface.

  3. craig-security owns partner identity. Partner organizations, API keys (hash only), optional JWS public keys (ADR-010), and rate limits live in craig-security alongside the existing caseworker/admin-unit identity. Craig-security is CRAIG’s Keystone-analog.

  4. Events remain post-persistence notifications on craig.events (ADR-003). No events on the write path. Cases publishes case.report_submitted, case.report_disposition_recorded, case.referral_created, etc. after the DB commit, same pattern cases already uses for its other publications.

Intake data shape (domain-stateless)

Intake’s process holds, in RAM or via externalized stores:

  • JWKS cache (for caseworker JWT validation, via craig-auth library — unchanged)

  • Idempotency-key response cache (via the existing idempotency middleware per api-idempotency plan — externalized, not intake-owned state)

  • Short-TTL (~60 s) in-process cache of partner credentials fetched from craig-security — derived, not authoritative; loss re-fetches transparently

  • Rate-limiter state (governor crate — in-process token buckets keyed on partner id)

No domain tables. No schema migrations. No backup/restore. No domain state to lose.

Write path

Partner / SDK / craig-web  ──POST /reports──►  craig-intake
                                                  │
                                                  ├─ schema validation, CAPTCHA, rate limit,
                                                  │  partner auth (hits craig-security),
                                                  │  optional JWS verification (pubkey from craig-security)
                                                  │
                                                  ├─ HTTP POST to craig-cases /v1/cases/reports
                                                  │     with authenticated-partner context in a
                                                  │     service-to-service signed header
                                                  │
                                                  └─ return cases' response verbatim (201 + report_id)

craig-cases ──── single-service transaction ────►
  ├─ INSERT reports
  ├─ INSERT screening_decisions (initial disposition if one was supplied inline; else deferred)
  ├─ emit case.report_submitted on craig.events
  └─ return 201 { id, submitted_at, ... }

The write path is a single HTTP request/response from the caller’s perspective and a single DB transaction on the cases side. No eventual consistency, no polling, no 202.

Read path

GET /reports/{id} on intake becomes a read-through proxy to cases, preserving the existing partner-facing API contract. Rationale:

  • Partners (and the SDKs) shouldn’t need to know about CRAIG’s internal service topology to look up a report they submitted.

  • Keeps intake’s partner-facing API surface intuitive and self-contained.

  • The proxy is stateless; no cache is required for correctness (stale reads on status transitions are acceptable within an interactive polling window, but a short-TTL cache is deferred as an optimization — see open questions).

GET /reports list endpoints on intake are removed — they were only meaningful for intake-side admin dashboards that don’t exist in the stateless model. Case-management UI (caseworker-facing) queries cases directly through craig-web.

Disposition and decision chain (cases-side)

Cases' schema accommodates the four disposition paths and multi-party decision chains via neutral terms; Georgia’s CICC/county workflow is expressed as a craig-rules ruleset (ADR-008), not baked into schema.

Proposed neutral tables (final column shape deferred to implementation):

Table Purpose

reports

The report itself (reporter, narrative, children, adults, submission metadata, partner_id). Immutable body after creation.

screening_decisions

Decision chain. (report_id, actor_role, disposition_kind, decided_at, rationale, supersedes_decision_id). Multiple rows per report when overrides occur. Current effective disposition is a computed view over the chain.

disposition_follow_ups

Optional per-disposition actions. For "screened-out-and-referred" and I&R paths that involve caseworker action despite being screened-out at the disposition level. (decision_id, action_kind, target_provider, notes, performed_at, performed_by).

referrals

Existing. Gains intake_report_id (optional, unique-not-null for conversions from a report) and a FK back to the screening decision that authorized the conversion.

disposition_kind is a jurisdiction-configurable enum sourced from the jurisdiction ruleset, not a hardcoded Rust enum. Valid transitions, authority-to-override, and disposition → follow-up mappings all live in craig-rules.

NCANDS export pulls from reports + the effective-disposition view + disposition_follow_ups in a single service. No cross-service join.

Partner identity (craig-security)

New tables in craig-security (additive; caseworker/admin tables unchanged):

Table Purpose

partners

Partner organization record. (id, name, kind (hospital / school / LE / other), status (active / suspended), contact, created_at, rate_limit_rpm).

partner_api_keys

API key hashes. (id, partner_id, key_hash, label, created_at, revoked_at, last_used_at, jws_public_jwk (optional)). Hash only; plaintext never stored. JWS pubkey co-located so a single lookup returns everything intake needs for either auth mode.

New craig-security endpoints for partner admin:

  • POST /v1/security/partners — create partner (admin-only)

  • GET /v1/security/partners — list partners (admin-only, paginated)

  • POST /v1/security/partners/{id}/keys — issue a new API key; returns plaintext ONCE

  • DELETE /v1/security/partners/{id}/keys/{key_id} — revoke

  • POST /v1/security/partners/{id}/keys/{key_id}/jwk — attach/rotate JWS pubkey

  • POST /v1/security/partners/verify — authenticate an inbound credential; returns { partner_id, rate_limit_rpm, jwk? } or 401. This is the endpoint intake hits on every request.

craig-web gains partner-admin pages peer to the existing user-admin pages, both calling craig-security.

Standalone / air-gapped partner deployments

Two configurations share one intake binary and one codepath:

  1. Integrated / network-reachable standalone (most common): intake is configured with CRAIG_SECURITY_URL=…​ and authenticates each inbound request by calling craig-security’s verify endpoint (with the 60 s in-process cache for throughput). Same codepath whether the craig-security instance is the state-central one or a partner-local one.

  2. Fully air-gapped standalone (rare — partner with no network path to any auth service): intake loads partner_keys.json at startup, same shape as today’s ConfigApiKeyLookup. Admins rotate by re-deploying the config. This is the escape hatch for genuinely disconnected partners.

Both modes implement the same PartnerAuthLookup trait; selection is configuration, not a codepath fork. Partner-facing business logic is identical. Standalone mode no longer implies "intake owns a DB" — it implies "intake is configured with a local auth source."

JWS integrity (ADR-010) — unchanged semantics, relocated plumbing

Intake continues to verify detached JWS signatures on submissions when the partner has opted into signing (ADR-010). The public key comes from craig-security (returned inline by the partners/verify call), not from intake’s own DB. Signature verification, canonical payload hashing, and rejection semantics are unchanged. Partners experience no contract change on the signing path.

Client / SDK impact

Surface Change

Partner Python SDK (sdks/python/craig_intake)

None. client.submit_report() POSTs to the same endpoint with the same signature. Response shape is 201 + report id, same as today.

Partner TypeScript SDK (sdks/typescript/src/client.ts)

None.

craig-cli craig intake submit

None.

craig-web public-report form

None (the BFF path is unchanged; craig-web still POSTs to intake).

craig-web caseworker screening/review/convert UIs

These pages move from hitting intake’s API to hitting cases' API (since cases now owns the reports domain). Internal refactor of BFF routes; no public-API surface change.

E2E tests

Update the intake-convert and screening specs to assert against cases (where the reports now live). No change to public-submission specs.

Events

Post-persistence notifications remain on craig.events. Cases publishes:

  • case.report_submitted — on intake acceptance

  • case.report_disposition_recorded — on each screening decision (supports the decision-chain model; one event per decision, not per final state)

  • case.report_converted — on conversion to referral (renamed from intake.report_converted; name normalizes to case-namespace)

  • case.referral_created — existing; unchanged

Intake publishes nothing. It’s a pure HTTP-to-HTTP tier.

Alternatives considered

Keep the current design (Option 0 — rejected)

The four structural problems catalogued in ADR-016 (no distributed atomicity, availability coupling, brittle JWT forwarding, no backpressure) remain unaddressed. Additionally, the data-dam problem identified by practitioner feedback (NCANDS retention, disposition workflow, chain visibility) is not fixed.

Event-driven coordination (ADR-016 — rejected / mooted)

Addresses the four structural problems but preserves the fundamental assumption that intake holds report state. The resulting design requires distributed coordination, polling contracts, DLQ monitoring, reconciliation tooling, and a new "converting" intermediate state — all infrastructure to keep two stores in sync. Mooted once the "intake doesn’t need state" direction was identified.

Merge intake into cases (Option A — rejected for this ADR)

The most OpenStack-pure version: delete intake as a service, make it a process-tier within cases (cases-api-public process serves partner traffic; cases-api-internal process serves caseworker traffic; both share cases' codebase and DB). Eliminates one service entirely.

Rejected because: * Conflates partner integration concerns (JWS, API keys, CAPTCHA, rate limiting) with case-management concerns in a single codebase. Test harness, deployment configuration, and blast radius all grow. * Partner integration is a real, stable product surface with its own lifecycle (partner onboarding, credential rotation, external-auditor scrutiny). It warrants its own service boundary for operational and security reasons even though it no longer has domain data. * Larger refactor with more migration surface, for marginal architectural benefit over Option B.

Dedicated craig-partners service for partner identity (Option C — rejected)

A fourth service (craig-partners) owning partner organizations and credentials, separate from craig-security. Rejected because identity is identity — internal caseworker identity and external partner identity are both "who is this actor and what can they do?" questions. craig-security already answers that for caseworkers/admin-units; extending to partners is scope expansion in the natural direction, not mission creep. Splitting them would be premature specialization.

Consequences

Positive

  • Single source of truth. Report lifecycle lives in one service, one schema, one transaction boundary. Atomicity problems dissolve.

  • Chain visibility for free. Staff querying a family’s history across intakes, referrals, investigations, and cases run joins within cases' DB. No cross-service reads, no BFF-side merging.

  • NCANDS export is a single-service query. All four disposition paths live together.

  • Jurisdictional configurability is strengthened. Disposition kinds, override authorities, and workflow transitions become craig-rules data, not Rust source. Georgia’s CICC/county model is one ruleset among many.

  • Intake scales trivially. No DB connection pool, no schema migrations, no replication.

  • One codepath for standalone and integrated. No business-logic fork. Differences collapse to ApiKeyLookup backend selection.

  • Partner SDKs are unchanged. Public API surface on intake is stable.

  • Partner admin is consolidated. Partner onboarding, key rotation, JWS pubkey management live alongside user admin in craig-web, calling one auth service (craig-security).

  • ADR-016’s four structural problems are resolved — atomicity (single-service transaction), availability coupling (intake → cases is one HTTP call, same availability model as any other write; not worse than today’s convert flow), JWT forwarding (replaced by craig-security-sourced partner credentials), backpressure (cases' connection pool provides natural limiter; adding a queue would re-create the DLQ problem we rejected).

Negative

  • Standalone partner-hospital availability regresses by default. Today, a partner running intake+DB can accept reports when central CRAIG is unreachable (they queue locally, forward later). Stateless intake with an HTTP sink to a remote cases returns 503 if cases is unreachable. Mitigation requires a local-outbox pattern (see open questions).

  • Write-path availability is now fully coupled to cases' availability for integrated deployments. If cases is down, no submissions succeed. Today’s submission path already writes to intake’s DB, which has similar coupling characteristics — but the failure mode moves from "intake DB down" to "cases DB down."

  • Read-through proxy latency. GET /reports/{id} on intake adds one internal HTTP hop. Sub-millisecond in practice for service-to-service calls within a cluster; acceptable.

  • Migration work is non-trivial. Existing intake.reports rows must migrate to cases. Existing partner API keys must migrate to craig-security. Both are one-time, scriptable, idempotent migrations — but they are real data migrations, not code-only changes.

  • Deployment ordering matters. During rollout, we need cases to be deployed with the new reports tables before intake cuts over to the new forwarding sink. Standard forward-migration discipline.

Neutral

  • Event semantics stabilize. Events become strictly post-persistence notifications. This matches how events are used elsewhere in CRAIG and eliminates the ambiguity of mid-flow coordination events.

  • Service naming. "Intake" as a service name remains accurate — it’s the intake (ingress) point for partner submissions. The implementation is thinner, but the concept is unchanged.

  • Scope of craig-cases grows. Cases absorbs the reports domain. Its surface grows from 46 endpoints to ~55 (rough estimate pending design of the screening endpoints), and its table count from 13 to ~17. Still the largest service; incrementally so.

Open questions (deferred to implementation, not blockers to accepting direction)

  1. Local-outbox pattern for partner-hospital standalone deployments. Does the availability regression for air-gapped/flaky-link partners warrant adding an optional local append-only buffer with background forwarding? If yes: what’s the retention policy; how is backpressure surfaced to the partner’s UI; who monitors the outbox depth? If no: how do we document the degraded-mode behavior for partners whose networks cannot guarantee reachability? Decision required before implementation. Precedent: ADR-009.

  2. Information & Referral (I&R) modeling. Is an I&R call a report-with-disposition-kind-i_and_r, or a distinct entity routed to a different endpoint and table? Practitioner input suggests I&R and screened-out-and-referred share enough workflow structure (light intervention, external-provider referral) that they may be sibling dispositions in the same table. Needs SME input before schema finalization.

  3. Migration strategy for existing intake.reports data. One-shot migration vs. dual-write during a transition window. Recommended: one-shot, since the system has only one production deployment and data volume is small, but worth confirming against backup/rollback preferences.

  4. Read-through proxy caching on intake. For throughput on repeated GET /reports/{id} calls (e.g., partner polling for async operations elsewhere), should intake add a short-TTL cache for the proxied responses? Deferred as an optimization; default is no cache for correctness.

  5. Decision-chain schema shape. Exact column list for screening_decisions and disposition_follow_ups (surrogate keys, nullable rationale, enum constraints, etc.) is an implementation-time concern. The neutral shape sketched above is indicative, not final.

  6. Deprecation window for intake.report_converted event. Resolved at implementation time: pre-v0.1, no live consumers, so the event was simply renamed in one step (Step 4 + Step 10) with no dual-publish.

  7. Rate limiting boundary. Rate limits are partner-scoped. Does the limit apply per-intake-instance or globally across all intake instances? If globally, we need a shared store (Redis or similar); if per-instance, partner burst tolerance depends on deployment shape. Current behavior is per-instance; preserving that is the simpler default. Resolved — see the Amendment below (#269).

Amendment — #269: rate-limiting boundary resolved as per-instance (2026-08-14)

Open question 7 is DECIDED: rate limits are per-instance, recorded as-built at services/craig-intake/src/api/rate_limit.rs:42-49 (#1136, epic &71) — per-process token buckets, each replica’s bucket filling independently, so a partner’s configured rpm multiplies by the replica count cluster-wide (rpm=60 × 4 replicas ⇒ 240 rpm effective). That is acceptable for the upper-bound abuse-defense use these limits serve; the same posture applies to the per-IP public limiter (craig_common::rate_limit::RetainingIpLimiter).

The shared-store alternative is not merely deferred — it is architecturally foreclosed independent of deployment scale: Redis was rejected in ADR-013 and that rejection is reaffirmed by ADR-029 and ADR-022 §E. A deployment needing precise global quotas would front intake with an edge/gateway limiter rather than adding a shared store to CRAIG. Operators size limits with the multiply-by-replicas semantics in mind (see the deployment guide’s scaling notes).

Implementation scope (sketch — detailed plan is a separate artifact)

This ADR supersedes the craig-intake-sink-convert scope of code-quality-review-2026-04.adoc Step 5b (which assumed coordination between two stateful services). A fresh implementation plan will enumerate the steps.

Expected shape, in approximate rollout order:

  1. craig-security partner identity tables + admin endpoints + /partners/verify endpoint — additive to craig-security; no breaking changes.

  2. craig-cases reports/screening_decisions/disposition_follow_ups tables + POST /v1/cases/reports + internal screening/disposition/convert endpoints — additive to cases; no breaking changes. intake_report_id on referrals becomes non-optional for the report-derived path.

  3. Data migration: intake.reportscases.reports, intake’s partner key store → craig-security.partner_api_keys. One-shot migrations with idempotency guards.

  4. craig-intake refactor: drop DB, drop Sink switchboard, add HTTP forwarder to cases, add partner auth via craig-security. Read-through proxy for GET /reports/{id}. The intake binary shrinks significantly.

  5. craig-web updates: partner-admin pages in craig-web BFF; caseworker screening/review/convert UI routes re-pointed at cases; unified report+case chain view.

  6. Event renameintake.report_convertedcase.report_converted (one-shot; no live consumers to dual-publish for).

  7. Plan archival — code-quality-review-2026-04 Step 5b closed out as superseded; this ADR’s implementation plan tracks the work going forward.

Each of these lands as its own MR; the migration (step 3) should be dry-run-able against a snapshot before the cutover.

Edit this page · latest