Plan: craig-intake Portal — Phase 3 (Parity Migration)

On this page

Phase 3 of the single-public-portal program (epic &60). Each issue ships as its own feature/ branch + MR (full pre-push validate + J1–J8). The final MR flips the program-brief P3 row → Done and archives this plan. The i18n + attachment decisions are recorded in ADR-044. Iterated through three contextless review rounds + an external code-grounded review; 4 design decisions locked.

Status

Step Description Status

P3.1 i18n-infra

Extract crates/craig-i18n (Fluent engine) + refactor craig-web onto it + ADR-044

Done (2026-06-26) — #716

P3.2 edge-i18n

Edge adopts craig-i18n: Askama bring-up + locale routing + externalize ~250–300 strings (en-only)

Done (2026-06-26) — #717

P3.3 a11y/wizard

Staged 6-step wizard + a11y parity, extending the axe audit to the edge UI

Done (2026-06-26) — #718

P3.4 attach-backend

Public POST attachments endpoint (cases-backed only; thin forward to cases)

Done (2026-06-26) — #719

P3.5 attach-UI

Post-submission upload UI (hidden under SHINES)

Done (2026-06-26) — #720

P3.6 embed-ui-L7

Mount the embedded themed UI in integrated mode (runs LAST, after parity)

Done (2026-06-26) — #721

Epic: &60
Sequencing: P3.1→P3.2→P3.3 ; P3.4→P3.5 ; P3.6 LAST (after P3.2/P3.3/P3.5)
Branch convention: feature/Phase 3 of the craig-intake single-public-portal program (epic &60) — bring the standalone edge to parity with craig-web’s public report wizard (i18n machinery via a shared craig-i18n crate, a staged accessible wizard, public attachments, and mounting the embedded UI in integrated mode), so the edge can become THE public portal and craig-web’s /report can be deleted in Phase 5. per issue.

Context

Phases 1–2 made the standalone edge themed (P1) + gave it integration aids (P2). It is not at parity with craig-web’s public report wizard — the blocker to the program goal (the edge becomes THE public portal; craig-web’s /report is deleted in Phase 5 / L6). The four parity gaps, all code-verified:

Gap Edge today craig-web has

i18n

static include_str! HTML, ~250–300 hardcoded English strings, no machinery

Fluent + Askama (locales/en/public.ftl, 247 keys)

wizard / a11y

single page, submit-only validation, non-aria-live errors

6-step wizard, focus mgmt, ARIA landmarks

attachments

no public route (partner attachments exist under /partner/v1)

a public upload proxy (currently 404s — the edge route does not exist)

integrated UI

embed_ui=false (not mounted)

n/a

The whole repo is en-only — no es/ catalog exists. So "i18n parity" = give the edge the same localizable machinery (en, externalized keys); authoring a second language is a separate tracked effort.

Decisions resolved (→ ADR-044)

  1. i18n = a new shared craig-i18n crate (Fluent). Extract craig-web’s engine; both services consume it (one engine, one catalog). The public catalog outlives craig-web’s public side (Phase 5).

  2. Public attachment uploads (locked by review): capability = the report id (UUID v7, unguessable) in the path, no extra token (matches the public status read; fits the stateless edge); MIME/size validation stays in craig-cases via the craig-store default policy (which allows images/CSV/Excel — the edge does not duplicate or restrict it); POST-only, no list (a reporter only ever sees the status enum); the edge handler forwards to cases with a service token (the partner path’s S2S pattern). Cases-backed only.

  3. Partner-org key registration gap (NOT gating). craig-web’s /report/keys (the ADR-010 §38 mechanism for a partner organization to register its JWS signing key) proxies to never-built intake endpoints and dead-ends; the edge keygen is SHINES-only (ui.rs:62). After Phase 5 deletes craig-web’s /report, a central deployment loses that UI. ADR-044 records where central partner-org key registration should live (revive an endpoint, or move to the authenticated craig-security partner-admin UI) as a tracked follow-up — not a Phase-5 blocker.

Design

P3.1 — Extract craig-i18n + refactor craig-web

Goal: a shared Fluent engine both services use; craig-web’s worker UI unchanged.

  • Crate crates/craig-i18n owns: the Fluent pipeline (I18n::load/translate, TerminologyBootError), the task_local! context, a pure negotiate_locale(i18n, query, candidates, accept_language), and a new locale_layer Axum middleware (extracted from craig-web’s inline set_locale). All pub.

  • Encapsulation: expose a has_message(default_locale, key) → bool (and message(…​)) accessor — do not keep I18n’s `HashMap fields pub (the only consumer is plugins.rs:161’s `i18n.messages.get; refactor it to the accessor so the shared-crate API does not bake in the map layout).

  • Shared public.ftl via include_dir!/include_str! embedded in the crate, loaded as a disk-equivalent bundle (NOT the apply_terminology_overlay worker-terms path). web.ftl/common.ftl stay in craig-web.

  • craig-web refactor: i18n.rs → a re-export shim; resolve_locale computes the session-cookie candidate then calls negotiate_locale. Arc<str> cache + TerminologyContribution overlay move verbatim.

