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-US → es) 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:
-
jurisdiction-overlaid entry in the requested locale, else
-
jurisdiction-overlaid entry in the default locale, else
-
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.caseworker → term-role-caseworker, my_day → term-my-day, household → term-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
BundleContributionaxis: 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-webgains a dependency oncraig-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-exchangealready carries tocraig-state-ga, and Plan U adds it regardless via thestate-gaCargo 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’sTerminologyContribution— 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 fullesparity — 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
-
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. Thecraig-web → bundledependency it avoids is added by Plan U anyway. -
Fluent terms (
-role-caseworker) instead of flat messages. Rejected: terms are not harvested orget_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. -
A standalone
TerminologyRegistrytype materialized like the partner registries. Rejected: theI18npre-resolved table already is that registry for the only consumer (thetfilter); a parallel map would duplicate it. -
Keep Contract 2’s TOML format. Rejected: superseded by Fluent to match the existing stack and avoid maintaining two parsing paths.
-
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_overridingreturns()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.
Related decisions
-
ADR-032: Multi-Jurisdiction Partner Registry and Transport Abstraction — §2.7 boot activation + fail-fast (this ADR’s §8 mirrors it); §4 + A11 additive
BundleContributiongrowth, terminology pre-materialized; A5 bundles-own-their-content precedent. -
ADR-038: Trait-Object & Registry Patterns — §3 registry materialization (BFF-local here) + §4 field-sourcing / orphan rule (drives the bundle-embedded decision).
-
ADR-031: Nursery Lint Triage and Promotion Pattern — ADR format mirror.
-
Plan S — Multi-Jurisdiction Foundation — umbrella; this ADR is Step 10; implementation is Plan U Step 9.
-
Design engineering contracts — Contract 2 (terminology; format superseded here, semantics preserved) + Contract 1 (
display_nameterminology-key resolution per §6). -
ADR-036: Token + Theme Export via Bundle-Provided CSS Custom Properties Served From a Same-Origin Route — the sibling Phase-2 BFF axis (Plan U Step 8); mirrors this ADR’s posture (bundle-embedded source, BFF-local materialization, boot fail-fast on required coverage).
-
ADR-033 (anticipated) — Plugin Manifest + Render Contract — plugin
display_nameterminology keys resolve through the mechanism fixed in §6.