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

On this page

Plan adopted 2026-04-22 from ADR-017. Supersedes code-quality-review-2026-04.adoc Step 5b (which assumed two stateful services coordinating). The scope is substantially larger than Step 5b envisioned — that step is closed as superseded when Step 12 of this plan lands.

Status

Status column reflects plan adoption at 2026-04-22.

Step Description Status

1

Plan file + ADR-017 status flip + GitLab issue hygiene

Done (2026-04-23) — MR !137 — plan and ADR drafted, ADR-017 status flipped to Accepted, parent issue + per-step issue tree created.

2

craig-security: partner identity schema + admin CRUD + /partners/verify endpoint

Done (pre-ADR-030) — migration 20260423100000_partner_identity.sql + 10 endpoints + 3 events + 14 integration tests. 132/132 craig-security tests pass.

3

craig-cases: reports + screening_decisions + disposition_follow_ups schema + migrations

Done (pre-ADR-030) — migration 20260423110000_reports_and_screening.sql (3 tables + v_effective_disposition view + referrals.intake_report_id + referrals.authorizing_decision_id columns) + 4 model types + 13 CRUD helpers across 3 store modules. ADR-018 addendum adds report_attachments table + store + 4 endpoints (upload/list/download/delete) + 9 integration tests + case.report_attachment_uploaded event. 138/138 craig-cases tests pass.

4

craig-cases: report/screening/convert endpoints (write path + internal transitions)

Done (pre-ADR-030) — 8 new endpoints under Reports tag (POST/GET list, GET detail with effective disposition + follow-ups, decision chain, record-decision with override authority, follow-up create/list, convert). 3 new events (case.report_submitted, case.report_disposition_recorded, case.report_converted). 21 new integration tests covering submission, idempotency on duplicate request_id, multi-party override authority (county_supervisor over cicc), override rejection (caseworker can’t override), invalid enum values, cross-report supersedes rejection, follow-up CRUD on screened-out-and-referred path, convert idempotency, convert-from-screened-out rejected, authz gate. 129/129 craig-cases tests pass. Referral struct gained intake_report_id + authorizing_decision_id fields. Georgia allowlists (actor_role, disposition_kind, follow-up action_kind, override-authority rule) pinned as inline constants; craig-rules integration deferred to follow-up #213.

5

Data migration: intake reports → cases; intake partner keys → craig-security

Done (2026-04-25) — Removed — pre-v0.1 system, no production data exists. The tools/craig-migrate-stateless-intake binary (delivered then deleted) covered fresh-apply + re-apply of intake.public_reports → cases.reports, intake.api_keys → security.partner_api_keys, intake.report_attachments → cases.report_attachments, intake.signer_keys → security.partner_signer_keys per ADR-018. Maintaining a migration pathway for hypothetical future data was deemed worse-than-the-rewrite once Step 6 retired intake’s DB.

6

craig-intake refactor: drop DB, add cases-forwarding sink, partner auth via craig-security, read-through proxy

Done (pre-ADR-030) — intake retired its DB layer (deleted src/store/, src/transitions.rs, src/confirmation.rs, src/events.rs, sink/database.rs, sink/forwarding.rs, sink/mapping.rs, api/internal.rs, api/signer_keys.rs). Added CasesForwarderSink (POST /v1/cases/reports, GET status, multipart attachment proxy), PartnerAuthClient (POST /v1/security/partners/verify with 60s cache), SignerAuthClient (GET /v1/security/signer-keys/by-kid with 60s cache), and BearerSource (static token or Keycloak ROPC). IntakeSettings shed database_url/rabbitmq_url/keycloak_* fields; gained cases_url, cases_forward_secret, security_url, security_auth_token. Cargo dropped sqlx, craig-db, craig-mq, craig-store, craig-api, craig-auth. Tests rewritten to provision partners + signer keys via craig-security and submit through intake’s edge endpoints (37 unit + 43 integration tests, vs. 117 prior; 80/80 pass). docker-compose updated to drop the DB+RMQ+Keycloak-issuer config and pass a Keycloak ROPC bearer string. Workspace LOC for services/craig-intake dropped from ~5,500 to ~2,860 (src) and from ~9,686 to ~4,387 (src + tests). CLI subcommands (craig intake list-reports, claim-report, convert-report, screen-out-report, create-key, list-keys, revoke-key) and crates/craig-test-lib/tests/workflows.rs::workflow_public_report_to_referral now point at retired intake endpoints — marked #[ignore] with deferral to Step 8 (craig-web BFF re-point) which migrates them to the cases endpoints.