Verify: a new key-coverage test — every |t key in craig-web’s templates resolves in en (needed because translate() silently falls back to the key string); nextest -p craig-web -p craig-i18n green; locale-precedence tests re-run.

P3.2 — Edge adopts craig-i18n (en-only readiness)

Goal: the edge renders localizable via the shared engine; no second language shipped.

  • Askama bring-up (the edge has none): add askama/askama_web (axum-0.8) + askama.toml (dirs=["templates"]) + templates/ + a per-service filters.rs t() shim. Convert the 4 include_str! files → Askama templates, wrapping label/option text in t(). Alpine attrs (x-show, $store.relevance) emit as literal text → #715 per-profile nav + relevance store untouched.

  • JS strings (report-form.js, status-check.js, view-keys.js): a new /ui/i18n.js route emitting window.CRAIG_I18N = {…} as an external script, serialized with serde_json::to_string (NOT hand-built string formatting — translated strings contain quotes/HTML; do not copy `serve_ui_config’s string-format).

  • Boot: load I18n inside the embed_ui resolve block (reuse the fetched contribution) from the crate’s embedded catalog; add the session-less locale_layer (?lang=→Accept-Language→default) + a default_locale setting; <html lang="\{{ locale }}">.

  • Externalize the edge strings into the shared public.ftl (reuse matching keys; new edge-specific keys take an intake- prefix). *en only.

    Implementation deviation (2026-06-26). All edge strings — shared report-/public- AND edge-specific intake- — were placed in the *single shared crates/craig-i18n/catalog/en/public.ftl, NOT a separate edge-only intake.ftl. A separate embedded edge catalog would have required a new craig-i18n API to fold an extra embedded source into I18n::load (just after that crate’s P3.1 merge) for marginal benefit; one catalog
    one load path (I18n::load with no on-disk locales/ → embedded public only) is lower-risk and the single source of truth. craig-web loads the unused intake-* keys harmlessly (its coverage gate only checks craig-web template keys). 184 unique strings: 72 reused existing keys, 112 new.

Verify: e2e --project=intake-ui[-shines] green (the en form renders via Askama; #715 nav + theme intact); CSP/security-headers tests unchanged; /ui/i18n.js serves valid JSON; a hardcoded-visible-text audit (scan the edge templates/ + static/js/ for non-t() English) + an *intake template/JS key-coverage gate — "every |t key resolves" alone will not catch un-wrapped English.

Risk: no es report-* catalog exists and validate_bilingual_coverage (i18n.rs:152) does not protect public keys — so this ships en-only machinery. Authoring es (~247 keys) is a tracked follow-up.

P3.3 — Staged wizard + a11y parity

Goal: match craig-web’s 6-step wizard + a11y, with the edge UI actually audited.

  • Mirror craig-web’s wizard pattern (report-wizard.js + form.html) onto the edge’s own reportForm component + fetch submit + $store.relevance model — step state, nextStep()/prevStep()/goToStep(n) (forward only if validateStep() passes), per-step validation, progress nav, read-only review step.

  • a11y: role=banner/navigation/main, role="alert" aria-live="polite" errors, $nextTick() focus to the step heading, :aria-current, :required (relevance-driven), .skip-link. Keep the #715 nav
    $store.relevance gating across steps.

Verify: extend the axe audit to the edge — an env-guarded Edge block in accessibility-audit.spec.ts walks the edge report wizard (None + SHINES) to multiple steps + audits status/keys/keygen so the WCAG 2.1 A/AA gate covers the edge UI; e2e (wizard nav, per-step validation, focus on advance).

As built (deviations from the sketch above).

  • Step map (6 steps both profiles): 1 Reporter · 2 Incident · 3 Adults · 4 Children · 5 Additional information · 6 Review. The edge has no standalone "Narrative" step (craig-web’s step 5): the narrative questions are part of the profile-gated SHINES block, so step 5 holds additional_info (always shown) plus the SHINES block (x-show profile-gated). additional_info moved out of Incident into step 5 so the None-profile step 5 is never empty.

  • Honeypot stays the existing off-screen .honeypot (visible label + tabindex="-1"), not craig-web’s .sr-only aria-hidden wrapper — aria-hidden around a focusable input is itself a WCAG violation (aria-hidden-focus).

  • Step headings carry tabindex="-1" so the $nextTick() focus actually lands (craig-web’s headings lack it, so its focus call is a no-op) — a deliberate a11y improvement.

  • Landmarks (skip-link + header[role=banner] + main) added to all four public templates (report/status/keys/keygen), not only the report form, so the whole edge UI is landmark-structured.

  • [x-cloak]\{display:none\} added to intake-public.css so the six steps do not flash before Alpine hydrates.

P3.4 — Public attachments endpoint (cases-backed; POST-only)

Goal: create the public upload route craig-web already proxies to (it 404s today).

  • Confirmed missing: attachments::routes() is partner-only (partner.rs:24); the public nest (public.rs:80) has none.

  • Add a new public POST /public/v1/reports/{id}/attachments (submit_attachment). The handler forwards the multipart to craig-cases with a service token — the partner path’s pattern (http.post(url).with_service_identity(sink.service_token()).multipart(form)). No validation in intake — cases enforces MIME (craig-store default, images allowed) + the 10 MB/file cap; there is no per-report file-count cap today and we do not add one. Capability = the report id in the path (UUID, unguessable); no extra token.

  • Mount gating: add a mount_attachments: bool to build_router (mirror mount_status), true only for the cases-backed (None) profile; SHINES (BackendWiring::Signed, no cases backend) does not mount it.

  • Cleanup (per "no list"): remove craig-web’s now-dead GET /report/{code}/attachments list route
    list_attachments handler (it proxies to a non-existent list endpoint; reporters only ever see the status enum).

Verify: integration test (accept + a too-large reject via cases); None-profile e2e upload round-trip; the route is absent (404) under SHINES.

As built (deviations from the sketch above).

  • Mount gate = the cases backend, i.e. BackendProfile::None (integrated and standalone-None), not standalone-None alone. The plan’s "true only for the cases-backed (None) profile" resolves to both cases-backed boots — they are the None backend profile — exactly matching mount_status (true for integrated + standalone-None, false for SHINES). Integrated must mount it because the craig-web BFF proxies /report uploads to this public endpoint; SHINES has no cases backend, so the route is absent (404).

  • Gate carrier: instead of a bare mount_attachments: bool, build_router takes attachments_sink: Option<CasesForwarderSink>Some both mounts the route AND supplies the concrete sink it forwards over, so a mount-without-sink mismatch is impossible. The route + the Extension(sink)/Extension(http_client) layers move together.

  • Shared forward: the partner and public upload handlers now share a private forward_upload_to_cases helper (form-build → S2S POST → relay); the ONLY difference between them is the authorization done before the call (partner = API-key + ownership; public = the report-id capability, anonymous).

  • build_router params bundled into a RouterConfig struct (the new arg crossed the 7-arg lint) — the owned wiring travels as one struct alongside the borrowed settings.

  • craig-web cleanup: the combined get(list_attachments).post(upload_attachment) route becomes POST-only (list_attachments handler removed); the confirmation page’s upload form (POST) is unaffected and now reaches a live endpoint.

P3.5 — Attachments upload UI

Goal: the success-view upload form.

  • Mirror confirmation.html’s upload form (a `file input + a submit), posting multipart/form-data to the new edge route, with success/error feedback. The accept hint can list common types, but the server policy is cases/store default (images allowed). No list view. Hidden when $store.relevance.backend_profile === 'shines'.

