ADR-044: Shared craig-i18n engine, public-portal attachments, and partner-key reconciliation

On this page

Status

Accepted (2026-06-26). Drives Phase 3 of the craig-intake portal program (epic &60). Supersedes the partner-key registration mechanism named in ADR-010 §38.

Context

The program (ADR-043) makes the stateless edge (craig-intake) the single public reporting portal; craig-web’s /report is deleted in Phase 5 once i18n + a11y parity is met. Three architectural questions block that parity work, each surfaced + verified during Phase-3 planning:

  1. i18n duplication. craig-web localizes via Mozilla Fluent + an Askama t() filter, with a ~624-line engine in services/craig-web/src/i18n.rs (Fluent bundle load, pre-resolution into an Arc<str> cache, a task_local! request context, locale negotiation) and locales/en/{public,web,common}.ftl (247 report-/public- public keys). The edge has no i18n machinery — static include_str! HTML + plain Alpine with ~250–300 hardcoded English strings. Both services need i18n long-term (the edge for the public portal, craig-web for the authenticated worker UI), and the public catalog must outlive craig-web’s public side. The repo is en-only today — there is no second-language catalog anywhere.

  2. Public attachments. craig-web exposes a public attachment-upload proxy to intake/public/v1/reports/{id}/attachments, but that edge route does not exist — attachments are mounted partner-only under /partner/v1. craig-cases is the validation source of truth (MIME via the craig-store default policy, which allows images/CSV/Excel; a 10 MB/file cap; no per-report file-count cap) and requires a bearer/worker token. The only credential a public reporter holds is the report id (UUID v7 for cases, unguessable).

  3. Partner-org key registration. craig-web’s /report/keys page — named the canonical partner-key registration mechanism by ADR-010 §38 — lets a partner organization register its JWS signing public key, proxying to the same never-built intake /public/v1/keys* endpoints (it dead-ends). The edge keygen page is SHINES-only. When Phase 5 deletes craig-web’s /report, a central (integrated) deployment loses its partner-org key-registration UI.

Decision

1. Extract a shared craig-i18n crate (Fluent)

Create crates/craig-i18n owning the Fluent pipeline, the task_local! request-locale context, a pure negotiate_locale(i18n, query, candidates, accept_language), and a reusable Axum locale_layer middleware. Both craig-web and craig-intake consume it; craig-web’s i18n.rs collapses to a re-export shim (the worker UI must render byte-identical). The crate exposes a has_message/message accessor rather than pub HashMap fields (the shared API must not bake in the internal map layout). The shared public catalog (public.ftl) is embedded in the crate via include_dir!/include_str! and loaded as a disk-equivalent bundle (distinct from the apply_terminology_overlay jurisdiction-worker-terms path); web.ftl/common.ftl stay craig-web-local. The fluent-bundle/fluent-syntax/unic-langid deps move to [workspace.dependencies]. The edge adopts Askama for its public templates and consumes the crate; this delivers en-only localizable machinery — authoring a second language (es, ~247 keys) is a separate tracked effort, not part of this decision.

2. Public-attachment capability + validation model

The edge gains a public POST /public/v1/reports/{id}/attachments that forwards the multipart to craig-cases with a service token (the partner path’s S2S pattern). Specifically:

  • Capability = the report id in the path (UUID v7, unguessable) — no separate upload token. This matches the public status read and fits the stateless edge (ADR-017): introducing a stored/verified upload token would add edge state. The id is a bearer capability (an unguessable URL).

  • Validation stays in craig-cases via the craig-store default MIME policy (images/CSV/Excel/PDF/DOC/TXT allowed) + the 10 MB/file cap. The edge does not duplicate or restrict the policy — a single source of truth avoids drift. There is no per-report file-count cap (none exists today; none is added).

  • POST-only, no list. A public reporter only ever sees the report status enum (pending/screened_out/ screened_in); there is no attachment-listing surface. craig-web’s now-dead GET …/attachments list route is removed.

  • Cases-backed only. The route is gated by a mount_attachments flag (mirroring mount_status), mounted only under the cases-backed (None) profile. SHINES (BackendWiring::Signed, no cases backend) does not mount it.

3. Partner-org key registration reconciliation (supersedes ADR-010 §38)

Central (integrated) partner-organization signing-key registration must have a home once craig-web’s /report is deleted. This ADR supersedes ADR-010 §38 (which named the dead /report/keys page) and records the two viable homes: (a) revive a purpose-built registration endpoint/UI, or (b) move registration to the authenticated craig-security partner-admin surface (where partner_signer_keys already live). Selecting + building one is a tracked follow-up, not a Phase-5 blocker — the owner accepts a transitional window. The standalone-SHINES keygen path (the keyring, ADR-042 §D8–D9) is unaffected.

