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:
-
i18n duplication. craig-web localizes via Mozilla Fluent + an Askama
t()filter, with a ~624-line engine inservices/craig-web/src/i18n.rs(Fluent bundle load, pre-resolution into anArc<str>cache, atask_local!request context, locale negotiation) andlocales/en/{public,web,common}.ftl(247report-/public-public keys). The edge has no i18n machinery — staticinclude_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 isen-only today — there is no second-language catalog anywhere. -
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). -
Partner-org key registration. craig-web’s
/report/keyspage — 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-deadGET …/attachmentslist route is removed. -
Cases-backed only. The route is gated by a
mount_attachmentsflag (mirroringmount_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.
Related decisions
-
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-i18ngains the additive consuming builderI18n::pin_negotiation_to_default(): it restrictsavailable_localesto the default locale, and since everynegotiate_localetier is membership-gated,?lang=, caller candidates, andAccept-Languageall fall through to the default. The messages table is untouched (translatestill resolves every loaded locale). -
craig-web applies the builder at the boot seam, and warns at boot when the effective
CRAIG_WEB__DEFAULT_LOCALEis noten— a non-endefault under the pin would render raw message keys across the worker UI (the non-enbuckets hold only the public catalog, andtranslateskips the default-locale fallback when the requested locale is the default). Onlyenis supported until #1487. -
POST /set-localeand the session locale preference behind it (WebSession.locale/SessionSnapshot.locale/ the session candidate tier inresolve_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 alocalefield still deserialize (unknown fields are ignored) and are inert under the pin. -
The craig-intake edge is unaffected: it holds its own
I18ninstance and keeps full bilingual public negotiation viacraig_i18n::locale_layer.
Unpinning — real es worker catalogs, a language switcher, dynamic <html lang> — is tracked as #1487.