ADR-034: Per-Jurisdiction Terminology Resolution via Bundle-Overlaid Fluent Catalogs

On this page

Status

Accepted 2026-06-11. Anchors Plan S — Multi-Jurisdiction Foundation Step 10; the implementation lands in Plan U (State Bundle Pattern) Step 9. Supersedes the TOML terminology format proposed in Contract 2 (Fluent replaces TOML to match CRAIG’s existing i18n stack); preserves Contract 2’s fallback chain, bilingual requirement, audience split, and terminology-key semantics. Builds on ADR-032 §1.3 (dual-site/open carriage), §2.7 (boot fail-fast), §4 + A11 (additive BundleContribution growth; terminology is pre-materialized) and ADR-038 §3 (registry materialization) + §4 (field-sourcing / orphan rule).

Context

CRAIG ships an open-source CCWIS platform whose multi-jurisdiction mission requires per-jurisdiction vocabulary: one state calls a role "Family Services Specialist", another "Child Protection Worker"; constituent-facing surfaces reframe "household" as "family". The design team’s engineering Contract 2 specified this as per-jurisdiction TOML dictionaries with a jurisdiction → product-default → key-literal fallback, an en-US + es-US bilingual requirement, and separate worker/constituent vocabularies.

CRAIG already has a Fluent-based i18n stack in the craig-web BFF (services/craig-web/src/i18n.rs, using fluent-bundle + fluent-syntax + unic-langid). It loads .ftl catalogs from locales/<lang>/.ftl at boot, pre-resolves every message into a flat locale → key → value table of Arc<str>, and resolves at request time with a requested-locale → default-locale → key-literal fallback. Locales are keyed by the directory name, in practice the bare *primary subtag (en), with default_locale = "en"; the request middleware reduces region-qualified Accept-Language values (es-USes) before lookup, and translate() exact-matches that reduced tag. Only en exists today (~1,300 keys split across common.ftl / web.ftl / public.ftl); es is greenfield.

A second standing fact shapes the design: CRAIG is one deployment, one jurisdiction. craig_common::settings.jurisdiction is a required deployment-wide value; ADR-032 §2.7’s CRAIG__ACTIVE_STATE_BUNDLES selects the active bundle(s) at boot. There is no per-request or per-user jurisdiction anywhere, and none is planned. The jurisdiction is fixed before the HTTP listener binds.

This ADR records two decisions: (1) extend the existing Fluent stack rather than build the proposed TOML system, and (2) deliver per-jurisdiction vocabulary as a bundle-contributed catalog overlay merged once at boot, so the runtime translation hot path is unchanged.

Decision

1. Extend the existing Fluent infrastructure; supersede only the TOML format

Per-jurisdiction terminology reuses services/craig-web/src/i18n.rs wholesale. The .ftl format, the per-locale pre-resolved Arc<str> table, the t Askama filter, the task-local request locale, the middleware, and the translate() fallback are all retained unchanged. Contract 2’s TOML file format is superseded by Fluent .ftl; everything else Contract 2 specified (fallback chain, en-US + ≥1 es-US bilingual requirement, worker/constituent split, "unknown key renders as the key literal, never blank") is preserved.

This ADR also updates Contract 2 to standardize on Fluent and the key-naming rule in §6.

2. Per-jurisdiction vocabulary is a boot-time overlay; the runtime path is unchanged

Because jurisdiction is deployment-wide and fixed at boot (1-deployment-1-jurisdiction; ADR-032 §2.7), the active jurisdiction’s vocabulary is merged into the pre-resolved message table once, at boot. translate() keeps its (locale, key) → Arc<str> signature and its two-tier locale fallback; it gains no jurisdiction dimension. The jurisdiction → product-default hop happens at merge time (§5); the locale → default-locale → key-literal hop stays at request time.

3. Jurisdiction catalogs are bundle-embedded and flow through BundleContribution

A new pre-materialized field is added to the BundleContribution aggregate (ADR-032 §4; this is the first Phase-2 field to land):

// crates/craig-state-bundle
pub struct TerminologyCatalog {
    pub lang: &'static str,   // primary-subtag locale bucket, e.g. "en" / "es" (NOT region-qualified)
    pub source: &'static str, // raw Fluent source, include_str!'d in the bundle crate
}

pub struct TerminologyContribution {
    pub catalogs: Vec<TerminologyCatalog>, // empty default = no overlay
}

pub struct BundleContribution {
    // …5 Phase-1 fields…
    pub terminology: TerminologyContribution, // 6th field (this ADR / Plan U Step 9)
}