7

craig-web: partner-admin pages peer to user-admin

Done (2026-04-25) — MR !147 — services/craig-web/src/routes/security/partners.rs (658 LOC, 11 handlers) + 3 Askama templates (list / new / detail with inline edit + API-keys table + signer-keys table). One-shot plaintext API key surfaced via ?new_key=…​ redirect, never persisted. JWK accepted as JSON-encoded textarea, parsed server-side. Cross-nav buttons added to all 6 sibling security pages. 70+ Fluent i18n strings. 21/21 craig-web tests pass. 6/6 partner-admin E2E specs pass (3 setup + 3 lifecycle/nav/headers). Authz: API enforces admin-only — non-admin tokens get empty data; sidebar already gates is_admin() for the nav link.

8

craig-web: re-point caseworker screening/review/convert UI at cases; add unified chain view

Done (2026-04-25) — MR !148 — routes/intake/reports.rs + templates/intake/{reports,report_detail}.html re-pointed at cases /v1/cases/reports*; legacy claim flow dropped (replaced by explicit screening-decision chain). New /cases/persons/{id}/chain family chain view + routes/cases/chain.rs (parallel-fetches reports/referrals/investigations/cases, merges chronologically; surfaces a perf-note banner if any source page hits the 100-row cap, since cases doesn’t yet expose a person_id filter on those tables). CLI re-pointed: craig intake list-reports/get-report/list-decisions/record-decision/convert-report/screen-out-report against cases; new top-level craig partner namespace with list/create/get/delete/create-key/list-keys/revoke-key against craig-security. Five #[ignore]’d CLI tests + the `workflow_public_report_to_referral test all re-pointed and passing. Audit-log E2E spec broadened to row-presence (was matching a stale verb regex). Net -213 LOC (650 added, 893 removed) — caseworker UI codebase shrinks because the new screening-decision form + decision-chain renderer replaces 4 separate intake-side admin handlers. Workspace tests at merge: 1196 passed, 0 skipped, 0 failed.

9

SDK compatibility sweep (Python + TypeScript)

Done (2026-04-25) — MR !149 — fixed check_status in both SDKs to take UUID report_id; bumped 0.1.0 → 0.2.0. READMEs + CHANGELOGs added. Submit-path unchanged. 27/27 Python, 28/28 TypeScript, 1193/1193 Rust at the time of merge (later grew to 1197 after Step 10 doc cleanups). Rust SDK fix bundled into the compat-strip cleanup MR (!150).

10

Event rename: intake.report_convertedcase.report_converted (one-shot; pre-v0.1, no dual-publish)

Done (pre-ADR-030) — Step 4 already published case.report_converted from cases (no intake.report_converted ever existed in the stateless-intake era; intake retired its event publisher entirely). Step 10 cleaned up stale doc references in ADRs (-017, -012, -003), test/coverage plans, and the cross-service integration tests plan; resolved the deprecation-window open question in this plan + ADR-017. Closes #209 + #210 (collapsed pair — dual-publish was never needed).

12

Plan completion audit + archive + close CQR April Step 5b

Done (pre-ADR-030) — audit subagent reconciled Steps 1–10 against actual codebase. Discrepancies fixed: Step 1 row updated; stale references cleaned in .claude/docs/services.md, .claude/docs/shared-crates.md, .claude/CLAUDE.md, docs/modules/ROOT/pages/api/craig-intake.adoc, data-model-cases.adoc, implementation-guide.adoc. Orphaned services/craig-intake/migrations/ directory removed (intake holds no DB per Step 6). Untracked deferral filed as #215 (local-outbox pattern, ADR-017 OQ#1). Plan moved to archive; CQR April Step 5b closed via #197.

Step 11 (originally "remove deprecated intake.report_converted after consumer cutover") was collapsed into Step 10 — pre-v0.1 deployment, no live consumers, so the dual-publish window was never needed. Numbering preserved for stable cross-references in MR descriptions and the CHANGELOG.

