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:

  1. craig-web caseworker /intake/* — the authenticated worker UI, fully themed via ADR-036.

  2. craig-web public /report — the anonymous public wizard (Askama report_base.html + report-wizard.js), themed, with i18n (~312 report- message keys) + accessibility complete. It does *not store the report — it proxies the submission to craig-intake’s public API (intake_url).

  3. 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-rolls static/css/intake-public.css with 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-bundle export 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 |t Fluent 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.

  • /report now 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 /report directly (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_enforcement reporter 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 accepts Mandated. 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 |t infrastructure. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

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):

  1. Structural soft-gate, not a config knob. "Mandated must sign" is derived from the deployment wiring — matches!(backend, BackendWiring::Signed()) in build_router — so the public form refuses mandated _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 /public for a channel that does not exist in that deployment.

  2. The keyring is reused for public mandated reporters. ADR-042 §D9 framed the craig-intake-keyring + the /signed path 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}).

  3. Signing is made accountable by recording the signer. The pre-existing /signed/v1/reports verified 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-free report.signed audit 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 the kid in 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 (the IntakeSink abstraction 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.

  4. 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 unsigned mandated reports 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 /report regardless).

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 /report* flow is deleted outright (P5.1, #736) — routes (public_report_routes), handlers (routes/report.rs), templates, static JS, the intake BFF client, CRAIG_WEB__INTAKE_URL, the craig-intake-contracts dep, and the report-only i18n keys are all gone. The craig-intake edge is the sole public report portal; it reached full field parity with craig-web over the R1–R3 remediation (the edge form is a verified superset, POST-body-asserted by the intake-ui e2e), in addition to the i18n + a11y parity (L5, Phase 3) and the integrated-mode UI mount (L7, #721).

/report reaches the edge by path-based ingress — no redirect, no compatibility route (L6). The ingress rule itself is operator-owned deployment config (ADR-017 / L1): it is documented as a required cutover step in the deployment guide’s "Public report routing" section and flagged as a breaking change in the CHANGELOG, not implemented in-repo (the repo ships no gateway; devstack reaches services by port). Integrated individual signing remains the deferred global-L4 follow-up (#731) — not a Phase-5 blocker.

Edit this page · latest