Plan: craig-intake as the Single Public Intake Portal

On this page

Status

This was a program, not a single MR. Phase 1 was fully specified below and tracked at issue granularity; Phases 2–5 each received their own committed plan (now archived alongside this one), iterated through contextless review before that phase was coded. All five phases are complete and epic &60 is closed.

Phases 1–3 are complete (2026-06-26). Phase 1 (theming foundation): the standalone public portal renders in the active jurisdiction’s palette + OS light/dark. Phase 2 (integration aids): the PII-guarded debug CpsRequest panel, the gated view-keys endpoint, and the view-keys page + per-profile nav. Phase 3 (parity migration): the edge reached full parity with craig-web’s public report flow — shared craig-i18n, the staged accessible wizard, public attachments (endpoint + success-view UI), and the themed UI now mounted in integrated mode (L7). Phase 4 (mandated-reporter signing) is complete — SHINES-scoped browser signing with a PII-free accountability audit, the keyring approval-gate UX, and the structural Signed ⇒ /public 422 gate. Phase 5 (the last) removed craig-web’s duplicate public /report* outright — preceded by the edge field-parity remediation (R0–R3) that made the edge a verified superset. The program is complete; epic &60 is closed: craig-intake is the single public report portal for every deployment, reached by path-based ingress.

Step Description Status