Issues: #197 (CQR April — Step 5b; superseded by this plan on completion of Step 12)

Branch prefix: feat/stateless-intake-*

Context

See ADR-017 for the architectural rationale and the seven open questions raised at decision time. This plan pins concrete answers to those questions so implementation can proceed without re-litigating the direction:

Open question (from ADR) Resolution for this plan

Local-outbox pattern for air-gapped partners

Deferred. Air-gapped standalone retains the config-file PartnerAuthLookup path (existing ConfigApiKeyLookup semantics). A local-outbox sub-plan will be written if/when a partner surfaces a genuine flaky-link use case. For now, stateless intake in integrated mode couples availability to cases — documented as a known characteristic.

I&R modeling (sibling disposition vs distinct entity)

Sibling disposition. I&R reports live in the same reports table with disposition_kind='i_and_r'. The disposition_follow_ups table captures the operational follow-through (provider referral, LE assistance record). Rationale: same caseworker workflow surface, same NCANDS-style reporting needs, same chain-visibility requirement. If practitioner feedback during Step 3 design review pushes back, we’ll revisit before Step 4.

Migration strategy (one-shot vs dual-write)

One-shot with dry-run. Idempotent script; can re-run safely. Production deployment is small enough that downtime for the migration is negligible (measured in seconds). Dual-write adds complexity without buying anything.

Read-through proxy caching on intake

No cache initially. Stateless proxy for GET /reports/{id}. Revisit if telemetry shows a hot path; default to correctness.

Decision-chain schema shape

Pinned below (Step 3 design).

Deprecation window for intake.report_converted

Resolved at implementation time. Pre-v0.1, no live consumers — Step 4 published case.report_converted directly; Step 10 cleaned up doc references. No dual-publish window needed.

Rate-limit scope (per-instance vs global)

Per-instance preserved. Partner rate limits continue to apply per intake process, same as today. A shared-store-backed global limiter is a separate feature if needed.

Scope

In scope:

  • Relocating intake.reports schema + all associated state (screening, disposition, conversion, referral back-pointer) to craig-cases

  • Introducing neutral multi-party decision-chain schema in cases (screening_decisions, disposition_follow_ups) with jurisdictional override support via craig-rules

  • Relocating partner identity (partner_api_keys, optional JWS pubkeys) and per-partner rate limits to craig-security

  • Net-new partner admin UI in craig-web (CRUD partners, issue/revoke keys, attach/rotate JWS pubkey)

  • Stripping craig-intake to a domain-stateless edge service: schema validation, CAPTCHA, rate limiting, partner auth via craig-security, optional JWS verification, forward-to-cases sink, GET /reports/{id} read-through proxy to cases

  • Re-pointing craig-web’s caseworker screening/review/convert UI at cases endpoints

  • Cross-family/chain unified view in craig-web (single screen showing a family’s intakes + referrals + investigations + cases)

  • Event rename: intake.report_convertedcase.report_converted (one-shot — pre-v0.1, no live consumers)

  • One-shot idempotent data migration from intake → cases + craig-security

Out of scope (tracked as follow-ups if adopted):

  • Local-outbox pattern for flaky-link or air-gapped partners with submission buffering (ADR-009 precedent; separate plan if adopted)

  • Global (cross-instance) rate limiting — per-instance is preserved

  • Read-through proxy caching on intake

  • Retiring craig-intake as a service and merging into craig-cases (OpenStack nova-api-within-Nova pattern) — rejected in the ADR; remains rejected for this plan

  • Advanced chain-visualization UI (graph of related families, multi-generation case history) — MVP is tabular chain view; visualization is a future enhancement

Design

Reports + screening-decisions schema (cases-side)

-- Reports: the submission itself. Immutable body after creation.
CREATE TABLE reports (
    id                UUID PRIMARY KEY DEFAULT uuidv7(),
    partner_id        UUID NOT NULL REFERENCES security.partners (id),
    received_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    received_ip_hash  TEXT,                -- hashed; forensic value, not plaintext
    received_request_id UUID NOT NULL,     -- idempotency key surfaced from intake edge
    reporter_type     TEXT NOT NULL,
    reporter_name     TEXT,
    reporter_phone    TEXT,
    reporter_relation TEXT,
    admin_unit        TEXT NOT NULL,
    narrative         TEXT NOT NULL,
    children          JSONB NOT NULL,       -- typed per craig-intake-sdk::types::Child
    adults            JSONB NOT NULL,       -- typed per craig-intake-sdk::types::Adult
    raw_submission    JSONB NOT NULL,       -- verbatim partner payload for audit/replay
    -- Indices on (partner_id, received_at), (admin_unit, received_at),
    -- received_request_id UNIQUE for idempotency.
    UNIQUE (received_request_id)
);

