ADR-032: Multi-Jurisdiction Partner Registry and Transport Abstraction

On this page

Status

Accepted 2026-06-08. Anchors Plan S — Multi-Jurisdiction Foundation (umbrella; Phase 1 filed via !653 / sha 9a6d018a; Phase 2 re-authoring filed via !655 / sha f1f4db31), which sequences six child plans across two phases:

  • Phase 1 (partners + transport): Plan T (Adapter Registry Pivot) executes §1; Plan U (State Bundle Pattern) executes §2; Plan V (Transport + Substrate) executes §3.

  • Phase 2 (UI composability): Plans W (Plugin Manifest + Render Runtime), X (Composition Layer Engine), and Y (Field Ownership + Authz Extension) extend the same BundleContribution aggregate this ADR specifies. Each Phase 2 plan is anchored by its own ADR (033 / 034 / 035 / 036 / 037; all anticipated as of this ADR’s filing). See §4 for the additive-aggregate contract.

Supersedes Epic &43 (Partner Mock Substrate + webMethods Gateway Foundation); re-homes issues #547-#556 (M1-M10) per the Plan S Status table.

Context

Origin

CRAIG ships as open-source CCWIS with a stated multi-jurisdiction mission. Six jurisdiction-variability dimensions are already abstracted across prior plans (JDM rulesets per-state; admin units externalized; IdP across 4 backends; authz DSL-driven; ICPC + i18n generalized). The partners + broker dimension is the last unaddressed dimension before 1.0.

Plan L (closed 2026-06-08) typed the partner integration boundary as 10 per-partner crates implementing a typed ExchangeAdapter trait + craig-partner-audit aggregator with a closed ExchangeAdapterKind discriminated-union. The narrowness was deliberate per the pre-1.0-don’t-lock-contracts doctrine. A multi-jurisdiction extensibility audit on 2026-06-08 confirmed the closure shape encodes Georgia’s 11-partner set as a structural invariant of craig-exchange-contracts, and identified 11 closure surfaces that block non-Georgia state adoption (ExchangeAdapterKind enum + AnyAdapter dispatch + PartnerAuditEvent discriminated union + closed PartnerType taxonomy + DB CHECK constraint + hardcoded workspace Cargo members + static mock-server router + hardcoded GA seed data + create/update DTO+API+web+CLI surfaces + missing federal AFCARS/NCANDS partner_type mapping interface + hardcoded jurisdiction: "georgia" defaults at 4 production sites).

The audit + Plan S filing iterated through 4 contextless review rounds + 1 user external review per delivery-protocol.md:131-136. The architectural decisions below are the outcome.

Constraints

  • Object-safety constraint on ExchangeAdapter. Plan L’s trait at crates/craig-exchange-contracts/src/lib.rs:175-215 uses RPITIT (→ impl Future<…​> + Send) for ergonomic typed dispatch. Trait objects (Arc<dyn ExchangeAdapter>) will NOT compile against this shape. The pivot must NOT break Plan L’s typed-RPITIT contract — it’s load-bearing for typed audit payloads, typed errors, and per-partner ergonomics.

  • Pre-1.0 posture. No production users; no API contracts to honor; pre-1.0 destructive migrations + reseed are acceptable per the pre-1.0 destructive-rebuild posture. Post-1.0 the cost of this pivot increases substantially (fork divergence, breaking API changes, coordination overhead).

  • State-bundle deployment model. 1 deployment = 1 jurisdiction. Multi-tenant operator-side serving (one binary, N states served concurrently) is explicitly out of scope. State-specific deployments compile in their state bundle via Cargo features + activate via env var.

  • Federal reporting downstream consumer. AFCARS + NCANDS submission schemas have fixed federal partner-category codes. Opening PartnerType without a federal-mapping interface would cause silent malformed federal submissions OR fail-closed at a code path the foundation doesn’t test — the "open the seam, forget the consumer" failure mode Plan L’s PartnerAuditEvent storage-layer gap exemplified.

Decision

The pivot rests on three orthogonal axes. Each axis is delegated to a child plan; this ADR fixes the contract each axis adheres to.

§1 Adapter dispatch seam (executed by Plan T)

1.1 ErasedAdapter seam trait + per-crate impl_erased_adapter! macro

A NEW object-safe trait ErasedAdapter sits alongside Plan L’s typed ExchangeAdapter:

// craig-exchange-contracts
pub trait ErasedAdapter: Send + Sync + 'static {
    fn send_value(&self, endpoint: String, payload: Value) -> BoxFuture<'_, Result<Value, ErasedAdapterError>>;
    fn audit_value(&self, response: &Value) -> BoxFuture<'_, Result<Value, ErasedAdapterError>>;
    fn kind(&self) -> &'static str;
}

Erasure happens at the per-partner boundary via the impl_erased_adapter!(CapsAdapter, "caps") declarative macro exported from craig-exchange-contracts. The macro expansion contains the Value ↔ typed boundary code (deserialize Value into Outbound, call typed send/audit, re-serialize Inbound/AuditPayload back to Value) — matching today’s dispatch_send / dispatch_audit_typed match-arm logic at services/craig-exchange/src/adapters/mod.rs.

Plan L’s typed ExchangeAdapter trait is UNCHANGED. Per-partner crates implement BOTH (the typed trait by hand-rolled impl, the erased trait via macro invocation). Callers who know the partner type at compile time still get typed dispatch + RPITIT performance + per-partner audit payload shape; callers who hold polymorphic instances use Arc<dyn ErasedAdapter> and pay one allocation per call.

1.2 AdapterRegistry — immutable lookup built once at boot

Adapter dispatch becomes registry lookup, not enum match. The registry is an immutable structure populated at boot via the builder pattern established by StaticActorJwksRegistry at crates/craig-auth/src/actor_verifier.rs:59-111:

pub struct AdapterRegistry {
    by_kind: HashMap<&'static str, Arc<dyn ErasedAdapter>>,
}

impl AdapterRegistry {
    pub fn resolve(&self, kind: &str) -> Option<&Arc<dyn ErasedAdapter>> { ... }
    pub fn is_known(&self, kind: &str) -> bool { ... }
    pub fn registered_kinds(&self) -> impl Iterator<Item = &'static str> + '_ { ... }
}

