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 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, inboot_standalone. Every SHINES-specific piece lives underservices/craig-intake/src/backend/shines/, so removing SHINES support is a two-step operation: flip the profile fromShinestoNoneand delete that one directory. Nothing in the core DTO or the integrated path has to change. -
IntakeSinktrait: abstracts the submission target behind two methods,forwardandget_report_status. There are two implementations,CasesForwarderSinkandShinesSink. Crucially, the trait itself never references a SHINES type;ShinesSinkparses its own typed views out of the raw submissionValue. That’s what lets the generic seam keep compiling even withbackend/shines/deleted. -
TrackingReference: a backend-agnosticStringnewtype. 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:
-
Typed carriers, not a
Valuebag: top-level SHINES inputs arrive as sibling JSON keys and parse into a typedExtraFields; per-person fields extend the existingChildEntry/AdultEntry; the nine narrativequestion*fields come from an extendedNarrativeView. All of this has to stay in lockstep across four encodings of the report contract: the server plus the Rust, Python, and TS SDKs. -
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. -
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). -
Boot fidelity gate:
boot_standalonerefuses to start — fails closed — if any SHINES-required field has no source, or a crosswalk enum is left unmapped. -
ShinesSink: posts theCpsRequestsynchronously and returns the SHINES record id as theTrackingReference. 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_REQUESTis set to the exact literal valueEXPOSE_SSN_PII— a stray=truewon’t do it.validate()also requiresbackend_profile=shinesand logs a loud boot warning when the flag is on. This is meant for dev and integration testing only, never production. -
Contained.
ShinesSinkwrites the conformedCpsRequest, as pretty JSON, into a borrowed capture slot onForwardRequest, and the value is never logged. The handler returns a craig-intake-localDebugReportConfirmation: a Serialize-only superset that isn’t part of the OpenAPI/SDK contract, withCache-Control: no-store. The sharedReportConfirmationwire 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 /keysis mounted only whenCRAIG_INTAKE_KEYRING__ENABLE_KEY_LISTis 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 atGET /signed/v1/keys, since the keyring itself stays internal-only (ADR-042 §D8). That proxy relays the list as-is, relays a gate-off404as-is, and redacts any keyring-side fault down to a generic500. -
View-keys page + per-profile nav (#715). A SHINES-only
/keyspage renders the list as a table with effective-status badges. The top-of-page nav is profile-aware: SHINES pages linkRegister a KeyandView Keys, but neverCheck Status, since status lookup isn’t mounted under SHINES at all (ADR-042 §D7). The non-SHINES form linksCheck Statusinstead. These conditional links, on the sharedreport.html, are driven by therelevancestore’sbackend_profile.
Related
-
ADR-042 — the decision + rejected alternatives.