-- Screening decisions: the chain. Supports multi-party overrides.
-- The "current effective disposition" is the latest row where supersedes_id
-- is NULL OR where no later row supersedes it.
CREATE TABLE screening_decisions (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    report_id       UUID NOT NULL REFERENCES reports (id) ON DELETE RESTRICT,
    actor_role      TEXT NOT NULL,          -- 'cicc', 'county_supervisor', 'caseworker', etc.
                                            -- values sourced from jurisdiction ruleset.
    actor_sub       UUID NOT NULL,          -- keycloak sub of the deciding user
    disposition_kind TEXT NOT NULL,          -- 'screened_in', 'screened_out',
                                            -- 'screened_out_and_referred', 'i_and_r'.
                                            -- values sourced from jurisdiction ruleset.
    rationale       TEXT,
    decided_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    supersedes_id   UUID REFERENCES screening_decisions (id),
    CHECK (supersedes_id IS NULL OR supersedes_id <> id)
);

CREATE INDEX idx_screening_decisions_report_decided
    ON screening_decisions (report_id, decided_at DESC);

-- Follow-up actions for dispositions that involve caseworker work despite
-- being screened-out at the disposition level (screened-out-and-referred, I&R).
CREATE TABLE disposition_follow_ups (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    decision_id     UUID NOT NULL REFERENCES screening_decisions (id) ON DELETE RESTRICT,
    action_kind     TEXT NOT NULL,          -- 'external_provider_referral',
                                            -- 'le_assistance', 'info_packet_sent', etc.
    target          TEXT,                   -- provider name / LE agency / etc.
    notes           TEXT,
    performed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    performed_by    UUID NOT NULL           -- keycloak sub of the caseworker
);

CREATE INDEX idx_disposition_follow_ups_decision
    ON disposition_follow_ups (decision_id);

-- Referrals (existing): gains FK to the authorizing screening decision.
ALTER TABLE referrals
    ADD COLUMN intake_report_id UUID UNIQUE REFERENCES reports (id),
    ADD COLUMN authorizing_decision_id UUID REFERENCES screening_decisions (id);

Effective-disposition view: a SQL view returns the current effective disposition per report by resolving the supersedes chain. Used by UI rendering + NCANDS export + convert-eligibility checks. No stored "current" column — always computed from the chain to prevent drift.

Jurisdiction-configurable enums: actor_role and disposition_kind are TEXT columns populated from the jurisdiction ruleset at runtime. craig-rules validates submitted values against the active jurisdiction’s allowed set. Schema does not enum-constrain (would bake Georgia’s vocabulary into the schema).

Partner identity schema (security-side)

CREATE TABLE partners (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    name            TEXT NOT NULL,
    kind            TEXT NOT NULL,          -- 'hospital', 'school', 'le', 'other'
    status          TEXT NOT NULL DEFAULT 'active',  -- 'active', 'suspended'
    contact_email   TEXT,
    contact_phone   TEXT,
    rate_limit_rpm  INTEGER NOT NULL DEFAULT 60,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by      UUID NOT NULL
);

CREATE TABLE partner_api_keys (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    partner_id      UUID NOT NULL REFERENCES partners (id) ON DELETE RESTRICT,
    key_hash        TEXT NOT NULL UNIQUE,   -- bcrypt or argon2; see craig-crypto
    label           TEXT,                   -- operator-facing description
    jws_public_jwk  JSONB,                  -- optional; ADR-010
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_by      UUID NOT NULL,
    revoked_at      TIMESTAMPTZ,
    last_used_at    TIMESTAMPTZ
);

CREATE INDEX idx_partner_api_keys_hash ON partner_api_keys (key_hash)
    WHERE revoked_at IS NULL;

POST /v1/security/partners/verify contract