Registration happens via BundleContribution::adapters (see §2.1). The registry is NEVER mutated after boot. AnyAdapter enum + adapter_for(kind, …​) match at services/craig-exchange/src/adapters/mod.rs:41-93 are deleted; replaced with AdapterRegistry::resolve(kind).

Parallel registries follow the same shape for audit codec dispatch + mock router lookup + partner-type taxonomy + federal-category mapping. All five (AdapterRegistry, AuditCodecRegistry, MockRouterRegistry, PartnerTypeRegistry, FederalPartnerMappingRegistry) are built atomically at boot (see §2.1’s atomic-orchestrator pattern).

1.3 DB open-TEXT + dual-site validation closes TOCTOU

The DB-level closure (CHECK constraint on exchange_partners.adapter_kind at services/craig-exchange/migrations/20260525213241_add_adapter_kind.sql) is dropped. The DDL treats adapter_kind and partner_type as opaque TEXT columns. The closed-enum semantics move entirely to the application layer.

Two validation sites — not one — enforce the closure:

  1. Boot validation: the service iterates active exchange_partners rows + asserts every adapter_kind and partner_type value is present in the corresponding registry; refuses to start on mismatch with explicit error.

  2. Request validation: every create/update API handler reads from the same registry to validate inbound DTOs. Hardcoded enum checks are replaced with registry.is_known(kind) calls.

This closes a TOCTOU window an external reviewer surfaced during the Plan S review: between boot and the next deploy, an out-of-band SQL insert/update could land a row with an unregistered adapter_kind. Without the per-request validation, the next request that resolves that partner returns 500 (registry miss) instead of 400 — and the operational telemetry would attribute the failure to the dispatch layer, not the create path. The dual-site validation surfaces the bad data at the boundary where it was introduced.

§2 Bundle composition (foundation executed by Plan T; multi-bundle activation + per-jurisdiction bundles executed by Plan U; see Amendment A10)

2.1 StateBundle trait returning BundleContribution aggregate

State-specific deployments are partitioned into per-state bundle crates that implement a single trait:

// craig-exchange-contracts
pub trait StateBundle: Send + Sync {
    fn name(&self) -> &'static str;
    fn jurisdiction_code(&self) -> &'static str;
    fn contribute(&self) -> BundleContribution;
    fn federal_mapping(&self) -> HashMap<&'static str, FederalPartnerCategory>;
}

