ADR-043: craig-intake is the Single Public Intake Portal
On this page
Status
Accepted (2026-06-25). Resolves the duplication + theming question raised while
scoping the standalone mandatory-reporting portal’s appearance (the standalone
/report is unthemed; craig-web’s /report and craig-intake’s /report are
near-duplicate public forms). This ADR fixes the architecture; the code lands as a
multi-phase program — see
the consolidation plan
(epic &60). Only Phase 1 (theming) is fully specified + binding; Phases 2-5 are a
scoped roadmap, each planned separately through contextless review before coding.
Supersedes the informal two-surface model (two public report forms);
it does not flip any prior ADR’s status.
Related: ADR-017 (stateless edge — retained, this ADR keeps the edge stateless), ADR-004 (BFF — craig-web stays the authenticated BFF, sheds the public form), ADR-036 (token + theme export — amended by this program so the renderer is shared and the edge is a theme consumer), ADR-042 (standalone backend-profile — the edge this builds on; its "form served only in standalone mode" is superseded here, see §3), Standalone Mode (introduced the embedded edge UI as standalone-only — the posture this ADR generalizes).
Context
CRAIG presents three public-facing intake surfaces:
-
craig-web caseworker
/intake/* — the authenticated worker UI, fully themed via ADR-036. -
craig-web public
/report— the anonymous public wizard (Askamareport_base.html+report-wizard.js), themed, with i18n (~312report-message keys) + accessibility complete. It does *not store the report — it proxies the submission to craig-intake’s public API (intake_url). -
craig-intake standalone
/report— the stateless edge’s own self-contained public form (services/craig-intake/static/report.html), served only in standalone mode (ADR-042; Standalone Mode). It hand-rollsstatic/css/intake-public.csswith hardcoded hex colors and is therefore unthemed — it ignores the active jurisdiction palette and OS dark-mode.
Two facts make this untenable. First, duplication: surfaces 2 and 3 are two public report forms with two field sets, two CSS systems, and two places a form/server divergence bug can hide (CRAIG has already hit form/server divergence — #639, #650). Keeping both in lockstep is recurring cost with no offsetting benefit — the edge is already where the submission actually lands (surface 2 merely proxies to it).
Second, the theming deferral is now the blocker. ADR-036 §"Out of scope" explicitly
deferred per-jurisdiction theming of craig-intake "the public intake form … a
separate binary with its own CSP that serves a fixed intake-public.css with a
hardcoded palette … extending per-jurisdiction theming to intake is future work,"
because at the time theme tokens were consumed by craig-web only. The
multi-jurisdiction mission means any state may deploy the edge as its public front
door; an unthemed safety form that ignores the deploying agency’s palette is not
acceptable. The future work has arrived.
The constraints that shape the decision:
-
the edge must stay stateless (ADR-017 — no service-side DB/MQ/authz);
-
craig-web must keep its authenticated caseworker UI (that is the BFF’s job, ADR-004);
-
the public form’s i18n + accessibility must not regress (it is a public safety form); and
-
theming must be per-jurisdiction (driven by the active bundle), not a single hard-coded palette.
Decision
Make craig-intake the single public intake portal for every deployment mode, and
delete craig-web’s public /report. craig-web keeps only the authenticated caseworker
UI. Five sub-decisions follow.
1. The edge renders the themed public UI, jurisdiction-aware from the active bundle
craig-intake resolves the active state bundle at boot (the same
CRAIG__ACTIVE_STATE_BUNDLES activation craig-web uses — ADR-032 §2.7), takes its
ThemeContribution, and materializes one same-origin GET /assets/theme.css exactly
as craig-web does (ADR-036 §1, §4). The palette is therefore per-deployment — Georgia
/ Simple Statehouse for a SHINES-connected edge, another state’s palette for another
deployment — not a hard-coded one. This requires the ADR-036 renderer to be shared
rather than craig-web-private; the amendment to ADR-036 (landing in the same program
phase) moves the pure &ThemeContribution → CSS renderer into craig-state-bundle
so both binaries emit byte-identical CSS, while each binary keeps its own active-bundle
resolver (the resolver names concrete bundle crates that depend on the shared crate,
so it cannot move without a dependency cycle). No runtime TOML enters the edge
(ADR-036 §2 build-time-only invariant is preserved).
2. The service split (ADR-017 / ADR-004) is retained — only the form consolidates
This is not a merge of the two services. craig-intake stays the stateless edge
(validate + forward, no DB/MQ/authz — ADR-017); craig-web stays the authenticated
BFF for the caseworker UI (ADR-004). What consolidates is the public report form:
it lives in the edge, full stop. The edge is already the durability boundary for a
public submission (craig-web’s /report only proxies to it), so moving the form to
the edge removes a hop and a duplicate, not an architectural layer.
3. The edge serves the embedded public UI in integrated mode too
Today the embedded edge UI mounts only in standalone mode (embed_ui=true); integrated
mode boots embed_ui=false because craig-web served the public form
(Standalone Mode). This ADR supersedes that:
the edge must serve the public UI in all modes, so integrated deploys also get the
themed form from the edge. Mounting the UI in integrated mode is therefore a
prerequisite (program Phase 3 / L7) before craig-web’s form can be retired (Phase 5)
— otherwise integrated deployments would have no public form at all.
4. /report reaches the edge by path-based ingress routing — no redirect, no shim
In an integrated deployment, the public path /report is routed to craig-intake at the
ingress / reverse-proxy layer (one hostname, path-based routing — deployment config),
not by an HTTP redirect from craig-web and not by a compatibility shim. There is no
transitional craig-web /report that 30x-redirects; the route simply resolves to the
edge. This keeps the public URL stable for end users while the form lives in the edge,
and it keeps craig-web free of any public-report code.
5. Retiring craig-web’s /report* is gated on i18n + accessibility parity, then deleted outright
craig-web’s public_report_routes(), routes/report.rs, templates/report/,
report-wizard.js, the report- locale keys, the clients/intake.rs proxy, and the
intake_url config are *deleted outright (Phase 5) — no shim, no redirect, no
dead-but-kept code. That deletion is gated: it may not land until the edge has reached
parity on the public form’s internationalization and accessibility (the edge form must
carry craig-web’s ~312 report-* message keys and meet the same WCAG 2.1 AA / Section
508 bar — program Phase 3). Deleting the only accessible, translated public form before
the replacement reaches parity would regress a public safety surface; the gate forbids
it.
Consequences
Positive
-
One public report form, one field set, one CSS system — the form/server divergence class (#639, #650) shrinks to a single surface.
-
The edge is themed per-jurisdiction for every deployment — the ADR-036 deferral is closed.
-
craig-web sheds public-facing surface area, tightening it to the authenticated BFF role (smaller public attack surface behind auth-less paths).
-
The renderer becomes a shared, tested
craig-state-bundleexport reusable by any future host that renders chrome.
Negative / costs
-
The edge must reach i18n + a11y parity with craig-web’s mature wizard before the old form can go — real migration work (Phase 3), the program’s highest-risk phase (craig-intake serves static
include_str!HTML; craig-web has the|tFluent infra + a staged wizard). The i18n strategy for a static-HTML edge is an open design spike. -
Integrated deployments must mount the edge UI (Phase 3 / L7) — a boot-path change to a previously API-only mode.
-
/reportnow depends on ingress path-routing config; a misconfigured ingress yields a 404 rather than a redirect. This is deployment-doc + deployment-guide work (Phase 5). -
A breaking change for any operator currently relying on craig-web serving
/reportdirectly (pre-1.0; acceptable, recorded in CHANGELOG at Phase 5).
Follow-on (deferred to later program phases, not this ADR)
-
Mandated-reporter signing enforced server-side (Phase 4):
mandated/professional/law_enforcementreporter types must sign — the public channel (/public/v1/reports) rejects them server-side; they use/signed/v1/reports(ADR-042 §D9). This closes the current gap where the unauthenticated public channel acceptsMandated. The detailed design landed in Phase 4’s plan; the resolved decisions are recorded in the 2026-06-27 amendment below. -
Integration aids — a PII-fail-closed debug JSON panel, top-of-page nav, a view-registered-keys page (Phase 2).
Open questions
Deferred to the implementation phases, not blocking acceptance:
-
i18n strategy for a static-HTML edge (Phase 3): Askama server-render in craig-intake vs. a client-side JSON message-catalog. The edge serves static
include_str!HTML today; craig-web has the Fluent|tinfrastructure. Decided
documented in Phase 3’s plan before building. -
Attachments + organization-key endpoints on the edge (
/public/v1/keys,/public/v1/keys/organizations) — unimplemented in craig-intake today; response schema + org-list source defined in Phase 3. -
The exact ingress routing manifest for
/report→ edge (per deployment target) — deployment-guide detail, Phase 5.
Alternatives considered
-
Keep both forms; just theme the standalone one. Rejected: themes the edge (good) but leaves the duplication (two field sets, two CSS systems, the divergence-bug surface) permanently. The theming refactor is most of the work either way; stopping half-way banks the cost without the consolidation benefit.
-
Move the public form into craig-web and delete the edge’s UI. Rejected: craig-web is the authenticated BFF; pushing the only public surface back into it widens its public attack surface and contradicts ADR-017’s "the edge is the public front door." The edge is already where submissions land.
-
Merge craig-intake into craig-web (one service). Rejected: violates ADR-017 (stateless edge) and ADR-004 (BFF) — the edge is deployed independently (standalone mode, in front of a legacy backend) where craig-web’s DB/MQ/auth dependencies are absent by design. The split is load-bearing.
-
An HTTP redirect (craig-web
/report→ edge) as the transition. Rejected per L6: a redirect leaves public-report code in craig-web (the route that issues the 30x) and a visible URL hop. Path-based ingress routing keeps the URL stable with zero public-report code in craig-web. -
Hard-code the edge palette (skip per-jurisdiction theming). Rejected: contradicts the multi-jurisdiction mission (L2) — every other deployment would get Georgia’s colors. The active-bundle resolution is the same mechanism craig-web already uses; reusing it is cheaper than maintaining a hard-coded divergence.
Related decisions
-
ADR-017: Stateless Intake — retained; the edge stays stateless, the form is compiled-in data + static assets, not state.
-
ADR-004: BFF Pattern — craig-web stays the authenticated BFF and sheds the public form.
-
ADR-036: Token + Theme Export — amended by this program (shared renderer; edge as second consumer; the intake-theming "Out of scope" bullet superseded).
-
ADR-042: Standalone Intake Backend Plugin — the edge this builds on; its "form served only in standalone mode" posture is superseded (§3), and its §D9 signed channel is the Phase-4 enforcement mechanism.
-
Plan: craig-intake as the Single Public Intake Portal — the multi-phase execution of this ADR (epic &60).
-
Standalone Mode for craig-intake — introduced the embedded edge UI as standalone-only; this ADR generalizes it to all modes.
Amendment (2026-06-27) — Phase 4 mandated-signing design
Phase 4 (plan) resolves the Follow-on forward-reference above. Four decisions, scoped + reviewed (three contextless passes + a code-grounded owner review):
-
Structural soft-gate, not a config knob. "Mandated must sign" is derived from the deployment wiring —
matches!(backend, BackendWiring::Signed())inbuild_router— so the public form refusesmandated_exactly where a signing channel (/signed/v1/reports) is mounted. The UI branch (sign vs. plain submit) is convenience; the server gate is the enforcement (L4). Nothing to misconfigure: a reporter cannot be rejected from/publicfor a channel that does not exist in that deployment. -
The keyring is reused for public mandated reporters. ADR-042 §D9 framed the
craig-intake-keyring+ the/signedpath as a "3rd-party→CRAIG" / SHINES-API concern. Phase 4 refines that: the same keyring (register → operator-approve → revoke) and the same detached-JWS verification now also serve public individual mandated reporters, who generate a browser keypair, register the public key, and sign their report once approved. No new key store; the browser signer matches the existing Rust SDK header contract ({alg,kid,jti,iat}). -
Signing is made accountable by recording the signer. The pre-existing
/signed/v1/reportsverified the JWS then discarded the signer evidence — and SHINES (the standalone system of record) keeps no CRAIG-side report. Phase 4 captures the verification result and emits a PII-freereport.signedaudit event — the opaque signer / key ids (kid,signer_key_id), the replay/freshness anchors (jti,iat), the content binding (canonical-payload hash + signature), and the tracking id. The signer’s human identity (user_identifier) is not logged — it resolves from thekidin the keyring, so the record is attributable yet PII-free. Nothing new is forwarded to SHINES (existing report fields only) — and this is forced by how SHINES actually integrates (owner, 2026-06-27): SHINES has no validating backend API (its insertion rules only ever existed client-side in the legacy worker UI), so the mandatory-reporting flow writes a JSON document to a fileserver that a UiPath RPA bot re-keys into the legacy worker UI. A bot typing into a UI has nowhere to put a detached signature, so the JWS’s value is entirely CRAIG-side accountability; craig-intake’s own validation is the only real validation in the chain; and the synchronous SHINES "record id" is a convenience fiction of the mock (theIntakeSinkabstraction hides the real async file-drop, which is deferred). The signer↔reporter identity match — confirming the signature belongs to the person the report claims to be from — is the enforcement built next: it matches against the SHINES-issued unique reporter id, which the legacy portal keeps as an in-house enum of valid ids (a known finite set); the design is to roll that set into the keyring (an existing reporter’s id binding to their key, new CRAIG-onboarded reporters nesting in seamlessly). It is gated on #691 (the authoritative SHINES contract / the id list). A keyring-backed signing receipt store (stronger than logs) is a further deferred hardening. -
Integrated/cases-backed individual signing is deferred (documented posture). Integrated mode has no individual-reporter signing path (its partner API binds a
partner_id+ API key, unusable by a public individual). Building one needs a different trust model (operator-approving thousands of individual browser keys does not scale — likely IdP-authenticated identity) and overlaps the ADR-044 §3 partner-key-home question. Until it lands, integrated deployments accept unsignedmandatedreports as today — so L4 is satisfied for SHINES deployments now, and globally only once that follow-up is built. This is not a Phase-5 blocker (Phase 5 deletes craig-web’s/reportregardless).
As-built scoping note (P4.2, the required mandated category). The mandated_reporter_category field was already
captured into raw_submission (#638, closed). P4.2 adds the edge form <select> + two-layer server validation:
a non-blank value must be in the VALID_MANDATED_CATEGORIES allowlist on every channel (consistent with the
existing reporter_type/concern_type enum checks), but the required-when-mandated rule fires only on the
interactive web-form handlers (public + signed), not the partner programmatic API — whose deliberately-permissive
ingress (a typed Json<T> + a raw-Value escape hatch for arbitrary partner JSON) should not gain a newly-required
field as a side effect of a web-form feature. A partner forwarding a mandated report may legitimately omit the
category; a human filling the public/SHINES form may not.
As-built scoping note (P4.3, the approval-gate UX). Surfacing a reporter’s key-approval status (decision 2’s
register → pending → approve → sign journey) reads the keyring’s by-kid ACTIVE lookup
(GET /v1/security/signer-keys/by-kid/{kid}), proxied same-origin as GET /signed/v1/keys/by-kid/{kid} so the
browser never reaches the internal-only keyring. The proxy relays only the active/not-active bit the UI needs
({ kid, status: "active" } on 200; a bare 404 otherwise) — not the keyring’s holder identity / public JWK,
and with no pending-vs-revoked-vs-expired oracle (matching the verifier’s uniform-401 posture on the submit
path). This is per-reporter status, deliberately not the /keys list (enable_key_list is default-off and is
an all-keys view aid, not per-reporter status).
As-built scoping note (P4.4, the structural gate). Decision 1’s "mandated must sign, derived from the wiring"
is enforced by mandated_signing_required = matches!(backend, Some(Signed(_))) layered as a Copy extension on
the public nest; submit_report calls a standalone reject_unsigned_mandated_on_public check (mirroring
require_mandated_category, not threaded into validate_reporter_type) that returns a 422 — a VALID
reporter type refused by channel, distinct from the 400 validate_reporter_type raises for a malformed enum.
The curated PUBLIC_REPORTER_TYPES set is unchanged: mandated remains a valid public reporter type off
SHINES (the deferral invariant); only the SHINES wiring flips the gate on. Under SHINES /public submit is the
only public mandated ingress (attachments/status/partner API are not mounted under Signed), so this fully
closes the bypass there.
|
Phase 5 — as-built (craig-web
/report* removed, 2026-06-28)Decisions 4–5 are realized. craig-web’s public
|