P1.1 (#709)

Program plan (this file) + ADR-036 dated amendment + ADR-043; nav-linked.

Done (2026-06-25) — MR !806

P1.2 (#710)

Share the renderer: move materialize_theme_css + private helpers + ThemeBootError into craig-state-bundle; craig-web imports them; service-neutral banner.

Done (2026-06-25) — MR !807

P1.3 (#711)

craig-intake themes from the active bundle at boot (replicated resolver wrapper + state-bundle features); serve GET /assets/theme.css.

Done (2026-06-26) — MR !808

P1.4 (#712)

Convert intake-public.css hex → var(--token); link /assets/theme.css before it; e2e green.

Done (2026-06-26) — MR !809

P2

Integration aids (JSON panel PII-guarded, nav, view-keys) — its own committed plan before coding.

Done (2026-06-26) — plan MR !811; #713 (!812), #714 (!813), #715 (page + per-profile nav)

P3

Parity migration (i18n via a shared craig-i18n crate, a11y/wizard, public attachments, mount UI in integrated mode per L7) — committed plan + ADR-044. (org-keys descoped → ADR-044 partner-key reconciliation.)

Done (2026-06-26) — plan MR !816; #716 (!817), #717 (!818), #718 (!819), #719 (!820), #720 (!821), #721 (!822)

P4

Mandated-reporter signing (server-side enforce + browser signing) — committed plan.

Done (2026-06-27) — plan MR !826; #727 (!827), #728 (!828), #729 (!829), #730 (!830)

P5

Remove craig-web /report outright; ingress routes /report to the edge — committed plan. Preceded by the edge field-parity remediation (R0–R3).

Done (2026-06-28) — remediation #742/#741/#739/#740 (!833/!834/!835/!837); P5.1 strip #736 (!838); P5.2 archive + epic close #737. Epic &60 complete.

Epic: &60
Phase 1 issues: #709, #710, #711, #712
Branch convention: feature/Make craig-intake the one themed public reporting portal for all deployments, delete craig-web’s duplicate public /report, and add integration aids + mandated-reporter signing. A multi-phase program; Phase 1 (theming) is fully specified here. per issue (this MR: feature/intake-portal-phase1-plan)

Context

CRAIG runs three public-facing intake surfaces today, and only two of them are themed:

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

  2. craig-web public /report — the anonymous public wizard (Askama report_base.html + report-wizard.js), themed, i18n + a11y complete; it proxies the actual submission to craig-intake’s API (intake_url).

  3. craig-intake standalone /report — the edge’s own self-contained public form (services/craig-intake/static/report.html, served only in standalone mode per Standalone Mode + ADR-042). It hand-rolls static/css/intake-public.css with ~27 hardcoded hex colors and is therefore unthemed — it ignores the active jurisdiction’s palette and OS dark-mode entirely.

Two problems follow. First, the standalone portal looks nothing like the rest of the product — a jarring break for a public safety form that any jurisdiction may deploy. ADR-036 deferred per-jurisdiction theming of the edge as future work precisely because, at the time, theme tokens were consumed by craig-web only. That deferral is now the blocker.

Second, surfaces 2 and 3 are duplicates: two public report forms, two field sets to keep in lockstep, two CSS systems, two places a divergence bug can hide (CRAIG has already been bitten by form/server divergence — #639, #650). The architecturally honest move is to make the edge (craig-intake) the single public portal for all deployments and delete craig-web’s public /report, keeping craig-web as the authenticated caseworker BFF. ADR-043 records that decision; this plan executes it, theming first.

The theming pipeline already does almost everything needed. ADR-036 resolves the active jurisdiction bundle at boot, takes its ThemeContribution, renders one CSS string, and serves it from GET /assets/theme.css under the strict CSP (style-src 'self'). The renderer is a pure &ThemeContribution → CSS function. The only reason the edge can’t reuse it is that the renderer lives inside the craig-web binary. Phase 1 lifts the renderer into the shared craig-state-bundle crate so both binaries call the identical code, then themes the edge from its active bundle — per-jurisdiction, not a hard-coded palette.

Scope

In scope (this program):

  • Phase 1 — share the token→CSS renderer; theme the edge from the active bundle.

  • Phase 2 — integration aids: a PII-guarded debug JSON panel, top-of-page nav, a view-registered-keys page.

  • Phase 3 — parity: carry craig-web’s i18n + accessibility + staged-wizard UX
    attachments + org-key endpoints to the edge, and mount the embedded UI in integrated mode.

  • Phase 4 — server-side-enforced mandated-reporter signing + browser signing UI.

  • Phase 5 — delete craig-web’s public /report* outright; route /report to the edge at the ingress.

Out of scope:

  • Any change to the authenticated caseworker UI in craig-web (it stays, themed, as is).

  • Re-architecting the edge into a stateful service — ADR-017 (stateless edge) is retained throughout (L1).

  • Backwards-compatibility shims or HTTP redirects for the old craig-web /report (L6 — Phase 5 deletes it outright; the ingress routes the path).

  • Runtime theme reload (ADR-036 keeps theme compiled-in; unchanged).

Locked constraints

These are the program-level invariants every phase honors. They were settled during plan review and are not re-litigated per phase.

# Constraint

L1

Service split (architectural — all phases). Keep the edge-vs-BFF split (ADR-017). Only the public form consolidates into craig-intake; integrated deploys reach it by path-based ingress routing of /report → craig-intake (one hostname), not an HTTP redirect (see L6).

L2

Per-jurisdiction theming (Phase 1). Theming reaches the edge via the shared crate, materialized at boot from the active jurisdiction bundle (mirror craig-web’s active_contribution()contribution.theme) — per-deployment palette, not a hard-coded one. No runtime TOML (ADR-036 §2); the bundle is compiled-in data, not edge state (ADR-017).

L3

JSON panel — PII guarded (Phase 2). Shines-only, config-gated default OFF (CRAIG_INTAKE__DEBUG_EMIT_CPS_REQUEST); shows the exact CpsRequest only when enabled. The payload is third-party and minor PII (the child/adult SSNs the reporter entered about others), reflected to the submitter’s own browser. + Refined (2026-06-26). No compile-time production signal exists in IntakeSettings, so "refuse-to-enable in production" is realized as explicit-ack enablement: the flag is a string that must equal the literal EXPOSE_SSN_PII (a stray =true does not enable it), validate() requires backend_profile == Shines, a loud boot warn! names the exposure, the debug response carries Cache-Control: no-store, the value is never logged (and no response-body logging layer observes it), and the debug field is kept out of the OpenAPI/SDK contract (a craig-intake-local superset that does not derive ToSchema).

L4

Mandated signing — server-side (Phase 4). mandated/professional/law_enforcement reporter types must sign — enforced server-side (/public/v1/reports rejects those types; they must use /signed/v1/reports). UI branching is UX on top, never the gate. Public/anonymous stays unsigned.

L5

i18n + a11y parity gate (gates Phase 5). Consolidation must carry over craig-web’s i18n + accessibility — don’t regress the public safety form. Retiring craig-web’s form (P5) is gated on this parity. Erratum (2026-06-27): a pre-deletion audit found this was read too narrowly — Phase 3 carried i18n/a11y/wizard but the edge NONE/cases form’s field set diverged from craig-web’s (911 banner, is_victim, narrative section, reporter address/relationships, demographics). True parity (incl. the field set) is restored first by the parity remediation before P5 deletes craig-web’s form.

L6

No backwards-compat shims or redirects (Phase 5). craig-web’s /report* endpoints are deleted outright; /report reaches craig-intake via path-based routing at the ingress/reverse-proxy (deployment config, not a craig-web route).

L7

Embedded UI in integrated mode (Phase 3 — gates Phase 5). craig-intake must serve the public UI in integrated mode — today integrated boots embed_ui=false (API-only); the UI mounts only when embed_ui is true. Mounting it in integrated mode is a prerequisite before /report can route to the edge (P5).

Phase map (execution order)

Phase 1 is fully specified in this file (the "Design" + "Steps" sections below); Phases 2-5 are a scoped roadmap, each planned in its own committed .adoc before it is coded. The table shows the whole dependency chain at a glance.

Phase Goal Depends on Risk Rough size

1 Theming foundation

Share the token→CSS pipeline; theme the edge from the active bundle

Low (refactor)

3 issues

2 Integration aids

JSON panel (PII-guarded), nav, view-keys

P1

Low–Med

~3 issues

3 Parity migration

i18n, a11y/wizard, attachments, org-keys, + mount UI in integrated mode (L7)

P1

High (open design)

~6 issues

4 Mandated signing

Record the signer + browser signing + structural server gate (SHINES-scoped); see the committed plan

P1 (+P2 view-keys, P3 wizard)

Medium

4 issues + 2 follow-ups

5 Remove craig-web /report

Delete outright; ingress routes /report to the edge

P3 (incl. L7)

Medium (breaking)

~2 issues

ADRs (land in Phase 1, this MR)

  • ADR-036 — dated amendment (refinement, not reversal; matches ADR-036’s existing 2026-06-23 amendment precedent): the palette→CSS renderer becomes shared in craig-state-bundle and craig-intake becomes a second theme consumer; the §2 invariant (CSS from a compiled-in palette, no runtime TOML) is preserved; §4’s "materialization is BFF-local" is refined to "host-local" (each binary resolves its own active bundle and holds its own Arc<str>; only the pure renderer is shared); the "Out of scope: per-jurisdiction theming of craig-intake" bullet is superseded.

  • ADR-043 — new "craig-intake is the single public intake portal" (supersedes the informal two-surface model): the edge renders the themed/i18n/a11y public UI for all modes, jurisdiction-aware via the active bundle; the embedded UI is mounted in integrated mode (L7); /report reaches the edge by path-based ingress routing, no redirect (L6); craig-web keeps only the authenticated caseworker UI; the service split (ADR-017) is retained; retiring craig-web’s form is gated on i18n+a11y parity (L5).

Design (Phase 1)

The cycle constraint (deviation from the program brief)

The program brief sketched "move active_contribution() into craig-state-bundle so both services resolve identically." That is not possible and the committed plan corrects it: active_contribution()’s helper `candidate_bundles() (services/craig-web/src/bundle.rs:23) names the concrete bundle crates (craig_state_ga::GeorgiaBundle, craig_state_tx_stub::TxStubBundle), which depend on craig-state-bundle. Moving the resolver into the crate would make the crate depend on its own dependents — a dependency cycle.

To resolve the dependency cycle, Phase 1 splits the refactoring into two parts — what can move into the shared crate (cycle-free) versus what must stay per-binary:

  • Shared (cycle-free, moves into craig-state-bundle): the pure renderer materialize_theme_css + its private helpers (render_palette_css, render_block, validate_palette) + the ThemeBootError type. These operate only on &ThemeContribution (already a crate type) and reference no concrete bundle crate.

  • Per-binary (stays, replicated): the active-bundle resolver wrapper (candidate_bundles() + active_contribution()), because it names the concrete bundle crates. resolve_active_bundle is already shared in craig-state-bundle (activation.rs); each binary keeps a thin wrapper over it that lists its own enabled concrete bundles. craig-web keeps its bundle.rs unchanged; craig-intake gets an equivalent bundle.rs of its own (P1.3).

This replication is small (~30 lines) and is the correct cost of the no-cycle rule — the alternative (a registry of Box<dyn StateBundle> constructors in the crate) would re-introduce the cycle or demand a plugin-registration dance ADR-038 already rejected for N=2.

What moves, and the public-API discipline

craig-state-bundle carries ![deny(unreachable_pub)] + ![deny(missing_docs)] (crates/craig-state-bundle/src/lib.rs:3). That gate forces the right shape:

  • materialize_theme_css and ThemeBootError flip from pub(crate) to pub, gain the crate-root pub use re-export beside ThemeContribution (lib.rs:67), and keep their existing /// docs.

  • render_palette_css, render_block, validate_palette stay private to the theme moduleunreachable_pub rejects a pub item with no re-export, so the gate mechanically prevents freezing the helpers into the public API. That is exactly the desired boundary.

  • ThemeBootError uses thiserror; confirm craig-state-bundle’s `[dependencies] carries thiserror (add it if absent — unused_crate_dependencies will flag a stale add, so only add it if the moved code needs it).

The generated-CSS banner comment (theme.rs:124, today "Generated by craig-web from the active state bundle …") becomes service-neutral (e.g. "Generated from the active state bundle (ADR-036 / design/token-schema.adoc)") so both binaries emit byte-identical CSS. The only craig-web output change in this whole phase is that one comment line — behavior is otherwise unchanged.

craig-intake themes from the active bundle (not a hard-coded palette)

The edge resolves + materializes exactly as craig-web does (services/craig-web/src/main.rs:136,167):

// craig-intake boot (standalone today; integrated in P3 per L7)
let (_jurisdiction, contribution) = bundle::active_contribution()?; // intake's own wrapper
let theme_css: Arc<str> = craig_state_bundle::materialize_theme_css(&contribution.theme)?;

So standalone-SHINES resolves to the Georgia / Simple Statehouse palette, and any other deployment resolves its own — per-jurisdiction theming for every deployment, which is the whole point of L2. The Arc<str> is held and served from a new GET /assets/theme.css route in services/craig-intake/src/ui.rs, mirroring craig-web’s theme_css_handler (main.rs:259). Boot fails fast on a structurally-incomplete palette (ADR-036 §8), the same posture as the BFF.

The edge must not gain a runtime toml dependency: craig-state-bundle parses TOML only at build time (its build.rs), so adding it as a runtime dep is safe. The verification step asserts this.

Steps (Phase 1)

Step P1.1 — Plan + ADRs (#709, this MR)

Files: this plan; docs/modules/ROOT/pages/adrs/adr-036-token-and-theme-export.adoc (amendment); docs/modules/ROOT/pages/adrs/adr-043-craig-intake-single-public-portal.adoc (new); docs/modules/ROOT/nav.adoc (ADR-043 index entry + this plan under Plans); CHANGELOG.adoc.

Commit the program plan, the ADR-036 dated amendment, and ADR-043. No code. Iterate the plan through contextless review before merge.

Step P1.2 — Share the renderer (#710)

Spec: ADR-036 §4 amendment (2026-06-25) is the authoritative description of this move; read it alongside this step.

Files: crates/craig-state-bundle/src/theme.rs (receive the renderer + helpers
ThemeBootError), crates/craig-state-bundle/src/lib.rs (re-export materialize_theme_css + ThemeBootError), crates/craig-state-bundle/Cargo.toml (thiserror if needed); services/craig-web/src/theme.rs (delete the moved defs; keep the route handler + AppState.theme_css), services/craig-web/src/main.rs (import the shared materialize_theme_css).

  1. Move materialize_theme_css, validate_palette, render_palette_css, render_block, ThemeBootError, and the CORE_TOKENS const from craig-web’s theme.rs into craig-state-bundle’s `theme.rs. Make materialize_theme_css
    ThemeBootError pub; keep the helpers + const private. Re-export the two pub items from lib.rs.

  2. Make the banner string service-neutral (drop "by craig-web").

  3. craig-web main.rs:167 calls craig_state_bundle::materialize_theme_css(…​); delete craig-web’s local copies. The route handler (theme_css_handler)
    AppState.theme_css are unchanged.

  4. Move every unit test in craig-web’s theme.rs that asserts materialize_theme_css / validate_palette / ThemeBootError behavior (the rendered-string + boot-fail cases — materialize_default_emits_root_and_mode_blocks, materialize_accepts_a_declared_complete_palette, materialize_emits_two_axis_high_contrast_and_select_arrow, generated_css_emits_no_legacy_color_alias, materialize_rejects_a_palette_with_mismatched_modes, materialize_rejects_a_palette_missing_a_core_token). The static-stylesheet lints (static_stylesheets_have_no_raw_hex_colors, static_stylesheets_have_no_legacy_color_var_refs, accent_token_is_never_a_text_color, the strip_block_comments helper) STAY in craig-web — they scan craig-web’s static/css/* and belong to the consumer.

  5. The byte-identical guarantee: craig-web’s /assets/theme.css body changes by only the banner comment line. Confirm via the existing contains tests (they don’t pin the banner) + a manual diff during review.

Step P1.3 — Edge themes from the active bundle (#711)

Files: services/craig-intake/Cargo.toml (add craig-state-bundle + a [features] block + optional craig-state-ga/craig-state-tx-stub), new services/craig-intake/src/bundle.rs (replicated resolver wrapper), services/craig-intake/src/main.rs (resolve + materialize at boot; pass the Arc<str> into the UI layer), services/craig-intake/src/ui.rs (new /assets/theme.css route + handler).

  1. Cargo.toml. Mirror craig-web (services/craig-web/Cargo.toml:15-25):

    [features]
    default = ["state-ga"]
    state-ga = ["dep:craig-state-ga"]
    state-tx-stub = ["dep:craig-state-tx-stub"]
    
    [dependencies]
    craig-state-bundle = { workspace = true }
    craig-state-ga = { workspace = true, optional = true }
    craig-state-tx-stub = { workspace = true, optional = true }
  2. bundle.rs. Replicate craig-web’s services/craig-web/src/bundle.rs (candidate_bundles() + active_contribution() + the compile_error! no-bundle guard + the active_contribution_for test seam). It is a near-verbatim copy — the only per-binary content is which concrete bundles it lists, which is identical to craig-web’s here. (A future refactor could macro this; for N=2 the copy is clearer and avoids the cycle.)

  3. Boot. In the standalone boot path (boot_standalone, main.rs:224 — the only mode that serves the embedded UI today), resolve active_contribution()?, materialize craig_state_bundle::materialize_theme_css(&contribution.theme)? into an Arc<str>. Integrated-mode theming is out of scope for P1.3: integrated boots embed_ui=false (no UI to theme), so it gains the resolve+materialize step only when the UI is mounted in integrated mode (P3 / L7). P1.3 touches the standalone path only.

  4. Thread it through UiConfig. craig-intake already passes runtime state to the UI handlers as a single Extension<UiConfig> (the struct at ui.rs:11, layered at ui.rs:58 via routes(config: UiConfig), read at ui.rs:69). Add a theme_css: Arc<str> field to UiConfig, set it from the materialized string at boot, and have the new handler read it from Extension<UiConfig>. This mirrors craig-web’s AppState.theme_css (main.rs:184,259) and keeps the handler signature to a single existing extractor — prefer this over a separate newtype layer.

  5. Route. Add .route("/assets/theme.css", get(theme_css_handler)) to the UI router (ui.rs:21-58); the handler returns config.theme_css (Arc<str>) with Content-Type: text/css, mirroring serve_intake_public_css (ui.rs:108) but reading the materialized string instead of include_str!.

  6. Boot fails fast on a structurally-incomplete palette (the ? on materialize_theme_css). Add a boot test asserting the GA palette materializes via the replicated active_contribution_for(Some("georgia")) seam.

Files: services/craig-intake/static/css/intake-public.css (hex → var(--token)), services/craig-intake/static/report.html, keygen.html, status.html (link order).

  1. Convert every hardcoded hex in intake-public.css to its semantic token per the map below. Rule: each color maps to its role — backgrounds to --page/--surface/--sunken, text to --body/--heading/--muted/--on-primary, accents to the --primary/--danger/--success/--info/--warning families.

  2. In each HTML file, add <link rel="stylesheet" href="/assets/theme.css"> before the existing <link … href="/static/css/intake-public.css"> (report.html:16, keygen.html:14, status.html:11) so the bundle tokens are defined before the component CSS consumes them.

  3. The edge CSS must end with zero raw hexes outside any documented carve-out (mirror craig-web’s static_stylesheets_have_no_raw_hex_colors lint posture; if a token genuinely has no semantic home, surface it in review rather than inventing one).

hex → token map (complete)

The authoritative, full per-selector mapping of every hex literal in the current intake-public.css. Verified against:

  • the current file (services/craig-intake/static/css/intake-public.css);

  • the 28-token contract (the Theme Token Schema);

  • the live palette (crates/craig-state-bundle/theme/simple-statehouse.toml) — every target token below is confirmed to exist with both light + dark values.

P1.4 applies this map. If the CSS file has diverged by P1.4, surface any uncovered hex in review — do not guess a token.

hex selector(s) / role token

#1a1a1a

body text

--body

#fafafa

body background (page)

--page

#555

.subtitle, .status-box .label

--muted

#ccc

input/select/textarea border

--border

#e0e0e0

.field-group/.child-entry/.adult-entry/.status-box border

--border-soft

#fff

.field-group/.status-box background (surface)

--surface

#fff

.btn-primary text

--on-primary

#1a56db

.btn-primary bg, .btn-add text, nav a

--primary

#1648b8

.btn-primary:hover bg

--primary-hover

#999

.btn-primary:disabled bg

--faint

#eee

.btn-secondary bg

--sunken

#f8f8f8

.child-entry/.adult-entry bg

--sunken

#333

.btn-secondary text

--heading

#e8f0fe

.btn-add bg

--primary-tint

#c00

.required::after, .error, .btn-remove text

--danger

#fca5a5

.errors-box/.error-box border

--danger

#991b1b

.error-box text

--danger-ink

#fee, #fef2f2

.btn-remove/.errors-box/.error-box bg

--danger-surface

#f0fdf4

.success-box bg

--success-surface

#86efac

.success-box border

--success

#166534

.success-box .code text

--success-ink

#fef3c7 / #92400e

.status-pending bg / text

--warning-surface / --warning-ink

#dbeafe / #1e40af

.status-screening bg / text

--info-surface / --info-ink

#d1fae5 / #065f46

.status-converted bg / text

--success-surface / --success-ink

#f3f4f6 / #374151

.status-screened_out bg / text (neutral)

--sunken / --muted

#e0e7ff / #3730a3

.status-forwarded bg / text

--primary-tint / --primary

A multi-color rule tokenizes every color in place — e.g. .error-box { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; } becomes .error-box { background: var(--danger-surface); border: 1px solid var(--danger); color: var(--danger-ink); }.

Phases 2-5 (scoped roadmap)

These are not binding specs. Each phase gets its own committed .adoc, iterated through contextless review, before it is coded — that phase-specific plan is the authority when it lands. This roadmap exists only to orient the program and to record the agreed shape; rough issue weights are in parentheses.

Phase 2 — Integration aids (deps: P1)

  • JSON debug panel (L3, PII guarded): capture the mapped CpsRequest in ShinesSink::forward (after mapper::map, before the POST) as a pretty String. Do not add it to the backend-agnostic AcceptedReport (that would leak an optional debug field into every public response schema); carry it via a Shines-scoped seam and let the handler attach it to the Shines response only, under L3’s guards (explicit-ack enablement, Cache-Control: no-store, never logged, kept out of the OpenAPI/SDK contract). UI panel below the success card + a CSP-safe copy-helper.js (clipboard + execCommand fallback; secure-context note).

  • Nav + view-keys: report/keygen nav gains "Register a Key" / "View Keys" (Shines-gated); a new keyring GET /keys (metadata only, default-off gate) + an intake proxy + a view-keys page.

  • Rough issues: JSON-panel (3), nav + view-keys-endpoint (3), view-keys-UI (2).

Phase 3 — Parity migration (deps: P1; the hard one)

Done (2026-06-26) — see the archived Phase-3 plan + ADR-044. The design spike resolved to a shared craig-i18n crate (Fluent), and the org-keys endpoints were descoped (the prior /public/v1/keys* sketch was never built + a public org directory contradicts ADR-017 §D5; ADR-044 reconciles where central partner-org key registration lives instead). The list below is the original sketch, superseded by the committed plan.
  • Design spike first: the i18n strategy — Askama server-render in craig-intake vs. a client-side JSON message-catalog (the edge serves static include_str! HTML; craig-web has ~312 report- keys + the |t Fluent infra). Decide + document before building. *(Resolved: shared craig-i18n crate — ADR-044.)

  • Then: migrate the i18n keys; bring accessibility + the staged-wizard UX up to craig-web’s level; add an attachments UI; mount the embedded public UI in integrated mode (L7) — enable embed_ui for integrated.

  • Issues (per the committed plan): i18n-infra (5), edge-i18n (5), a11y/wizard (5), attach-backend (3), attach-UI (2), embed-ui-integrated (2).

Phase 4 — Mandated-reporter signing (deps: P1; benefits from P2 view-keys + P3 wizard)

Detailed design + the resolved decisions: the committed Phase-4 plan + the ADR-043 2026-06-27 amendment. SHINES-scoped + a structural soft gate (the public form rejects mandated exactly where the Signed backend is wired); integrated individual signing is a deferred follow-up, and Phase 5 is not gated on it.

  • P4.1 signer-evidence (backend): /signed/v1/reports records the verified signer (a re-verifiable report.signed audit event + a signer field on the forward) instead of discarding it — the actual non-repudiation value. SHINES has no report store, so this is what makes signing accountable.

  • P4.2 signing-UI: /report’s `mandated branch signs in-browser to the SDK header contract ({alg,kid,jti,iat} — craig-web’s helper omits jti/iat) via a type=module bridge, POSTs /signed/v1/reports; a required mandated_reporter_category; a kid-bearing key file.

  • P4.3 approval-UX: register → pending → operator-approve → sign, with per-kid status (the keyring by-kid active lookup), graceful "awaiting approval" handling, and key re-import.

  • P4.4 server-gate (last): Signed/public rejects mandated (422), defense-in-depth behind the UI.

  • Issues (per the committed plan): signer-evidence (3), signing-UI (5), approval-UX (3), server-gate (2); +2 deferred follow-ups (integrated individual signing; keyring-receipt hardening).

Phase 5 — Remove craig-web /report (deps: P3 parity complete, incl. L7)

  • Delete outright — no redirect, no compat shim (L6): remove public_report_routes(), routes/report.rs, templates/report/, report-wizard.js, the report- locale keys, clients/intake.rs (verify no other consumer, then delete), and the now-unused intake_url config. /report reaches the edge by *path-based ingress routing (not an HTTP redirect) — requires L7 (integrated intake already serving the UI). Retarget the public-report* e2e specs
    screenshots to the edge. Update nav + architecture.adoc / services.adoc / deployment-guide + ADR-043.

  • Rough issues: strip-craig-web-public-report (3), e2e + docs retarget (3).

Files Touched (Phase 1)

File Change

docs/…​/plans/craig-intake-portal-consolidation.adoc

This plan (P1.1)

docs/…​/adrs/adr-036-token-and-theme-export.adoc

Dated amendment: shared renderer + intake as second consumer (P1.1)

docs/…​/adrs/adr-043-craig-intake-single-public-portal.adoc

New ADR (P1.1)

docs/modules/ROOT/nav.adoc

ADR-043 index entry + this plan under Plans § Active (P1.1)

crates/craig-state-bundle/src/theme.rs + lib.rs + Cargo.toml

Receive the renderer + helpers + ThemeBootError; re-export the two pub items (P1.2)

services/craig-web/src/theme.rs + main.rs

Delete moved defs → import shared; keep route handler + AppState.theme_css (P1.2)

services/craig-intake/Cargo.toml

craig-state-bundle dep + [features] + optional bundle crates (P1.3)

services/craig-intake/src/bundle.rs (new) + main.rs + ui.rs

Replicated resolver; boot materialize; /assets/theme.css route (P1.3)

services/craig-intake/static/css/intake-public.css

hex → var(--token) (P1.4)

services/craig-intake/static/{report,keygen,status}.html

Link /assets/theme.css before intake-public.css (P1.4)

CHANGELOG.adoc

== Unreleased entries (each Phase-1 MR)

Verification (Phase 1)

cargo fmt --all
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run -p craig-state-bundle -p craig-web -p craig-intake
# The edge must gain NO runtime toml from craig-state-bundle (intake already pulls
# toml via `config`; craig-state-bundle must NOT appear among toml's runtime dependants):
cargo tree -p craig-intake -e normal -i toml
cargo xtask plan-lint && cargo xtask check-docs
cargo xtask e2e --no-refresh -- --project=intake-ui-shines

Assertions:

  • standalone /report + /keygen + /report/status render in the active bundle’s palette, and the OS prefers-color-scheme flip applies (it didn’t before);

  • craig-web is visually unchanged — its /assets/theme.css differs only by the banner comment line;

  • intake-public.css has zero raw hexes outside any documented carve-out;

  • the intake-ui-shines e2e project stays green (tests/e2e/specs/intake-embedded-ui-shines.spec.ts, tests/e2e/specs/intake-keygen-signed.spec.ts).

Process & conventions (program-wide)

  • Epic first, then child issues with weights (1/2/3/5/8) + type+priority labels + epic_id linkage (/relate for siblings); one issue per shippable unit.

  • Each phase: a committed .adoc (nav PlannedActive), iterated through 3-4 contextless review rounds before coding; branch + MR per issue (standard cadence); Closes #N + a closing comment; plan Status flips after each step (canonical tokens only — each MR flips the previous MR’s step to Done on its branch).

  • Multi-MR: only a phase’s final MR touches .claude/CLAUDE.md status tables, and only if that phase changed a documented status surface (e.g. P5). Earlier MRs update Antora docs.

  • Mid-execution discoveries → a GitLab issue + /relate, never a "future work" section here. Plans are specs; the tracker is the backlog.

  • On program completion: archive each phase plan (nav → Archive), run the Plan Completion Audit.

Documentation Updates

Edit this page · latest