pub struct BundleContribution {
    pub adapters: Vec<(&'static str, Arc<dyn ErasedAdapter>)>,
    pub audit_codecs: Vec<(&'static str, Arc<dyn AuditCodec>)>,
    pub mock_routes: Vec<(&'static str, MockRouteFactory)>,
    pub partner_types: Vec<PartnerTypeMeta>,
    pub seed_data: SeedContribution,
}

The trait returns an aggregate rather than mutating five &mut registry-builder parameters. The earlier fn register(&self, &mut AdapterRegistry, &mut AuditCodecRegistry, &mut MockRouterRegistry, &mut PartnerTypeRegistry, &mut SeedDataRegistry) shape (surfaced and rejected during Plan S review round 2) violated clippy::too_many_arguments, conflicted with the "immutable registries built once" claim (mutating builders ≠ immutable lookup), and had undefined partial-failure semantics (if bundle A’s first 3 entries land then bundle B’s 4th register() call panics on duplicate key, the builders leak inconsistent state).

A boot orchestrator iterates active bundles, accumulates contributions, validates uniqueness across bundles (no two bundles register the same adapter_kind or partner_type token), THEN materializes the five immutable registries in a single atomic step. Partial-failure surfaces as a boot-time error before any registry is observable; the orchestrator never publishes a half-built registry to callers. (Federal-mapping completeness check originally specified here was moved to report-emit-time per Amendment A7.)

2.2 PartnerTypeRegistry opens the partner_type taxonomy

The closed PartnerType enum at crates/craig-reference/src/enums.rs:683 (StateAgency / CourtSystem / FederalAgency / TribalAuthority / PrivateProvider) is deleted. Bundles contribute partner-type metadata via BundleContribution::partner_types; the boot orchestrator builds an immutable PartnerTypeRegistry from the union of active bundles' contributions, keyed by &'static str token.

The 5 existing values are NOT lost — they remain the default set provided by crates/craig-state-ga’s `BundleContribution. States adopting CRAIG without state-ga (e.g. state-tx-stub-only deployment) get only their own partner-type values; states layering on top of GA’s defaults get both.

2.3 FederalPartnerCategory closed enum + bundle-provided mapping

Opening the partner-type taxonomy exposes a downstream consumer: federal AFCARS + NCANDS submission. CRAIG MUST emit federally-defined partner-category codes when reporting, not the registered local tokens. A NEW closed enum lives in craig-reference::translate:

// craig-reference::translate
pub enum FederalPartnerCategory {
    // AFCARS + NCANDS partner-category codes as defined by federal submission schemas
    // ... fixed set, never mutated by bundles
}

This enum is closed by federal design — bundles do NOT contribute new variants. Bundles instead contribute a mapping from their registered partner-type tokens to existing federal categories via StateBundle::federal_mapping(). Plan T3 Step T3.4 ships the federal enum + report-emit-time validation that every registered partner_type has a federal mapping entry; AFCARS + NCANDS row-emit paths return typed ReportingError::MissingFederalMapping per row (per Amendment A7).

2.4 Federal mapping enforced at boot + reporting

The federal mapping is enforced at report-emit time (per Amendment A7 — earlier boot-time check moved to report-time so seed-default bundles with incomplete mappings don’t block service startup):

  1. Boot: orchestrator validates adapter_kind + partner_type registry membership only (every active partner row has a registered adapter_kind AND partner_type). Federal-mapping completeness is NOT enforced at boot.

  2. Reporting submission: services/craig-reporting AFCARS + NCANDS submission paths resolve partner_type → FederalPartnerCategory via the registry at row-emit time. A token without a mapping returns typed ReportingError::MissingFederalMapping per row rather than emitting a malformed federal payload.

2.5 Per-jurisdiction mock-manifest contract

Plan U Step 3’s crates/craig-state-tx-stub reference example must be authorable without depending on Plan T’s mock-server changes landing first. The per-jurisdiction mock-manifest contract is fixed here:

  • Each bundle’s BundleContribution::mock_routes provides Vec<(&'static str, MockRouteFactory)> — the kind token + a factory function that returns an axum::Router for that partner’s mock surface.

  • tools/craig-mock-server iterates compiled-in bundles, invokes each factory, and nests the resulting routers under /partner/<kind>/…​.

  • MockRouteFactory is Arc<dyn Fn() → Router + Send + Sync> (object-safe; no generic over factory closure type).

Plan U Step 3 can author craig-state-tx-stub’s `texas-demo mock factory against this fixed contract; Plan T2 Step T2.3 implements the mock-server iteration loop against the same contract.

2.6 Hardcoded jurisdiction: "georgia" defaults removed

Four sites are listed below as encoding Georgia as a default value. Per Amendment A8 verification (2026-06-09), 3 of the 4 sites are actually #[cfg(test)] test helpers, NOT production code; only 1 site (the seed default) is truly production. Plan T3 Step T3.6 scope is reduced accordingly. The 4 sites:

  • crates/craig-common/src/settings.rs:406

  • crates/craig-api/src/bootstrap.rs:623

  • services/craig-intake/src/config.rs:355

  • tools/craig-seed/src/datagen.rs:1630 (in SeedRateTable row generation)

All four are removed. CRAIG_JURISDICTION becomes required-from-env; absence at boot is a fail-fast error. Test-only georgia fixtures in crates/craig-authz/{tests/, src/audit.rs, src/types.rs} + services/craig-cli/tests/cli/financial.rs + services/craig-financial/tests/api/{auth, payments, rates} are acceptable test data and stay unchanged.

2.7 CRAIG__ACTIVE_STATE_BUNDLES env var — fail-fast on empty

Active bundle activation is controlled by a single env var, comma-separated bundle names (matching the bundles' name() return values). The contract has two non-obvious failure modes that are fixed here:

  1. Empty / missing var → fail-fast at boot with explicit error: "no state bundle activated; set `CRAIG__ACTIVE_STATE_BUNDLES=<name>`". NOT silent-empty-registry (which would produce confusing 400s at first create-partner request).

  2. Jurisdiction mismatchsettings.jurisdiction value not matched by any active bundle’s jurisdiction_code() → fail-fast with explicit error naming both the expected and the active bundles' jurisdictions.

Cargo features control which bundle crates compile; this env var controls which compiled bundles activate. The two layers are decoupled so a build can include both state-ga and state-tx-stub (for testing) while only one activates at boot.

§3 Outbound transport (executed by Plan V)

3.1 OutboundTransport trait + concrete impls co-located in NEW crates/craig-exchange-transport

A NEW crate crates/craig-exchange-transport contains the OutboundTransport trait + its concrete implementations:

// craig-exchange-transport
pub trait OutboundTransport: Send + Sync + 'static {
    fn send(&self, endpoint: String, body: Vec<u8>, headers: Headers)
        -> BoxFuture<'_, Result<TransportResponse, TransportError>>;
}

pub struct DirectHttpTransport { client: reqwest::Client, ... }
impl OutboundTransport for DirectHttpTransport { ... }

pub struct WebMethodsTransport { ... }  // runtime stub from Software AG public docs
impl OutboundTransport for WebMethodsTransport { ... }

Per-partner adapter constructors take Arc<dyn OutboundTransport> rather than reqwest::Client directly. The 10 partner crates at crates/craig-partner-*/src/adapter.rs each gain a NEW dependency on craig-exchange-transport (for the trait) in addition to craig-exchange-contracts (for the types).

Trait + impls are co-located in one focused crate per the craig-store precedent at crates/craig-store/src/store.rs:16-63 (Store abstraction + LocalFs + S3 backends co-located in one crate). craig-exchange-contracts stays runtime-dep-free + does NOT know transport exists — lightweight clients (CLI, SDK, contract tests) consume contracts without pulling reqwest.

WebMethodsTransport is a runtime stub (NOT compile-only) — it targets craig-mock-server’s webMethods invocation-surface mock route so integration tests can replay broker semantics. Production wiring (concrete `RetryPolicy + IdempotencyPolicy deployment-config structs) is deferred per Out of scope below.

§4 BundleContribution grows additively across Plan S phases

The BundleContribution struct shape declared in §2.1 is the **Phase 1 minimum — it ships in Plan T1 Step T1.5 with five fields (adapters, audit_codecs, mock_routes, partner_types, seed_data). Phase 2 child plans (W, X, Y) and Plan U Steps 9-10 add five additional fields to the SAME aggregate, anchored by ADRs 033-037 (all anticipated as of this ADR’s filing):

// craig-exchange-contracts — Phase 1 minimum (Plan T1 Step T1.5)
pub struct BundleContribution {
    pub adapters: Vec<(&'static str, Arc<dyn ErasedAdapter>)>,
    pub audit_codecs: Vec<(&'static str, Arc<dyn AuditCodec>)>,
    pub mock_routes: Vec<(&'static str, MockRouteFactory)>,
    pub partner_types: Vec<PartnerTypeMeta>,
    pub seed_data: SeedContribution,

    // Phase 2 — each added by the introducing plan's first MR
    pub plugins: Vec<PluginManifest>,                  // ADR-033 (anticipated) / Plan W
    pub terminology: TerminologyContribution,         // ADR-034 (anticipated) / Plan U Step 10
    pub compositions: CompositionContribution,        // ADR-035 (anticipated) / Plan X
    pub theme: ThemeContribution,                     // ADR-036 (anticipated) / Plan U Step 9
    pub field_ownership: FieldOwnershipContribution,  // ADR-037 (anticipated) / Plan Y
}

Growth contract. Each Phase 2 field is introduced by the plan that consumes it. No cross-crate reference precedes its plan’s MR:

  • The struct field is added in the same MR that introduces its supporting type (PluginManifest, TerminologyContribution, etc.) and the consuming registry (plugin registry, terminology overlay, composition layer engine, theme tokens, field-ownership enforcement).

  • The boot orchestrator’s atomic materialization step (§2.1) extends to cover the new registry in the same MR.

  • Bundles that don’t contribute to a given axis return the field’s Default::default() (empty Vec / empty contribution). Adding a field is therefore non-breaking for existing bundles.

  • No Phase 2 plan may merge before its anchoring ADR is Accepted. The umbrella’s Status table sequences the gate.

Why aggregate-grows-not-trait-grows. Adding a method to StateBundle for every Phase 2 axis would force every bundle author to implement five new methods on bundle-version-N upgrade; returning empty fields on a single contribute() aggregate is one default-call per field. The atomic materialization invariant (§2.1) also survives unchanged — partial-failure semantics remain "validate all, then publish all," not "validate each registry independently and hope they agree."

Bundle author burden remains bounded. A bundle that only ships partners (Plan T’s craig-state-tx-stub example) sets compositions: CompositionContribution::default() and so on for the other Phase 2 fields. The struct grows, but the per-bundle implementation surface grows only for bundles that contribute to a given axis.

Consequences

Positive

  • State-neutral foundation. A state adopts CRAIG by writing one new craig-state-<name> crate + a Cargo feature + an env var. No edits to craig-exchange-contracts, services/craig-exchange, crates/craig-partner-*, or the mock-server.

  • Typed inner trait preserved. Plan L’s typed RPITIT ExchangeAdapter is unchanged; typed callers retain per-partner audit shape + zero-allocation dispatch. Erasure is opt-in at the trait-object boundary only.

  • Boot-time atomicity. Five registries materialize atomically from validated bundle contributions; partial-failure is impossible after boot completes successfully.

  • Federal consumer enforced at report-emit time (per Amendment A7). AFCARS + NCANDS submission paths consult FederalPartnerMappingRegistry; rows whose partner_type has no federal mapping return a typed ReportingError::MissingFederalMapping rather than silently emitting malformed payloads. Boot-time check covers adapter_kind + partner_type registry membership only.

  • Transport abstraction unlocks broker-faithful integration testing. WebMethodsTransport stub against the mock-server simulates broker semantics; other states' brokers (MuleSoft, BizTalk, custom) implement the same trait without service changes.

  • Contracts crate stays minimal. craig-exchange-contracts remains runtime-dep-free; CLI / SDK / contract tests consume contracts without pulling reqwest.

  • TOCTOU surface closed. Validation at both boot and request-time means out-of-band SQL updates surface at the boundary where they were introduced.

Negative

  • Three new crates (craig-exchange-transport, craig-state-ga, craig-state-tx-stub) + macro-generated impl per partner = larger workspace surface. Compile time grows modestly (single-digit-percent in dev builds; CI matrix grows to three builds: default-all, --features state-ga, --features state-tx-stub).

  • Two traits at the partner boundary (ExchangeAdapter typed + ErasedAdapter erased) increases conceptual load for adapter authors. Mitigation: the macro hides the second impl; authors write only the typed trait + invoke impl_erased_adapter!.

  • Per-request registry lookup on the create/update path adds a hash-map probe. Negligible in absolute terms; documented for awareness.

  • Bundle-author burden for federal mapping. Authors must provide an entry per registered partner_type in federal_mapping(). Mitigation (per Amendment A7): typed ReportingError::MissingFederalMapping surfaces missing entries at report-emit time with explicit per-row context; the closed FederalPartnerCategory enum makes the contract evident at bundle-author time.

  • B3a serde_json::Value count may rise modestly due to ErasedAdapter’s Value-boundary at the dispatch seam. Mitigation: STRUCTURAL-VALUE markers per STRUCTURAL-VALUE marker placement; raise the quality-budget lock per `coding-conventions.md § Quality-budget enforcement gate lock-raise procedure if the increase is real.

Mitigations

  • TOCTOU closure — validation at BOTH boot AND every create/update request handler (not just boot).

  • Fail-typed on missing federal_mapping entries (per Amendment A7) — AFCARS + NCANDS submission paths return ReportingError::MissingFederalMapping per row. Boot-time check covers registry membership only.

  • Partial-failure atomicity — orchestrator accumulates contributions + validates uniqueness + completeness BEFORE materializing immutable registries; no half-built registry is observable.

  • Fail-fast on empty ACTIVE_STATE_BUNDLES — explicit error naming the env var, not silent-empty-registry that surfaces confusingly at first create.

  • Fail-fast on jurisdiction mismatchsettings.jurisdiction is cross-checked against the union of active bundles' jurisdiction_code(); service refuses to start on mismatch.

Out of scope

This ADR does not address:

  • RetryPolicy + IdempotencyPolicy deployment-config structs for WebMethodsTransport. Tracked at issue #557; blocked on GA DHS Layer 2 deployment runbook. WebMethodsTransport stub ships as a test-purposes runtime stub only.

  • BrokerContract cross-state generalization beyond WebMethodsTransport. MuleSoft / BizTalk / custom transports stay deferred until second-state-adoption milestone; the trait makes them swappable when needed.

  • Operator-side multi-tenancy (one binary serving N states concurrently). The trait+registry shape supports it; not a Plan S deliverable. Today: 1 deployment = 1 jurisdiction.

  • Dynamic bundle loading via dylib / WASM at runtime. Bundles compile in via Cargo features; deferred indefinitely (operator multi-tenancy use case).

  • jurisdiction_code column on exchange_partners for runtime per-row filtering. Preserves 1-deployment-1-jurisdiction posture.

  • Cross-state PartnerType taxonomy governance. Opening the taxonomy enables divergence; aligning categories across states (beyond AFCARS/NCANDS federal joins) is a governance concern, not an architectural one.

Alternatives considered

1. BoxFuture-ify ExchangeAdapter itself

Convert RPITIT returns to Pin<Box<dyn Future<…​>>> to make ExchangeAdapter directly object-safe; remove the need for a separate ErasedAdapter seam.

Rejected: erases Plan L’s typed performance + ergonomics for typed callers who don’t need polymorphism. The vast majority of dispatch sites know the partner type at compile time; paying an allocation per call across the entire workspace is the wrong default. Adding the erased trait as a seam preserves typed callers' performance while enabling polymorphic dispatch only where it’s actually needed.

2. Blanket impl<T> ErasedAdapter for T where T: ExchangeAdapter

A single workspace-wide blanket implementation; per-partner crates need no per-crate macro invocation.

Rejected: (a) locks out per-adapter hook points that Plan V substrate work needs (request inspection, transport selection, custom audit payload shaping); (b) coherence pain if a partner crate ever wants its own ErasedAdapter impl for non-ExchangeAdapter reasons (future foreign-type integrations); (c) forces audit shape to be a generic Value rather than a per-partner customizable payload — losing typed-audit information that Plan L invested in. The per-crate macro shape costs one line per partner + makes the Value-boundary explicit at each crate.

3. Keep ExchangeAdapterKind enum + add Other(Box<dyn ErasedAdapter>) variant

Closed-by-default but open for non-default-jurisdiction adapters via a single open variant.

Rejected: tin-can hack. The first-class Georgia partners still appear as named enum variants while everyone else lives under an Other(…​) second-class slot. State adopters would correctly read this as "Georgia is special; the framework knows it." The architectural intent is symmetric treatment — no state is special at the type level.

4. Typestate / sealed-trait ErasedAdapter

Restrict who can implement the trait via a sealed-trait pattern.

Rejected: per-partner crates outside the core workspace need to author impls without core edits. Sealing the trait re-encodes the closure surface this ADR is opening.

5. OutboundTransport trait in craig-exchange-contracts, impls in services/craig-exchange

Put the trait alongside the existing typed ExchangeAdapter; put the DirectHttpTransport impl inside the exchange service.

Rejected: contracts crate breaks its runtime-dep-free posture if it imports the transport trait that callers expect to consume with Arc<dyn OutboundTransport>. Putting impls in services/craig-exchange couples transport implementations to that one service when other workspace members (CLI, SDK, intake, future bundle crates) may legitimately want the abstraction. The co-located trait + impls in craig-exchange-transport mirrors craig-store precisely.

6. OutboundTransport trait + impls split across two crates (-contracts and -impl)

Trait in a lean -contracts crate; impls in a separate -impl crate.

Rejected: craig-store precedent is the opposite — trait + concrete LocalFs + S3 backends all live in one crate (craig-store) and consumers depend on that one crate. The two-crate split adds dependency-graph noise without unlocking any consumer that the single-crate shape doesn’t already serve.

7. Keep CHECK constraint + ALTER on bundle change

Retain the closed-enum semantics at the DB layer + emit an ALTER TABLE …​ CHECK migration when a new bundle adds a kind.

Rejected: defeats the open-registry intent at the DDL layer + introduces a migration step per bundle-author change (slow + ceremony-heavy for a contract-only change). The app-layer dual-site validation provides the same safety with no DDL friction.

8. federal_mapping as a separate registry populated independently of StateBundle

Decouple federal-category mapping from partner-type registration; require operators to populate the mapping separately.

Rejected: decoupling federal_mapping from StateBundle::contribute() is rejected for ergonomic reasons — co-locating both on the same trait keeps the contract atomic at the bundle author level. The consumer-skew bug (partner_type registers without federal mapping) is mitigated by typed ReportingError::MissingFederalMapping at report-emit time per Amendment A7.

9. Runtime-loaded bundles via dynamic library / WASM

Bundles load at runtime via libloading or WASM modules; build-time compilation is replaced.

Rejected: Rust dynamic linking has poor ergonomics (no stable ABI, complex symbol resolution, brittle across rustc upgrades). WASM is interesting but the binding surface (5 registries + async transport + reqwest) doesn’t map cleanly. Operator-side multi-tenancy is the use case for runtime loading + is explicitly out of scope.

10. jurisdiction_code column on exchange_partners for runtime per-row filtering

Add a column so partners can carry their jurisdiction in the DB row + the service filters rows by settings.jurisdiction at query time.

Rejected: preserves 1-deployment-1-jurisdiction posture. Adding the column enables runtime multi-tenancy without addressing the upstream concerns (auth scoping, audit scoping, jurisdiction routing in handlers). Deferring per Out of scope.

(Footnote: the inventory crate for type-driven static registration was considered. Rejected because (a) link-time registration semantics are surprising for a new architectural concept; (b) it adds a runtime dependency for no real benefit over the per-crate macro pattern; (c) it conflicts with the Cargo-features-control-which-bundle-compiles model.)

Anticipated (Phase 2 design contracts; cited in plain-text form until each ADR lands, then converted to xref: by the originating MR):

  • ADR-033 (anticipated) — Plugin Manifest + Render RuntimeBundleContribution::plugins field semantics + #[craig_plugin] macro + compile-time linkme distributed slices for plugin + sibling slice for Askama templates. Anchors Plan W.

  • ADR-034 (anticipated) — Terminology OverlayBundleContribution::terminology field + Fluent per-jurisdiction overlay shape extending services/craig-web/src/i18n.rs. Anchors Plan U Step 10.

  • ADR-035 (anticipated) — Composition Layer EngineBundleContribution::compositions field + NEW services/craig-composition backend service (port 8009) + 5-layer merge semantics (user delta → role override → jurisdiction live override → jurisdiction baseline → product default) + RFC 6902 JSON Patch deltas + RFC 8785 canonical hashing. Anchors Plan X.

  • ADR-036 (anticipated) — Token + Theme ExportBundleContribution::theme field + GET /assets/theme.css route (strict-CSP-compatible) + --color-*--primary/--accent token migration with alias layer. Anchors Plan U Step 9.

  • ADR-037 (anticipated) — Field Ownership + Authz ExtensionBundleContribution::field_ownership field + per-field read/write enforcement at OWNING backend service + BFF lock-icon reflection. Anchors Plan Y.

Source citations

  • crates/craig-exchange-contracts/src/lib.rs:175-215 — Plan L’s typed ExchangeAdapter trait shape; the typed contract ErasedAdapter sits alongside.

  • crates/craig-auth/src/actor_verifier.rs:59-111StaticActorJwksRegistry immutable-builder precedent for AdapterRegistry shape.

  • crates/craig-store/src/store.rs:16-63Store::from_config factory pattern; precedent for co-located trait + concrete backends in one focused crate (craig-exchange-transport).

  • services/craig-exchange/src/adapters/mod.rs:41-93 — the closed AnyAdapter enum being replaced.

  • services/craig-exchange/src/adapters/standard.rs:212-343StandardAdapter (SHINES catch-all) Value-Value-Value behavioral special-case; the macro likely ships a passthrough variant per §1.1’s design.

  • services/craig-exchange/src/api/partners_dtos.rs:26-32 — hardcoded #[serde(default = "default_adapter_kind")] shines default being removed.

  • crates/craig-reference/src/enums.rs:683 — closed PartnerType enum being replaced by PartnerTypeRegistry.

Amendments

Amendments are corrections + clarifications to specific sections of this ADR that do not warrant a superseding ADR. Each amendment cites the originating section + the correction. Amendments are dated; the most recent amendment date supersedes earlier conflicting text. This is the first CRAIG ADR to carry an == Amendments section; the pattern is precedent for future ADR refinements.

Amendment 2026-06-09 (Plan T iteration findings + ADR-038)

Plan T body authoring (Plan S Step 3) iterated through 5 contextless reviewer rounds + 3 user-driven contextless rounds. The iteration surfaced ADR-032 sections that under-specified architectural decisions or had drifted from current code reality. ADR-038 (Trait-Object & Registry Patterns) lands alongside this amendment block + codifies the recurring principles cited by A1/A2/A4/A5. Amendments below are listed in §-order.

A1. §1.1 ErasedAdapter signature — object-safety + format dispatch

§1.1 ErasedAdapter trait method signatures are amended on two axes.

Axis 1 (object-safety): methods return BoxFuture<'_, Result<…​>> per ADR-038 §1 Tier-O. The original §1.1 code block was ambiguous; this amendment makes BoxFuture explicit.

Axis 2 (format dispatch): BOTH send_value AND audit_value signatures gain format: Option<&str> because SHINES uses self.format in BOTH send (services/craig-exchange/src/adapters/standard.rs:248) AND audit (line 333). The amended §1.1 code block:

pub trait ErasedAdapter: Send + Sync + 'static {
    fn send_value(
        &self, endpoint: String, payload: Value, format: Option<&str>,
    ) -> BoxFuture<'_, Result<Value, ErasedAdapterError>>;
    fn audit_value(
        &self, response: &Value, format: Option<&str>,
    ) -> BoxFuture<'_, Result<Value, ErasedAdapterError>>;
    fn kind(&self) -> &'static str;
}

Macro expansion strategy (the typed ExchangeAdapter trait at crates/craig-exchange-contracts/src/lib.rs:175-215 STAYS UNCHANGED — fn send(endpoint_url, payload) is preserved per Plan L invariant):

  • Typed-partner variant (impl_erased_adapter!(CapsAdapter, "caps")): macro expansion IGNORES the format parameter. Typed per-partner adapters (Caps/Cprs/etc.) don’t carry format-dependent dispatch; format originates from SHINES-only behavior. The erased trait method receives format but doesn’t thread it into ExchangeAdapter::send.

  • Passthrough variant for SHINES (impl_erased_adapter!(StandardAdapter, "shines", passthrough)): macro expansion threads format into NEW StandardAdapter::send_with_format(endpoint, payload, format) + audit_with_format(response, format) direct methods (NOT through ExchangeAdapter::send, which can’t carry format). The per-instance StandardAdapter::format: String field at line 214 IS REMOVED; format becomes per-call.

A2. §1.2 AuditCodec trait + impls location

§1.2 parallel-registries language is amended: the AuditCodec trait AND all 10 per-partner impls live in crates/craig-partner-audit (NOT crates/craig-exchange-contracts; NOT per-partner crates) per ADR-038 §2 trait-location rule + Rust orphan rule.

Rationale (full chain):

  • AuditCodec::encode returns PartnerAuditEvent from craig-partner-audit.

  • Per ADR-038 §2 sub-clause 1: trait lives in the return-type crate or downstream.

  • Per ADR-038 §2 sub-clause 2 + Rust orphan rule: impl AuditCodec for <X>AuditPayload is legal only in the trait’s crate OR the type’s crate. Per-partner crates don’t depend on craig-partner-audit (would cycle); ∴ impls live in craig-partner-audit.

  • craig-partner-audit already depends on all 10 per-partner crates → it can name <X>AuditPayload for each impl.

Concrete shape:

// craig-partner-audit/src/codec.rs (NEW)
pub trait AuditCodec: Send + Sync + 'static {
    fn encode(&self, payload: &serde_json::Value)
        -> Result<PartnerAuditEvent, ErasedAdapterError>;
}

// craig-partner-audit/src/codec/{caps,cprs,...}.rs (10 files)
pub struct CapsAuditCodec;
impl AuditCodec for CapsAuditCodec {
    fn encode(&self, payload: &Value) -> Result<PartnerAuditEvent, ErasedAdapterError> {
        let typed: CapsAuditPayload = serde_json::from_value(payload.clone())?;
        Ok(PartnerAuditEvent::Caps(typed))
    }
}

Closed-aggregator limitation: PartnerAuditEvent at crates/craig-partner-audit/src/lib.rs:95-122 is a closed enum over the 10 known georgia partner audit payload types. A non-georgia state adding a new partner adapter cannot add a PartnerAuditEvent::* variant from outside craig-partner-audit. This contradicts ADR-032’s "open registry" intent at the audit-event aggregation layer specifically. Plan T inherits the closed-aggregator + accepts that non-georgia state adoption requires editing craig-partner-audit. Decoupling (per-state aggregators OR Box<dyn Any> seam OR registry-driven decode tables) is filed as a future tracking issue under epic &45.

A3. §1 / §1.2 noop:// sentinel handling

§1.2 dispatch model is clarified: the noop:// endpoint sentinel (test-injection mechanism) is handled INLINE at the dispatch entry point (currently services/craig-exchange/src/adapters/mod.rs:358-366 in adapter_for(); post-Plan T1/T2/T3 split, this moves to the dispatch site that consults AdapterRegistry), NOT via an entry in AdapterRegistry. Rationale: noop:// is not a partner kind; it’s a test-only escape hatch that should not pollute the production registry surface. The dispatch check is if endpoint.starts_with("noop://") { /* noop adapter */ } else { registry.resolve(kind)…​ }.

A4. §2.1 StateBundle::contribute() factory shape

§2.1 pub trait StateBundle signature fn contribute(&self) → BundleContribution is amended: BundleContribution::adapters field type is Vec<(&'static str, ErasedAdapterFactory)> (NOT Vec<(&'static str, Arc<dyn ErasedAdapter>)>) per ADR-038 §3 factory-shape rule. ErasedAdapterFactory = Arc<dyn Fn(&BootContext) → Arc<dyn ErasedAdapter> + Send + Sync>. Rationale: adapter constructors take Arc<dyn OutboundTransport> (Plan V Step 2 replaced the reqwest::Client the BootContext originally carried with a transport field — see §3.1 + ADR-038 §Companion-BootContext); pre-materialized adapters at bundle definition time cannot access that boot-time transport. BootContext lives in crates/craig-state-bundle per A6 crate placement (NOT in each per-state bundle crate — BootContext is a shared type the orchestrator constructs once at boot + passes to each factory; per-state crates only consume &BootContext references).

A5. §2.5 mock_routes contribution source

§2.5 mock-manifest contract is amended: mock partner modules MUST live at crates/craig-partner-*/src/mock.rs (NOT tools/craig-mock-server/src/<partner>.rs) per ADR-038 §4 aggregate-field-sourcing rule. Bundle crates contribute mock_routes but depend on per-partner crates — NOT on tools/craig-mock-server. Mock partner code MUST live where bundles can see it.

Axum dep gating: per-partner crates currently have NO axum dependency. Each per-partner crate gains an OPTIONAL mock Cargo feature gating the mock module + the axum dep. Production consumers (CLI, SDK, partner-edge) compile without the feature; mock-server enables craig-partner-*/mock to assemble the registry. The Plan implementing this MUST add [features] mock = ["dep:axum"] to every per-partner crate’s Cargo.toml + place pub mod mock behind #[cfg(feature = "mock")].

Feature propagation through bundle crates: bundle crates that contribute mock_routes ALSO need a mock feature that propagates to their per-partner crate deps. Concrete: craig-state-default/Cargo.toml adds [features] mock = ["craig-partner-caps/mock", "craig-partner-cprs/mock", …​]; mock-server enables craig-state-default/mock. Without this propagation, the bundle’s contribute() would reference mock symbols that production builds never compile.

Plan L F-063 impact: Plan L Step 7’s mock-server adapter integration tests currently live in tools/craig-mock-server. After A5 migrates mock modules to per-partner crates, those tests either (a) move to per-partner crate tests behind the mock feature, or (b) stay in mock-server which builds with all craig-partner-*/mock features enabled. The Plan implementing A5 chooses; both are valid.

Also amended: MockRouteFactory type signature is Arc<dyn Fn() → axum::Router + Send + Sync> (NOT Arc<dyn Fn() → Router<MockState> + Send + Sync>). The shared MockState at tools/craig-mock-server/src/state.rs is removed; each mock partner module becomes self-state-bound via Arc<RwLock<…​>> closures captured by its routes() function. Mock partners are independently stateful in the multi-jurisdiction world; no shared state.

A6. §2.1 BundleContribution + StateBundle crate location

§2.1 trait-location language is amended: BundleContribution aggregate + StateBundle trait live in NEW crates/craig-state-bundle (NOT craig-exchange-contracts). Rationale: craig-exchange-contracts retains its runtime-dep-free posture per §3.1; BundleContribution::mock_routes requires axum, StateBundle::federal_mapping requires craig-reference. The new crate sits at the dep-graph node where bundle authors operate: state-bundle → {contracts, partner-audit, reference}. Precedent: craig-store co-located trait+impl pattern.

A7. §2.4 federal-completeness check timing

§2.4 federal mapping enforcement is amended: the "service refuses to start on missing entry" boot-time check is REPLACED with a REPORT-EMIT-time check. Rationale: a seed-default bundle (e.g. craig-state-default) registering partner_type tokens that lack real federal mappings should not block service startup — Plan U Step 2 ships real per-jurisdiction mappings; blocking Plan T archive on Plan U is incorrect coupling. The federal-completeness check moves to services/craig-reporting’s AFCARS + NCANDS row-emit paths: a row whose `partner_type has no federal mapping returns a typed ReportingError::MissingFederalMapping. Boot-time check is retained ONLY for adapter_kind + partner_type registry membership.

This amendment carries 6 companion edits to ADR-032 that have been APPLIED INLINE in this MR alongside this amendment block:

  • §2.3 federal enum description: "boot-time validation" → "report-emit-time validation"

  • §2.4 enforcement points: boot-time federal check removed; only registry membership remains

  • §Consequences§Positive "Federal consumer enforced": rewritten for report-time enforcement

  • §Consequences§Negative bundle-author burden mitigation: typed error at report-time

  • §Mitigations: fail-typed (not fail-fast) on missing federal_mapping

  • §Alternatives§Alt-8 rationale: consumer-skew bug mitigated at report-time

A8. §2.6 hardcoded "georgia" defaults — corrected site list

§2.6 originally enumerated 4 "production default" sites. Verification 2026-06-09 — crates/craig-common/src/settings.rs:406 is inside [cfg(test)] (line 394 starts the [cfg(test)] test-helper block; :406 falls inside it). All 3 of bootstrap.rs:623 + intake/config.rs:355 + settings.rs:406 are #[cfg(test)] test helpers; only tools/craig-seed/src/datagen.rs:1630 is a production seed default.

  • crates/craig-common/src/settings.rs:406 — TEST HELPER (#[cfg(test)] block starting at :394). NOT production.

  • crates/craig-api/src/bootstrap.rs:623 — TEST HELPER (fn unreachable_settings(…​)).

  • services/craig-intake/src/config.rs:355 — TEST HELPER (#[cfg(test)] mod tests).

  • tools/craig-seed/src/datagen.rs:1630 — PRODUCTION seed default (seed tool runs in dev only; the only true production default in the §2.6 list).

Existing production wiring at crates/craig-common/src/settings.rs:76 (pub jurisdiction: String) already REQUIRES per-service env var CRAIG_<SVC>__JURISDICTION (per A9). The "hardcoded georgia in production" concern §2.6 raises is already addressed by the per-service settings convention; the §2.6 4-site list mostly identified test helpers.

Plan T scope for §2.6 is REDUCED: convert tools/craig-seed/src/datagen.rs:1630 to CRAIG_SEED__JURISDICTION env var; leave the 3 test-helper sites as-is per CRAIG’s standard test-fixture allowance (test-fixture defaults are not state-neutrality concerns).

The §2.6 fixture allowlist (crates/craig-authz/…​ etc.) is unchanged. The §2.6 carve-outs (datagen.rs:826 rule_set_name; :1617 comment) are unchanged.

A9. §2.6 env var naming

§2.6 CRAIG_JURISDICTION env var name is amended: CRAIG’s actual per-service naming convention is CRAIG_<SERVICE>JURISDICTION (e.g. CRAIG_EXCHANGEJURISDICTION, CRAIG_INTAKE__JURISDICTION). Settings loader at crates/craig-common/src/settings.rs:69 expects per-service env vars; field at line 76 (pub jurisdiction: String) is required-from-env.

Post-A8: this per-service convention is ALREADY in place. Plan T’s scope for §2.6 + §2.7 reduces to (a) converting the 1 production seed-default site to CRAIG_SEED__JURISDICTION; (b) verifying no other production paths bypass the existing per-service settings. The deployment-global CRAIG_JURISDICTION originally in §2.7 is NOT introduced.

A10. §2.7 CRAIG__ACTIVE_STATE_BUNDLES activation timing + §2 execution label

§2.7 CRAIG__ACTIVE_STATE_BUNDLES env var is clarified: ACTIVATION lands in Plan U Step 4 (Cargo-feature gating + env-var-driven bundle selection), NOT in Plan T. Plan T (or its T1/T2/T3 split) ships hardcoded DefaultBundle activation at the exchange service main. Rationale: activation requires multiple bundles to exist; Plan T only ships one (craig-state-default); Plan U ships craig-state-ga + craig-state-tx-stub and is where multi-bundle activation becomes meaningful. The §2.7 fail-fast semantics (empty / missing var → error) apply to Plan U Step 4’s implementation.

§2 execution-label correction: ADR-032 §2 header originally read "Bundle composition (executed by Plan U)". This label is incorrect post-amendment because Plan T ships StateBundle trait + BundleContribution aggregate + DefaultBundle concrete bundle + hardcoded activation. Plan U executes only the multi-bundle activation + per-jurisdiction bundles. The §2 header label is REWRITTEN in this MR as "Bundle composition (foundation executed by Plan T; multi-bundle activation + per-jurisdiction bundles executed by Plan U)".

A11. §4 Phase 2 field shape (factory vs pre-materialized)

§4 BundleContribution-grows-additively language is clarified: each Phase 2 field defaults to PRE-MATERIALIZED shape unless ADR-038 §3 selection rule applies. Each Phase 2 plan author chooses per their field:

  • Plan W plugins: Vec<PluginManifest> — pre-materialized (no boot-time external resource).

  • Plan U Step 10 terminology: TerminologyContribution — pre-materialized (Fluent catalog content).

  • Plan X compositions: CompositionContribution — factory shape if compositions take per-state operator context at boot; pre-materialized otherwise.

  • Plan U Step 9 theme: ThemeContribution — pre-materialized (CSS token data).

  • Plan Y field_ownership: FieldOwnershipContribution — pre-materialized (static ownership mappings).

Each Phase 2 plan’s anchoring ADR (033/034/035/036/037) MUST document its field’s shape choice + cite ADR-038 §3 selection rule.

Amendment provenance

These amendments arose during Plan T body authoring (Plan S Step 3). The 8-round iteration surfaced ADR defects that prior contextless reviewer rounds did not catch. Amendments dated 2026-06-09 represent the post-iteration state of ADR-032; Plan T1/T2/T3 (Plan T umbrella split per the same authoring session) execute against this amended text. ADR-038 (Trait-Object & Registry Patterns) lands alongside this amendment block + codifies the recurring principles A1/A2/A4/A5 cite.

Amendment 2026-06-16 (ADR-033 — plugin axis sourced via PluginSource)

A12. §4 + A11 — the plugins field is NOT added; the plugin axis is sourced via PluginSource

§4’s anticipated BundleContribution::plugins: Vec<PluginManifest> field (also listed in A11’s per-field shape table) is NOT added. Per ADR-033 §5, the plugin axis is discovered through the compile-time PluginSource / CRAIG_PLUGINS distributed slice (v1; a WasmPluginSource in v2) and materialized BFF-local into a pre-materialized PluginRegistry, which the boot orchestrator validates alongside the rest of the aggregate. A hand-built Vec<PluginManifest> on the contribution would duplicate the manifests the slice/loader already own (a second source of truth that drifts), so it is omitted. A11’s "Plan W plugins: Vec<PluginManifest> — pre-materialized" bullet is superseded to this extent: the registry remains pre-materialized (ADR-038 §3 — no boot-time external resource; the host supplies the transport + render context per call), but there is no aggregate field. The additive-growth convention of §4 continues to govern the other Phase-2 fields (terminology, theme, compositions, field_ownership).

Amendment 2026-06-18 (ADR-037 — field_ownership shape: declaration in bundle, owner map on disk)

A13. §4 + A11 — field_ownership is a pre-materialized DECLARATION; the per-jurisdiction owner map is runtime-loaded from rulesets/

A11’s "Plan Y field_ownership: FieldOwnershipContribution — pre-materialized (static ownership mappings)" bullet is refined per ADR-037 §4. The contribution stays pre-materialized (a value, not a boot-time factory — no per-state operator resource is needed at construction, ADR-038 §3), but it carries a DECLARATION (the field-bearing surfaces + each surface’s field roster), NOT the owner classifications. The per-jurisdiction OWNER MAP (which field is state-owned / provider-owned / shared) is NOT bundle-embedded — it is runtime-loaded by the owning backend from rulesets/<jurisdiction>/cwca_ownership.toml, exactly as ADR-035 §8 runtime-loads composition baselines from rulesets/ rather than embedding them in compositions. This is the same declaration-in-bundle / data-on-disk split applied consistently across the two data-bearing Phase-2 axes; A11’s "static ownership mappings" parenthetical (which read as bundle-embedded owner data) is superseded to this extent. The additive-growth convention of §4 is unchanged; only A11’s characterization of the owner data's home is refined.

Edit this page · latest