Request (from intake, service-to-service signed):

{
  "credential_kind": "api_key" | "jws_detached",
  "credential": "..."  // raw key, or the JWS header+signature for lookup of the key id
}

Response (200):

{
  "partner_id": "018f1234-...",
  "partner_status": "active",
  "rate_limit_rpm": 60,
  "jws_public_jwk": { ... }  // present if credential_kind == 'jws_detached' or if the key has an attached JWK
}

Response (401): ApiError::Unauthorized per RFC 9457.

Latency budget: sub-millisecond p50, <5 ms p99 on warm pool.

Intake caches the { partner_id, rate_limit_rpm, jws_public_jwk } triple keyed on the credential hash for 60 s with an in-process cache (no shared cache). Cache miss goes to craig-security; cache hit is a single hashmap lookup.

craig-intake trimmed binary

After Step 6, craig-intake contains:

  • HTTP handler for POST /reports (public) and POST /api/internal/reports (integrated-auth) — both forward to cases after validation

  • HTTP handler for GET /reports/{id} — read-through proxy to cases

  • Schema validation (craig-intake-sdk::types compatible)

  • CAPTCHA verification for public endpoint

  • Rate limiting (governor, per-partner)

  • Idempotency middleware (craig-api shared, unchanged)

  • Partner auth client for craig-security (with 60 s TTL cache)

  • Optional JWS verification (pubkey from craig-security response)

Removed:

  • services/craig-intake/src/store/ (entire module — reports, api_keys, hashing)

  • services/craig-intake/src/sink/database.rs

  • services/craig-intake/src/sink/forwarding.rs (replaced by the cases-forwarder)

  • services/craig-intake/src/sink/mapping.rs (body mapping moves to cases where it’s persisted)

  • services/craig-intake/src/transitions.rs (state machine — moved to cases, expressed via craig-rules)

  • services/craig-intake/src/api/internal.rs::convert_report (handler replaced by a BFF route that calls cases directly)

  • Intake’s DB migrations (the reports and api_keys tables are dropped post-migration)

Expected LOC reduction in intake: ~2,500 lines → ~800 lines.

craig-web routing changes

| Old route | New destination | |---|---| | /intake/reports (caseworker dashboard) | cases /v1/cases/reports | | /intake/reports/{id}/screen | cases /v1/cases/reports/{id}/screening-decisions (POST) | | /intake/reports/{id}/convert | cases /v1/cases/reports/{id}/convert (internal cases transition) | | /intake/partners/ (admin — did not exist; partner management was config-only) | craig-security /v1/security/partners/ | | /intake/submit (public-facing report submission) | unchanged — still hits intake; intake forwards | | /intake/reports/{id}/status (partner status check) | unchanged — intake read-through proxy to cases |

Steps

Each step is one logical unit of work shippable as its own MR. Where mentioned, step N depends on step N-1 unless noted.

Step 1 — Plan + ADR-Accept + issue hygiene

Files: this plan file, docs/modules/ROOT/pages/adrs/adr-017-stateless-intake.adoc (Status: Accepted), docs/modules/ROOT/nav.adoc (Active entry).

  1. Flip ADR-017 Status to Accepted (done in this commit).

  2. Add this plan to nav.adoc under ** Active.

  3. Create a parent GitLab epic for the stateless-intake rollout.

  4. Create one GitLab issue per Step 2–12. Each:

    • Prefix: feat: for 2–9, chore: for 10–12

    • Links back to this plan

    • Labels: P2-medium, plus craig-security / craig-cases / craig-intake / craig-web / sdk as relevant

    • Milestone: 2026 Q2 — Close Active Plans

Verification: cargo xtask check-docs passes. glab issue list --milestone "2026 Q2 — Close Active Plans" shows the 11 new issues + existing ones.

Step 2 — craig-security partner identity