Consequences

  • Positive: one i18n engine + one public catalog across both public-facing surfaces (no duplication, no forked client/server i18n); the edge becomes localizable + attachment-capable; the public-attachment model has a single validation source of truth and no new edge state; the partner-key gap is explicit + owned.

  • Negative / accepted: Phase 3 ships en-only machinery (no translation yet); a public unauthenticated upload endpoint (capability = unguessable id) accepts images per the store default (an accepted abuse-surface trade-off, chosen for utility — reporters may attach injury photos); a transitional window exists where central partner-key registration has no UI.

  • Scope: the shared-crate extraction touches craig-web’s i18n surface (mitigated by the re-export shim + a key-coverage test, since translate() silently falls back to the key string).

Open questions

  • The home for central partner-org key registration (revive endpoint vs. craig-security partner-admin) — deferred to the tracked follow-up; must be resolved before Phase 5 deletes craig-web’s /report.

  • Whether a per-report attachment-count cap is eventually warranted (would require transactional enforcement in craig-cases).

Alternatives considered

  • i18n — edge-local Fluent (duplicate the engine): contained blast radius but two copies of the engine to keep in sync; rejected for the maintainability cost (the shared crate is the proper refactor).

  • i18n — client-side JSON catalog on the edge: lightest, keeps the edge static, but forks the i18n system (Fluent server-side in craig-web vs. JSON client-side on the edge) and the catalog format — worst on long-term maintainability; rejected.

  • Attachments — a distinct upload token returned at submit: more defense-in-depth for a write, but requires edge-side token state (conflicts with ADR-017) + a UX step; rejected in favor of the id-as-capability model.

  • Attachments — restrict public uploads to documents (no images): mitigates anonymous image-upload abuse, but the store default already allows images and a reporter may legitimately attach an injury photo; rejected for utility (owner’s call), accepting the abuse surface.

  • ADR-043 — the single-public-portal program this phase advances.

  • ADR-017 — the stateless edge (why the attachment endpoint forwards
    uses an id capability rather than stored token state).

  • ADR-010 — §38 (partner-key registration) is superseded here.

  • ADR-036 §4 — the per-binary bundle resolver (dep-cycle) that P3.6 reuses for integrated-mode theming.

Amendment — #1332 craig-web worker-UI locale pin (2026-08-17)

Decision 1’s "delivers en-only localizable machinery" note aged out when #722 made the embedded public catalog bilingual: the embedded es catalog injects an es bundle into every consumer’s available_locales, which silently made es a negotiable locale for craig-web’s authenticated worker UI — while the worker catalogs (web.ftl/common.ftl, ~1,200 keys) remain en-only and fully disjoint from the public catalog’s report-/public- keys (pinned by craig-web’s no_template_t_key_resolves_in_es empty-intersection test). An es-negotiated worker request therefore rendered wholesale default-locale fallback — all-English pages under the hardcoded <html lang="en"> — and any future partially-overlapping catalog would have produced a mixed-language render. A latent hazard plus a dishonest negotiation, not a shipped mixed-Spanish defect.

Decision (#1332, posture 1 — pin to the default locale until real es worker surfaces exist):

  • craig-i18n gains the additive consuming builder I18n::pin_negotiation_to_default(): it restricts available_locales to the default locale, and since every negotiate_locale tier is membership-gated, ?lang=, caller candidates, and Accept-Language all fall through to the default. The messages table is untouched (translate still resolves every loaded locale).

  • craig-web applies the builder at the boot seam, and warns at boot when the effective CRAIG_WEB__DEFAULT_LOCALE is not en — a non-en default under the pin would render raw message keys across the worker UI (the non-en buckets hold only the public catalog, and translate skips the default-locale fallback when the requested locale is the default). Only en is supported until #1487.

  • POST /set-locale and the session locale preference behind it (WebSession.locale / SessionSnapshot.locale / the session candidate tier in resolve_locale) are removed — a pre-1.0 breaking change with zero shipped consumers (no template or nav control ever linked the endpoint). A preference no negotiation honors would be a dead half-wired knob. Stale session cookies carrying a locale field still deserialize (unknown fields are ignored) and are inert under the pin.

  • The craig-intake edge is unaffected: it holds its own I18n instance and keeps full bilingual public negotiation via craig_i18n::locale_layer.

Unpinning — real es worker catalogs, a language switcher, dynamic <html lang> — is tracked as #1487.

Edit this page · latest