Verify: None-profile e2e upload; the form is absent under SHINES.

As built (#720, 2026-06-26)
  • The form lives on the edge report form’s x-if="submitted" success card (the edge is a fetch-based Alpine SPA, not a server-rendered page like craig-web’s confirmation.html), so it POSTs via fetch to /public/v1/reports/${reportId}/attachments rather than a native <form action>. The file is read from an x-ref input; feedback is an aria-live="polite" line styled success/error via a :class ternary.

  • Honest policy copy: the accept hint and help text reflect the actual cases/craig-store default (PDF, images, Word, Excel, plain text, CSV; 10 MB each) — intentionally broader than craig-web’s "no photographs" copy, since the store default allows images.

  • i18n: eight en keys under a new # intake/attach section in the shared public.ftl; the three JS-looked-up keys registered in JS_I18N_KEYS (the i18n-coverage gate enforces both template + JS keys resolve). es tracked in #722.

P3.6 — Mount the embedded UI in integrated mode (L7) — runs LAST

Goal: integrated deployments serve the parity edge UI (not the degraded one).

  • Dependency (review fix): sequence P3.6 after P3.2/P3.3/P3.5 — flipping embed_ui=true earlier would expose the current static, single-page, non-i18n, no-attachment UI in integrated mode.

  • Flip integrated boot to embed_ui=true (main.rs:213). The per-binary resolver already exists (active_contribution() materializes /assets/theme.css in standalone, main.rs:468), so integrated reuses it + the UI-enabled CSP. Resolver stays per-binary (dep-cycle, ADR-036 §4). Does not remove craig-web’s /report (Phase 5).

Verify: integrated instance serves the themed parity UI; e2e; CSP UI-enabled (not default-src 'none').

As built (#721, 2026-06-26)
  • One-line boot flip + one env. boot_integrated sets embed_ui=true; the shared build_router embed-UI block (theme + i18n + locale layer + UI routes + UI-enabled CSP) is mode-agnostic and reused verbatim. The one required infra change: the integrated craig-intake devstack service now carries CRAIG__ACTIVE_STATE_BUNDLES (it was standalone-only; active_contribution() fail-fasts without it — caught before validate, not at runtime).

  • e2e wiring already existed. CRAIG_INTAKE_URL (the integrated instance, port 8008) was already injected into the Playwright container by xtask, so a new intake-ui-integrated project needed no xtask change. The spec is deliberately thin (form served + theme.css + UI-enabled CSP) — the integrated instance runs the same binary/templates the intake-ui project exhaustively covers, so re-testing the wizard would be redundant.

  • The integrated-instance CSP security-headers integration test (security_headers.rs) now exercises its embedded-UI branch (previously the API-only default-src 'none' path); the assertion was already branch-guarded, so only its comment needed updating.

  • CSP test reconciliation (caught by validate). health::healthz_carries_documented_csp_api_mode pinned the integrated /healthz to the API-only CSP — now wrong, since integrated emits the embedded-UI CSP. Retargeted to the embedded-UI assertion (renamed integrated_mode). Because every deployed intake mode now mounts the UI, the shared craig_test_lib::csp const/helpers were renamed INTAKE_STANDALONE_CSPINTAKE_EMBED_UI_CSP / assert_intake_standalone*assert_intake_embed_ui_* (the "standalone" name became a misnomer); security.adoc § CSP was corrected to match (it also still claimed the UI CSP carried 'unsafe-inline', stale since #414).

Files Touched (summary)

Area Change

crates/craig-i18n/ (new)

Fluent engine + has_message accessor + embedded public.ftl (P3.1)

craig-web i18n.rs/filters.rs/middleware.rs/plugins.rs

Refactor onto the crate; shim; negotiate_locale; use the accessor (P3.1)

craig-web main.rs/routes/report.rs

Remove the dead GET …/attachments list route + handler (P3.4)

craig-intake Cargo.toml + new askama.toml/templates//filters.rs

Askama bring-up (P3.2)

craig-intake ui.rs/main.rs, static/.htmltemplates/, static/js/, embedded intake.ftl

Edge i18n + wizard + JS i18n (P3.2/P3.3)

craig-intake api/public.rs + main.rs (mount_attachments)

Public POST attachments forward to cases (P3.4)

craig-intake main.rs:213

embed_ui=true for integrated (P3.6)

tests accessibility-audit.spec.ts

Extend axe to the edge UI (P3.3)

adrs/adr-044-*.adoc (new); ADR-010 §38; program brief

Decisions + reconciliation

New deps to clear (cargo deny/musl/AGPL): askama,askama_web (intake); fluent-bundle,fluent-syntax, unic-langid[workspace.dependencies] (already vetted in craig-web).

Verification (whole phase)

cargo fmt --all && cargo clippy --workspace --all-targets --locked -- -D warnings
cargo nextest run -p craig-i18n -p craig-web -p craig-intake
cargo xtask dev reload && cargo xtask e2e --no-refresh -- --project=intake-ui --project=intake-ui-shines
cargo xtask e2e --no-refresh -- --project=accessibility        # WCAG gate (P3.3) — now covering the edge UI
cargo xtask plan-lint && cargo xtask check-docs

Documentation Updates

  • ADR-044 (new): the craig-i18n decision + catalog mechanism + the public-attachment capability/validation model + the partner-key reconciliation (supersedes ADR-010 §38; the central-registration home is tracked, not gating Phase 5).

  • CHANGELOG.adoc == Unreleased per MR; Shared Crates (craig-i18n); Standalone Intake — Backend-Profile Architecture (edge i18n + wizard + attachments); the program-brief Phase-3 row.

  • Tracked follow-ups filed now: (a) author the es public.ftl (~247 keys); (b) central partner-org key registration home (gated on the ADR-044 decision; not a Phase-5 blocker).

Delivery sequence

  1. Plan + ADR MR — commit this plan (nav Active) + ADR-044 + the program-brief update; file the 6 issues + 2 follow-ups under epic &60.

  2. Code, one MR each — P3.1→P3.2→P3.3 ; P3.4→P3.5 ; then P3.6 (full validate + J1–J8; Closes #N + closing comment; flip the prior step’s Status).

  3. Final MR — program-brief P3 → Done; archive this plan; Plan Completion Audit.

Edit this page · latest