Files: services/craig-security/migrations/XXX_partner_identity.sql, services/craig-security/src/store/partners.rs (new), services/craig-security/src/api/partners.rs (new), services/craig-security/src/events.rs.

  1. Migration creates partners + partner_api_keys tables per the Design section.

  2. store::partners::{create, get, list, update, delete} CRUD.

  3. store::partner_api_keys::{issue, revoke, rotate_jwk, lookup_by_hash, touch_last_used}.

  4. API endpoints:

    • POST /v1/security/partners (admin-only)

    • GET /v1/security/partners (paginated)

    • GET /v1/security/partners/{id}

    • PUT /v1/security/partners/{id}

    • DELETE /v1/security/partners/{id} (soft-delete, sets status='suspended')

    • POST /v1/security/partners/{id}/keys — returns plaintext ONCE

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

    • PUT /v1/security/partners/{id}/keys/{key_id}/jwk (attach/rotate JWS pubkey per ADR-010)

    • POST /v1/security/partners/verify — the hot path; intake calls this per request

  5. Publish security.partner_created, security.partner_key_issued, security.partner_key_revoked events for the audit subscriber.

  6. Integration tests cover: create partner, issue key, verify returns correct metadata, revoke key rejects subsequent verify, JWK attach/rotate round-trip.

Verification: 15+ new integration tests. cargo nextest run -p craig-security passes. No changes to existing craig-security surface; backward compatible.

Step 3 — craig-cases reports + screening-decisions schema

Files: services/craig-cases/migrations/XXX_reports_and_screening.sql, services/craig-cases/src/store/reports.rs (new), services/craig-cases/src/store/screening_decisions.rs (new), services/craig-cases/src/store/disposition_follow_ups.rs (new), migration adding new columns to existing referrals table.

  1. Migration creates reports, screening_decisions, disposition_follow_ups tables per the Design section. Adds intake_report_id + authorizing_decision_id columns to referrals.

  2. Create the v_effective_disposition SQL view that resolves the supersedes chain per report.

  3. Model types in store/models.rs: Report, ScreeningDecision, DispositionFollowUp, EffectiveDisposition.

  4. CRUD helpers (no endpoints yet — Step 4 adds those).

  5. Jurisdiction-ruleset lookups for valid actor_role and disposition_kind values: craig-cases borrows from craig-rules via the existing shared-client pattern.

  6. Unit tests for the CRUD helpers + the effective-disposition view.

Verification: cargo nextest run -p craig-cases --lib adds ~15 tests. Migration applies cleanly on a fresh DB and on the current seed fixture.

Step 4 — craig-cases report/screening/convert endpoints

Files: services/craig-cases/src/api/reports.rs (new), services/craig-cases/src/api/screening.rs (new), services/craig-cases/src/api/convert.rs (new — replaces intake’s convert handler), router updates.

Public (service-to-service, authenticated-intake-only):

  • POST /v1/cases/reports — submit a report. Validates partner context in the signed service header. Returns 201 + report row.

Internal (caseworker-authenticated):

  • GET /v1/cases/reports — list (paginated, filtered: by partner, admin_unit, disposition, date range)

  • GET /v1/cases/reports/{id} — single report + effective disposition + follow-up actions

  • GET /v1/cases/reports/{id}/decisions — full decision chain

  • POST /v1/cases/reports/{id}/screening-decisions — record a new screening decision. Validates actor_role authority via craig-rules (e.g., county can override CICC per Georgia ruleset; a caseworker can’t override a supervisor). Publishes case.report_disposition_recorded.

  • POST /v1/cases/reports/{id}/follow-ups — attach a disposition follow-up action

  • POST /v1/cases/reports/{id}/convert — atomic internal transition: requires effective disposition = screened_in; creates a referral; updates the effective view; publishes case.report_converted + case.referral_created (existing). Single DB transaction.

Verification: cargo nextest run -p craig-cases adds ~25 integration tests covering: submission, listing, single-party screening, multi-party override (CICC → county), unauthorized override rejection, disposition follow-ups on screened-out-and-referred, convert from screened-in, convert rejection from other dispositions, idempotent convert.

Step 5 — Data migration (REMOVED)

The migration tool was delivered, then deleted in the 2026-04-25 compat-strip cleanup. CRAIG is pre-v0.1 with no live deployments, so a one-shot migration tool that lives in-tree forever to support a hypothetical future cutover is net-negative on maintenance. If a real cutover is ever needed, write a fresh tool against the schema as it stands at that moment.

Step 6 — craig-intake refactor

