Standalone Intake — Backend-Profile Architecture

On this page

craig-intake is a stateless edge: it validates and forwards a SubmitReportRequest, keeping no database of its own (ADR-017). It runs in two modes:

  • integrated: forwards to CRAIG’s own craig-cases

  • standalone: forwards to an external backend

This page walks through the standalone-SHINES path. The architectural decision behind it (four approaches considered, three rejected) is ADR-042.

The flow

forward
X-JWS-Signature
same-origin proxy
POST /signed/v1/keys/register
None
Shines: verify JWS / replay
Shines: ShinesSink → CpsRequest
craig-intake edge
BackendProfile
IntakeSink trait
Public web form
Signed API client
Keygen page
craig-cases
craig-intake-keyring sidecar
SHINES intake API

The seam (ADR-042 §D1–D2)

A seam is a place in the code where behavior can be swapped out without editing the code at that point. Here it’s the one boundary where SHINES-specific logic plugs in, so it can be unplugged cleanly:

  • BackendProfile { None, Shines }: resolved once, in boot_standalone. Every SHINES-specific piece lives under services/craig-intake/src/backend/shines/, so removing SHINES support is a two-step operation: flip the profile from Shines to None and delete that one directory. Nothing in the core DTO or the integrated path has to change.

  • IntakeSink trait: abstracts the submission target behind two methods, forward and get_report_status. There are two implementations, CasesForwarderSink and ShinesSink. Crucially, the trait itself never references a SHINES type; ShinesSink parses its own typed views out of the raw submission Value. That’s what lets the generic seam keep compiling even with backend/shines/ deleted.

  • TrackingReference: a backend-agnostic String newtype. It exists because SHINES record ids are legacy `NUMBER`s rather than UUIDs; the cases backend just wraps its UUID in the same newtype.

The SHINES backend (ADR-042 §D3–D7)