The catalog .ftl source lives in the bundle crate (crates/craig-state-<jurisdiction>/terminology/<lang>/*.ftl) and is embedded via include_str!. It is PRE-MATERIALIZED per ADR-032 A11 / ADR-038 §3 (the source is a &'static str; no BootContext resource is needed, so the field carries a value, not a factory — contrast adapters, which are factories). A bundle that ships no overlay returns TerminologyContribution::default() (empty) and the deployment runs on the product default alone.

The product-default catalogs (services/craig-web/locales/<lang>/*.ftl) remain on disk, owned by craig-web — they are the jurisdiction-neutral baseline every deployment inherits, not a per-jurisdiction concern.

This refines the literal layout the Plan S umbrella sketched (services/craig-web/locales/<jurisdiction>/<lang>/<bundle>.ftl): jurisdiction catalogs do NOT live under craig-web’s tree. Rationale — ADR-038 §4 requires a contribution’s value to originate in a crate the bundle author owns (an `include_str! from the bundle crate satisfies this; a file under craig-web’s tree does not), ADR-032 A5 already moved per-jurisdiction content (mock routes) INTO the partner crates rather than leaving it in a shared service, the BFF stays thin, the choice mirrors how `theme.toml is sourced for the sibling ADR-036, and Plan U adds the craig-web → bundle dependency anyway (specified in Plan U Step 9 — not yet in code), so the embed choice incurs no dependency edge the plan was not already committed to. As a bonus it sidesteps a real hazard: the existing loader parses every top-level locales/ directory name as a BCP-47 language tag and silently skips non-tags, so a directory literally named georgia would be ignored with only a warning.

4. Materialization is BFF-local; the I18n table is the terminology registry

There is no standalone TerminologyRegistry type. craig-web consumes the active bundle’s TerminologyContribution at boot — I18n::load gains it as an input — and the existing I18n.messages table IS the materialized, immutable-after-boot terminology registry. This is the same "the consuming host materializes the axis it consumes" rule ADR-038 §3 applies to the exchange registries (craig-exchange materializes partner registries because it dispatches partners; craig-web materializes terminology because it renders templates). Introducing a parallel HashMap-backed TerminologyRegistry beside the I18n table would duplicate it for no consumer.

Wiring contract: at boot, craig-web resolves the active bundle(s) (per the ADR-032 §2.7 activation model — CRAIG__ACTIVE_STATE_BUNDLES, implemented in Plan U Step 7) and passes their merged TerminologyContribution to I18n::load. The exact boot helper (whether craig-web grows a small bundle-resolution shim or reuses a shared one) is a Plan U Step 9 implementation detail; the contract this ADR fixes is only that I18n::load is the materialization point and the active bundle’s contribution is its input. This makes Plan U Step 9 depend on Step 7 (activation must exist before terminology can read the active bundle) — a within-Plan-U sequencing gate.

5. Boot merge uses Fluent override semantics; the effective fallback chain

For each locale, I18n::load adds the product-default resources first (as today, via add_resource), then adds the active bundle’s overlay catalogs for the same locale via Fluent’s overriding resource add (add_resource_overriding), so jurisdiction entries win over product-default entries with the same id. (Plain add_resource is first-wins and would NOT override — the overriding variant is required.) The merged bundle is then pre-resolved to Arc<str> exactly as today and discarded; message ids are harvested from the MERGED bundle (product-default + overlay), so keys introduced only by the overlay still resolve.

Canonical locale map (pinned to prevent misrouting): CRAIG keys locales by primary subtag — the existing loader’s locales/<lang>/ directories, default_locale = "en", the middleware’s es-US → es reduction, and translate()’s exact match all operate on bare primary tags. Overlay catalogs therefore declare their locale as the SAME primary-subtag bucket (`en, es); a region-qualified request normalizes to its primary subtag before any lookup. The bilingual requirement (§8) is satisfied by providing an en bucket + at least one Spanish (es) bucket. A bucket present only in the overlay (e.g. es, before any product-default es exists on disk) is created from the overlay alone, and available_locales becomes the union of product-default and overlay buckets. This ADR pins the bare-primary-subtag keying as the canonical map so overlays cannot misroute against the region-qualified tags Contract 2’s prose names; no locale-negotiation change is introduced.

Multi-bundle ordering: the normal deployment has exactly ONE jurisdiction bundle active over the product default, so terminology key collisions are between product-default and that one jurisdiction (the intended override). When more than one bundle is active (e.g. a state-ga + state-tx-stub test build), catalogs are overlaid in CRAIG__ACTIVE_STATE_BUNDLES order and the last-activated bundle wins a colliding key — deterministic, with cross-bundle key coordination an authoring responsibility (see Out of scope).

The effective fallback, after the boot merge collapses into the pre-resolved table:

  1. jurisdiction-overlaid entry in the requested locale, else

  2. jurisdiction-overlaid entry in the default locale, else

  3. the key literal.

The jurisdiction → product-default hop is baked in at boot (override-or-inherit); the locale → default-locale → key-literal hop is the unchanged runtime translate(). This realizes Contract 2’s required jurisdiction → product default → key-literal chain.

6. Terminology keys are flat Fluent messages with a term- namespace

Terminology entries are ordinary flat kebab-case Fluent messages, NOT Fluent terms. This matches CRAIG’s existing convention (every one of the ~1,300 current keys is a flat kebab message; zero terms exist) and reuses the harvest/pre-resolve machinery with no change — the harvester collects Entry::Message, and FluentBundle::get_message does not resolve terms, so a term-based design would require new machinery and per-term accessor messages.

Contract 2’s dotted keys map to flat message ids by replacing . and _ with -, under a reserved term- prefix that marks jurisdiction-overridable vocabulary: role.caseworkerterm-role-caseworker, my_dayterm-my-day, householdterm-household. A jurisdiction overlay redefines term- messages; product-default term- messages are the baseline.

Contract 1’s plugin display_name = "{term.child_support}" is a terminology key, not a literal and not Fluent {} interpolation: the plugin layer strips the term. prefix, transliterates to term-child-support, and resolves it through the same translate() path the t filter uses (yielding the jurisdiction-overlaid string with the standard fallback). Where vocabulary must appear inside a sentence, messages reference other messages with Fluent’s { message-id } placeable — still flat messages, no terms.

7. Worker vs constituent audience is an authoring convention, not a runtime dimension

The existing common.ftl / web.ftl / public.ftl split already encodes the worker/constituent boundary (web worker UI, public constituent intake, common shared chrome). A jurisdiction overlay follows the same file split for organization (worker.ftl / constituent.ftl / shared), but all files for a locale merge into ONE pre-resolved map. translate() stays one-dimensional (locale only). Where worker and constituent need different strings for the same concept, they use different keys — which the codebase already does (surface-prefixed keys such as cases-, intake-). Adding a runtime audience dimension to the hot path is out of scope.

8. Boot fail-fast on incomplete bilingual coverage (BFF-local)

When an active bundle contributes a NON-EMPTY TerminologyContribution, craig-web validates at boot that the contribution covers the en bucket AND at least one Spanish (es) bucket (per the §5 canonical primary-subtag map); a miss is a typed boot error and craig-web refuses to start, mirroring ADR-032 §2.7’s empty-bundle / jurisdiction-mismatch fail-fast (a silent miss would surface as English leaking into a Spanish render, or a key literal in production UI, discovered by an end user rather than at the boundary). This requires a fallible boundary: today I18n::load returns Self and silently tolerates missing locale directories, so Plan U Step 9 changes the load path to I18n::load → Result<I18n, TerminologyBootError> (or adds a pre-load validator) that main ?-propagates into its anyhow::Result<()>, so the check can actually halt boot. An EMPTY contribution (e.g. the current default bundle) is exempt — it runs on the product default, which is single-locale today; bringing the product default to full bilingual parity is separate translation work (see Out of scope). Bundles are validated post-activation (after CRAIG__ACTIVE_STATE_BUNDLES selection), so this composes with §2.7 rather than duplicating it. The check is BFF-local because craig-web is the terminology consumer; services that never render templates (e.g. craig-exchange) must not gate boot on locale coverage. The one other user-facing surface, craig-intake, serves pre-rendered static HTML with hardcoded copy (stateless edge per ADR-017) and does not consume the Fluent t filter, so it is not a terminology consumer here; bringing jurisdiction-aware copy to intake, if ever needed, is separate post-1.0 work (Out of scope).

Consequences

Positive

  • The runtime translation path is byte-for-byte unchanged — no new hot-path allocation, no jurisdiction dimension, no translate() signature churn.

  • Terminology is structurally identical to every other BundleContribution axis: a bundle is self-contained, its vocabulary compiles in with it under the same Cargo feature, and it is discoverable through the one aggregate.

  • Deployment is simpler: catalogs are baked into the binary (no separate file shipping for jurisdiction content).

  • Boot fail-fast surfaces missing-locale gaps at the boundary, consistent with ADR-032 §2.7.

  • Reuses ~1,300 existing keys and the existing file split; the migration to term-* keys is mechanical and additive.

Negative

  • craig-web gains a dependency on craig-state-bundle (and the active bundle crates). It had none.

  • A terminology copy fix requires a rebuild (no runtime catalog reload).

  • The product-default catalog stays English-only until separately translated, so the bilingual guarantee currently binds jurisdiction overlays, not the bare default.

  • The overlay merge relies on Fluent’s overriding resource-add; a future loader refactor must preserve override semantics or jurisdiction entries silently stop winning.

Mitigations

  • The new dependency is the same shape craig-exchange already carries to craig-state-ga, and Plan U adds it regardless via the state-ga Cargo feature + 5-build CI matrix — so it is a sanctioned, already-planned edge.

  • No-reload matches every other bundle axis (an adapter change also requires a rebuild); runtime bundle reload is deferred indefinitely per ADR-032.

  • The bilingual gate is explicitly scoped to overlays so it cannot brick the existing single-locale deployment; product-default bilingual parity is tracked as separate work.

  • The override-semantics requirement is stated in this ADR and must be asserted by a test (overlay key wins over product-default key).

Open questions

Deferred to the implementation (Plan U Step 9), not blocking acceptance:

  • The exact fallible boot signature (I18n::load → Result<…> vs a separate pre-load validator) and the small craig-web boot helper that resolves the active bundle’s TerminologyContribution — resolved in Plan U Step 9 (§4, §8 fix the contract; the helper shape is implementation choice).

  • Whether to add an explicit intra-bundle duplicate-key pre-scan lint (the override merge does not surface duplicates; §5 / Out of scope) — resolved or explicitly declined in Plan U Step 9.

  • Bringing the product-default catalog (locales/en/*.ftl) to full es parity — a separate translation effort; the §8 bilingual gate binds jurisdiction overlays, not the bare product default, so this does not block this ADR. No fixed date; tracked as future i18n work.

Alternatives considered

  1. Jurisdiction catalogs on disk under craig-web/locales/<jurisdiction>/ (the umbrella’s literal sketch). Rejected: violates ADR-038 §4 field-sourcing (content not owned by the bundle crate), inverts the ADR-032 A5 "bundles own their content" precedent, thickens the BFF, and collides with the existing top-level BCP-47 directory parser. The craig-web → bundle dependency it avoids is added by Plan U anyway.

  2. Fluent terms (-role-caseworker) instead of flat messages. Rejected: terms are not harvested or get_message-resolvable by the existing loader, so they would require new harvest machinery plus per-term accessor messages, and they diverge from the entire existing flat-key convention — cost without benefit, since CRAIG’s overlay model redefines whole strings rather than declining a shared noun across sentences.

  3. A standalone TerminologyRegistry type materialized like the partner registries. Rejected: the I18n pre-resolved table already is that registry for the only consumer (the t filter); a parallel map would duplicate it.

  4. Keep Contract 2’s TOML format. Rejected: superseded by Fluent to match the existing stack and avoid maintaining two parsing paths.

  5. A runtime worker/constituent audience dimension on translate(). Rejected: larger hot-path change; the existing surface-prefixed key convention already disambiguates audiences without it.

Out of scope

  • Translating the product-default catalogs to Spanish (separate translation effort; the bilingual gate binds jurisdiction overlays here).

  • Runtime catalog hot-reload (rebuild required, as for every bundle axis).

  • Spanish-length (~+20%) layout accommodation — a UI-layout concern owned by ADR-036 (anticipated) + the layout grammar.

  • Per-request / per-user terminology or a runtime audience dimension.

  • A stricter cross-bundle terminology collision policy than the deterministic last-activated-wins ordering fixed in §5. Terminology deliberately does NOT adopt the partner registries' DuplicateEntry-fails-boot rule, because its purpose is override: a product-default-vs-jurisdiction duplicate is the intended override, and a cross-bundle duplicate (only possible in a multi-active-bundle build) resolves by activation order with coordination left to bundle authors. Note the override merge gives NO free duplicate detection — add_resource_overriding returns () and reports nothing (that silence is exactly what lets a jurisdiction override the product default) — so a duplicate id WITHIN a single bundle’s own catalogs is not surfaced automatically; detecting it would require an explicit pre-scan in Plan U, which this ADR does not mandate.

Edit this page · latest