Files: services/craig-intake/src/sink/cases_forwarder.rs (new), services/craig-intake/src/api/partner_auth.rs (new — replaces the old api_key_lookup), removal of services/craig-intake/src/store/, services/craig-intake/src/sink/database.rs, services/craig-intake/src/sink/forwarding.rs, services/craig-intake/src/sink/mapping.rs, services/craig-intake/src/transitions.rs, services/craig-intake/src/api/internal.rs::convert_report. Config updates.

  1. Introduce CasesForwarderSink — takes the validated submission, posts to {cases_url}/v1/cases/reports with a service-to-service signed header containing { partner_id, received_request_id }. Returns cases' response verbatim.

  2. Introduce PartnerAuthClient — wraps a reqwest call to craig-security’s /partners/verify with a 60 s in-process cache.

  3. Replace api_key_auth::ValidatedApiKey with the new partner auth flow.

  4. POST /reports + POST /api/internal/reports become: validate + authenticate + forward. Handlers drop to ~30 lines each.

  5. GET /reports/{id} becomes a read-through proxy: fetch from cases, return the response.

  6. Remove the old sink switchboard entirely. ReportSink enum collapses to one variant. Consider removing the enum entirely.

  7. Delete transitions.rs — state transitions now validated in cases.

  8. Delete convert_report handler — craig-web BFF calls cases directly after Step 8.

  9. Retire the database_url config field. Intake no longer needs a DB connection.

  10. Update `craig-intake’s Cargo dependencies: remove sqlx, migration crates. Keep craig-mq only if still publishing any events (probably yes — partner-level events like rate-limit-exceeded).

  11. Integration tests updated: mock craig-security + craig-cases via wiremock; verify validation + forwarding paths.

Verification: cargo nextest run -p craig-intake passes. LOC drops from ~2,500 to ~800 (target). Binary size drops correspondingly. No database_url in craig-intake config.

Step 7 — craig-web partner-admin pages

Files: services/craig-web/src/routes/admin/partners.rs (new), services/craig-web/templates/admin/partners/*.html.jinja (new), nav updates.

  1. New craig-web BFF routes under /admin/partners:

    • GET /admin/partners — list

    • GET /admin/partners/new — create form

    • POST /admin/partners — create (calls craig-security)

    • GET /admin/partners/{id} — detail, including keys

    • POST /admin/partners/{id}/keys — issue new key (shows plaintext ONCE on success)

    • POST /admin/partners/{id}/keys/{key_id}/revoke — revoke

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

  2. Templates follow the existing user-admin visual pattern. Accessible nav entry under "Admin" section, peer to "Users" and "Admin Units".

  3. RBAC: admin role only (same as user-admin).

  4. E2E test: full CRUD flow (create partner, issue key, revoke key, attach JWK, suspend partner).

Verification: cargo xtask e2e — --grep "partner admin" — new specs pass. Existing specs unaffected.

Step 8 — craig-web caseworker screening/review/convert + chain view

Files: services/craig-web/src/routes/cases/reports.rs (new — replaces the intake-side reports routes), templates for reports list/detail/screening/convert, family-chain view route + template.

  1. Re-point caseworker-facing reports UI from intake to cases endpoints.

  2. Screening-decision form supports the multi-party override model: UI shows the decision chain; override button is conditional on the current user’s role + the active jurisdiction ruleset’s authority rules.

  3. Disposition follow-up UI for screened-out-and-referred and I&R paths.

  4. Family chain view — new page showing, for a given family (person/case), all intake reports, all referrals, all investigations, all cases on one screen, chronologically, with cross-links. Uses existing sort/search/pagination patterns.

  5. Update CLAUDE.md Phase Status table reference to "craig-web: 25 pages → ~29 pages".

Verification: cargo xtask e2e — screening, convert, chain-view specs pass.

Step 9 — SDK compatibility sweep

Files: sdks/python/tests/.py, sdks/typescript/tests/.ts, sdks/*/README.md.

  1. Verify Python + TypeScript SDKs see no API surface changes on submission (POST /reports response shape unchanged: 201 + {id, submitted_at, …​}).

  2. Verify GET /reports/{id} status-check continues to work (intake’s read-through proxy preserves the contract).

  3. Update SDK READMEs with a note that partner admin + key rotation are now done in craig-web’s admin UI, not via a config file (except for air-gapped mode).

  4. Run full SDK test suites against an end-to-end deployed stack.

Verification: pytest sdks/python/tests + npm test --prefix sdks/typescript both pass unchanged.

Step 10 — Event rename (one-shot)

Files: services/craig-cases/src/events.rs, any consumers of intake.report_converted.

  1. Rename intake.report_convertedcase.report_converted in cases. No dual-publish — pre-v0.1, no live consumers to migrate.

  2. Update any in-tree subscribers (reporting, audit, etc.).

  3. CHANGELOG entry under Unreleased.

Verification: grep workspace-wide for intake.report_converted — zero hits outside CHANGELOG historical entries.

Step 12 — Plan completion audit + archive

Files: this plan (status table → all Complete), docs/modules/ROOT/pages/plans/archive.adoc (new row), docs/modules/ROOT/nav.adoc (Active → Archive), docs/modules/ROOT/pages/plans/code-quality-review-2026-04.adoc (Step 5b → Superseded by stateless-intake plan).

  1. Audit Steps 2–11 for residual findings (spawn a plan-completion-audit subagent per delivery-protocol.md).

  2. Update the status table — all steps Complete.

  3. Move this plan from Active → Archive in nav.

  4. Add archive row under Architecture/Refactor section.

  5. Close CQR April plan’s Step 5b with reference to this plan’s MRs.

  6. Close the parent GitLab epic.

Verification: cargo xtask check-docs clean. Plan Completion Audit reports zero residual findings. glab issue list --milestone "2026 Q2 — Close Active Plans" --state opened shows no stateless-intake issues open.

Critical Files

File Purpose

services/craig-security/migrations/XXX_partner_identity.sql

Step 2 — new partners + partner_api_keys tables

services/craig-security/src/api/partners.rs

Step 2 — admin CRUD + verify endpoint

services/craig-cases/migrations/XXX_reports_and_screening.sql

Step 3 — reports + screening_decisions + disposition_follow_ups

services/craig-cases/src/api/reports.rs

Step 4 — report/screening/convert endpoints

services/craig-intake/src/sink/cases_forwarder.rs

Step 6 — new HTTP forwarder sink

services/craig-intake/src/api/partner_auth.rs

Step 6 — craig-security partner auth client

services/craig-web/src/routes/admin/partners.rs

Step 7 — partner-admin BFF routes

services/craig-web/src/routes/cases/reports.rs

Step 8 — re-pointed caseworker UI

docs/modules/ROOT/pages/adrs/adr-017-stateless-intake.adoc

Accepted status flipped in Step 1

docs/modules/ROOT/pages/plans/code-quality-review-2026-04.adoc

Step 5b marked Superseded in Step 12

Verification (whole plan)

  1. cargo nextest run --workspace --locked --profile integration — all tests pass including ~40 new integration tests across steps

  2. cargo xtask e2e — all existing + new specs pass

  3. cargo xtask dev reseed — seed data regenerates correctly with the new schema

  4. Full cargo xtask validate + pre-push gate green for every MR

  5. cargo xtask check-docs clean

  6. Manual QA: submit a report via the public form, screen it in (with a CICC → county override), attach a disposition follow-up, convert to referral, observe it in the family chain view

  7. SDK test suites pass unchanged (proves no public contract drift)

  8. Plan Completion Audit subagent reports zero residual findings

Documentation Updates

  • .claude/docs/services.md — update craig-intake stats (endpoint count drops, table count drops to 0), craig-cases stats (endpoint count rises, table count rises by 3), craig-security stats (+2 tables, +8 endpoints)

  • .claude/docs/architecture.md — update service ownership diagram; add the chain-view lineage as a capability

  • .claude/docs/local-dev.md — remove intake DB setup; note the new migration tool

  • .claude/docs/services.md — update CLAUDE.md Phase Status table reference counts

  • CHANGELOG.adoc — one entry per MR

  • docs/modules/ROOT/pages/plans/archive.adoc — add stateless-intake row on completion (Step 12)

  • docs/modules/ROOT/nav.adoc — move plan Active → Archive on completion (Step 12)

  • docs/modules/ROOT/pages/plans/code-quality-review-2026-04.adoc — mark Step 5b Superseded

  • sdks/python/README.md, sdks/typescript/README.md — partner admin moved to craig-web UI (Step 9)

Edit this page · latest