A submission under the Shines profile is conformed to SHINES’s CpsRequest:

  1. Typed carriers, not a Value bag: top-level SHINES inputs arrive as sibling JSON keys and parse into a typed ExtraFields; per-person fields extend the existing ChildEntry/AdultEntry; the nine narrative question* fields come from an extended NarrativeView. All of this has to stay in lockstep across four encodings of the report contract: the server plus the Rust, Python, and TS SDKs.

  2. Field-relevance rule engine: a single declared table of { field, relevant_when, required_when_relevant } entries drives the form’s show/hide/required behavior, the API, and server-side validation — one source of truth, so the three can’t drift apart.

  3. Typed mapper + RefVals crosswalk: a pure function maps CRAIG’s model onto CpsRequest, using a placeholder numeric RefVals crosswalk (craig-reference::shines, tracked in #692).

  4. Boot fidelity gate: boot_standalone refuses to start — fails closed — if any SHINES-required field has no source, or a crosswalk enum is left unmapped.

  5. ShinesSink: posts the CpsRequest synchronously and returns the SHINES record id as the TrackingReference. There’s no status-read route mounted under the Shines profile (ADR-042 §D7).

See the proposed SHINES intake API for the wire shape.

Signed-API + keyring topology (ADR-042 §D8–D9)

Standalone-SHINES has no partner tenancy: anyone can submit through the API, as long as their detached JWS verifies against an active kid (JOSE terminology for "key id" — the header field naming which registered public key signed the request) in a known-key registry. That verification happens in a distinct handler, submit_report_signed, which isn’t bound to any partner id and accepts the full reporter-type set. It checks the JWS over the raw inbound bytes, before any typed parsing happens, against the craig-intake-keyring sidecar — the one piece of state a stateless edge actually needs — and dedupes replays by jti.

The keygen page provisions a keypair client-side and registers only the public key, through a same-origin intake proxy; the keyring itself stays internal-only. See the craig-intake API reference for the full endpoint catalog.

Theming the embedded UI (ADR-036 §4 amendment / ADR-043)

The embedded public form is themed from the active jurisdiction bundle, not a hardcoded palette. At boot — in any mode that mounts the UI, integrated as well as standalone — the edge resolves CRAIG__ACTIVE_STATE_BUNDLES (the same selector the BFF and stateful services use, in src/bundle.rs) and renders the bundle’s ThemeContribution to CSS through the shared craig_state_bundle::materialize_theme_css renderer (#710), serving the result from GET /assets/theme.css. That route exists because the strict CSP (style-src 'self', no 'unsafe-inline') rules out an inline <style> tag, so the per-jurisdiction palette has to arrive over a same-origin route instead, the same approach craig-web uses. Boot fails fast if there’s no active bundle, or if a resolved palette is structurally incomplete.

This was the first step of the portal-consolidation program (ADR-043), whose goal is making the edge the single themed public portal for every deployment.

The integrated edge mounts this same UI too, since P3.6 (#721, unit L7): boot_integrated sets embed_ui=true, so an integrated deployment now serves the themed, localized, accessible, attachment-capable form — at parity with the standalone one — under the UI-enabled CSP (default-src 'self') instead of the API-only default-src 'none'. The integrated devstack service carries CRAIG__ACTIVE_STATE_BUNDLES as a result. craig-web’s own duplicate /report was removed in Phase 5 (#736); the edge is now the sole public report portal, reached through path-based ingress (ADR-043).

Localization (ADR-044 / epic &60 Phase 3)

The embedded public pages — report, status, keygen, keys — are localizable through the shared craig-i18n Fluent engine, the same one craig-web’s worker UI uses. That means the public string catalog is shared rather than forked.

The pages themselves are Askama templates (templates/.html) whose visible strings resolve through a t() filter, while the accompanying JS pulls its strings from window.CRAIG_I18N, emitted by GET /ui/i18n.js along with a CRAIG_T(key) helper. At boot, I18n::load resolves from craig-i18n’s *embedded public catalog — the edge ships no on-disk `locales/ directory, per ADR-017 — and overlays it with the active bundle’s terminology. A session-less locale_layer, scoped to the UI subtree, negotiates the locale by checking ?lang=, then Accept-Language, then falling back to en, and sets <html lang> accordingly.

Today this ships en-only: the localization machinery and externalized keys are in place, but authoring a second language (es) is tracked separately (#722). A key-coverage gate asserts that every template and JS key actually resolves.

Report wizard + accessibility (ADR-044 / epic &60 Phase 3)

The public report form is a staged 6-step wizard — Reporter, Incident, Adults, Children, Additional information, Review — matching craig-web’s public wizard step for step, but built on the edge’s own reportForm Alpine component. The field model, the fetch-based submit, and the $store.relevance profile gating all carry over unchanged. report-form.js tracks step state with nextStep/prevStep/goToStep; a forward move is gated on a per-step validateStep, and advancing moves focus to the newly revealed step’s heading. Step 5, "Additional information", always shows the additional_info field plus a profile-gated SHINES block, so the step is never empty even under the None/cases profile. Step 6 is a read-only review with per-section Edit jumps back.

Accessibility is first-class: a skip link; header[role=banner], nav[aria-label], and main landmarks on all four public pages; a progress nav with :aria-current; two role="alert" aria-live="polite" regions, one for per-step validation and one for submit/server errors; and step headings with tabindex="-1" so that the focus-on-advance behavior actually lands. The WCAG 2.1 A/AA gate (accessibility-audit.spec.ts) has an env-guarded Edge block that walks the wizard under both the None and SHINES profiles and audits the status, keys, and keygen pages too, so the edge — the future sole public portal — is held to zero violations.

Public attachment upload (ADR-044 / epic &60 Phase 3)

POST /public/v1/reports/{id}/attachments lets a reporter attach a file to an anonymously-submitted report. It’s anonymous by design: since a reporter has no account, the capability is simply the unguessable report UUID v7 in the path — the same model the public status read uses — and there’s no api-key or ownership check on top of it. The handler buffers the multipart body and re-emits it to craig-cases over the service-to-service identity, sharing the same forward_upload_to_cases path the partner upload uses. craig-cases is what enforces the MIME allow-list and the per-file size cap, so the edge itself adds no validation of its own. The public router rate-limits the endpoint per hashed client IP, and the global body limit bounds how much can be buffered.

The route is mounted only for the cases-backed profile — BackendProfile::None, covering both integrated and standalone-None deployments. Under SHINES there is no cases backend to forward to, so the route is absent (404). build_router expresses this as attachments_sink: Option<CasesForwarderSink>: its presence both mounts the route and supplies the sink the forward needs. There is deliberately no list endpoint: a reporter only ever sees the status enum.

Success-view upload form (P3.5 / #720)

The reporter-facing UI for the endpoint above lives on the report form’s confirmation view (the x-if="submitted" success card). After a report is accepted, a single-file form with Select File and Upload Document buttons POSTs multipart/form-data to the endpoint via fetch. The report id returned by the submit call is the capability, and the outcome is reported through an aria-live feedback line. There is no list view here either.

The form only renders under the cases-backed (None) profile. The underlying gate is x-show on a non-SHINES backend_profile, so the form stays hidden under SHINES, mirroring the absent endpoint.

Its accept hint and help text describe the actual cases/craig-store default policy: PDF, images, Word, Excel, plain text, and CSV, at 10 MB each. That’s intentionally broader than craig-web’s stricter "no photographs" copy, since the store’s default policy does allow images.

Integration aids (epic &60 Phase 2)

Debug CpsRequest panel (#713)

For SHINES integration testing, the standalone-SHINES edge can echo back the exact CpsRequest it POSTed to SHINES, right in the submit response. It’s surfaced as a copyable debug panel below the success card, so an integrator can see and replay the payload. Because that CpsRequest carries third-party and minor PII (child and adult SSNs), the panel is fail-safe by construction:

  • Explicit-ack gate, default off. The panel only activates when CRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST is set to the exact literal value EXPOSE_SSN_PII — a stray =true won’t do it. validate() also requires backend_profile=shines and logs a loud boot warning when the flag is on. This is meant for dev and integration testing only, never production.

  • Contained. ShinesSink writes the conformed CpsRequest, as pretty JSON, into a borrowed capture slot on ForwardRequest, and the value is never logged. The handler returns a craig-intake-local DebugReportConfirmation: a Serialize-only superset that isn’t part of the OpenAPI/SDK contract, with Cache-Control: no-store. The shared ReportConfirmation wire shape itself doesn’t change.

View-keys list + page (#714, #715)

For signer-key onboarding (register → approve → sign), the standalone-SHINES edge can list registered keys' metadata: kid, holder display name, an effective lifecycle status, and timestamps. The effective status is expired for an approved key past its expires_at, mirroring how lookup_active treats it. No key material and no holder PII identifier are ever included.

  • Gated metadata-only endpoint (#714). The keyring’s GET /keys is mounted only when CRAIG_INTAKE_KEYRING__ENABLE_KEY_LIST is set. When it’s off, the route is simply absent, so a request 404s without giving away whether the feature exists at all. The browser reaches it through a same-origin intake proxy at GET /signed/v1/keys, since the keyring itself stays internal-only (ADR-042 §D8). That proxy relays the list as-is, relays a gate-off 404 as-is, and redacts any keyring-side fault down to a generic 500.

  • View-keys page + per-profile nav (#715). A SHINES-only /keys page renders the list as a table with effective-status badges. The top-of-page nav is profile-aware: SHINES pages link Register a Key and View Keys, but never Check Status, since status lookup isn’t mounted under SHINES at all (ADR-042 §D7). The non-SHINES form links Check Status instead. These conditional links, on the shared report.html, are driven by the relevance store’s backend_profile.

Edit this page · latest