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 |
Done (2026-06-26) — #716 |
P3.2 edge-i18n |
Edge adopts |
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 |
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 |
Fluent + Askama ( |
wizard / a11y |
single page, submit-only validation, non- |
6-step wizard, focus mgmt, ARIA landmarks |
attachments |
no public route (partner attachments exist under |
a public upload proxy (currently 404s — the edge route does not exist) |
integrated UI |
|
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)
-
i18n = a new shared
craig-i18ncrate (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). -
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.
-
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-i18nowns: the Fluent pipeline (I18n::load/translate,TerminologyBootError), thetask_local!context, a purenegotiate_locale(i18n, query, candidates, accept_language), and a newlocale_layerAxum middleware (extracted from craig-web’s inlineset_locale). Allpub. -
Encapsulation: expose a
has_message(default_locale, key) → bool(andmessage(…)) accessor — do not keepI18n’s `HashMapfieldspub(the only consumer isplugins.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.ftlviainclude_dir!/include_str!embedded in the crate, loaded as a disk-equivalent bundle (NOT theapply_terminology_overlayworker-terms path).web.ftl/common.ftlstay in craig-web. -
craig-web refactor:
i18n.rs→ a re-export shim;resolve_localecomputes the session-cookie candidate then callsnegotiate_locale.Arc<str>cache +TerminologyContributionoverlay 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-servicefilters.rst()shim. Convert the 4include_str!files → Askama templates, wrapping label/option text int(). 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.jsroute emittingwindow.CRAIG_I18N = {…}as an external script, serialized withserde_json::to_string(NOT hand-built string formatting — translated strings contain quotes/HTML; do not copy `serve_ui_config’s string-format). -
Boot: load
I18ninside theembed_uiresolve block (reuse the fetchedcontribution) from the crate’s embedded catalog; add the session-lesslocale_layer(?lang=→Accept-Language→default) + adefault_localesetting;<html lang="\{{ locale }}">. -
Externalize the edge strings into the shared
public.ftl(reuse matching keys; new edge-specific keys take anintake-prefix). *enonly.Implementation deviation (2026-06-26). All edge strings — shared report-/public-AND edge-specificintake-— were placed in the *single sharedcrates/craig-i18n/catalog/en/public.ftl, NOT a separate edge-onlyintake.ftl. A separate embedded edge catalog would have required a newcraig-i18nAPI to fold an extra embedded source intoI18n::load(just after that crate’s P3.1 merge) for marginal benefit; one catalog
one load path (I18n::loadwith no on-disklocales/→ embedded public only) is lower-risk and the single source of truth. craig-web loads the unusedintake-*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 ownreportFormcomponent +fetchsubmit +$store.relevancemodel —stepstate,nextStep()/prevStep()/goToStep(n)(forward only ifvalidateStep()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.relevancegating 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).
|
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: booltobuild_router(mirrormount_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}/attachmentslist route
list_attachmentshandler (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).
|
P3.5 — Attachments upload UI
Goal: the success-view upload form.
-
Mirror
confirmation.html’s upload form (a `fileinput + a submit), postingmultipart/form-datato the new edge route, with success/error feedback. Theaccepthint 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)
|
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=trueearlier 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.cssin 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)
|
Files Touched (summary)
| Area | Change |
|---|---|
|
Fluent engine + |
craig-web |
Refactor onto the crate; shim; |
craig-web |
Remove the dead |
craig-intake |
Askama bring-up (P3.2) |
craig-intake |
Edge i18n + wizard + JS i18n (P3.2/P3.3) |
craig-intake |
Public POST attachments forward to cases (P3.4) |
craig-intake |
|
tests |
Extend axe to the edge UI (P3.3) |
|
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-i18ndecision + 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== Unreleasedper 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
espublic.ftl(~247 keys); (b) central partner-org key registration home (gated on the ADR-044 decision; not a Phase-5 blocker).
Delivery sequence
-
Plan + ADR MR — commit this plan (nav Active) + ADR-044 + the program-brief update; file the 6 issues + 2 follow-ups under epic &60.
-
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). -
Final MR — program-brief P3 → Done; archive this plan; Plan Completion Audit.