ADR-038: Trait-Object & Registry Patterns
On this page
Status
Accepted 2026-06-09. Anchors recurring architectural decisions surfaced during Plan T authoring (Plan S keystone child). Sibling to ADR-032 (Multi-Jurisdiction Partner Registry); cited by ADR-032 amendments + anticipated Plan T1/T2/T3 + Plans U/V/W/X/Y.
Context
Plan T body authoring (Plan S Step 3) iterated through 5 contextless + 3 user-driven contextless rounds. Each round surfaced architectural decisions that the underlying anchor ADR (ADR-032) had under-specified. Four of those decisions are reusable patterns that future plans extending the bundle aggregate (Plans W/X/Y) and adding new trait abstractions (Plan V transport, Plan W plugins, Plan X composition) will face identically:
-
Trait-object dispatch style — RPITIT vs
#[async_trait](BoxFuture) vs channel+!Send-thread -
Trait location — which crate hosts a trait that abstracts types from upstream crates
-
Registry shape — pre-materialized vs factory-shaped when boot-time resources are needed
-
Aggregate field sourcing — where do
BundleContribution-style field values come from in the dep graph
CRAIG already has three trait-object dispatch precedents in production code that ADR-038 codifies:
-
crates/craig-auth/src/actor_verifier.rs:59-63(trait) +:73-103(builder impl) — sync trait, object-safe, immutable builder (StaticActorJwksRegistry) -
crates/craig-authz/src/engine.rs:40-61—#[async_trait](BoxFuture under the hood), object-safe viaArc<dyn AuthzEngine>(per ADR-024) -
crates/craig-exchange-contracts/src/lib.rs:175-215— RPITIT (impl Future + Send), NOT object-safe; typed dispatch only (per Plan L invariant)
No existing CRAIG ADR or the coding-conventions standard documents WHEN to use which. This ADR fills that gap.
Decision
§1. Object-safety tiering
CRAIG traits use one of four dispatch styles. Pick by the dominant caller pattern. (Line ranges verified 2026-06-09.)
| Tier | When | How | CRAIG precedent |
|---|---|---|---|
Tier-S (sync) |
Trait has no async methods. Polymorphism is fine via |
Plain |
|
Tier-T (typed async) |
Every dispatch site knows the concrete type at compile time. Polymorphism is the EXCEPTION (handled separately via a per-tier-O sibling trait). |
RPITIT — |
|
Tier-O (object async) |
Polymorphism is the NORM. Callers hold |
|
|
Tier-C (channel) |
The underlying library produces |
Dedicated single-threaded tokio runtime; mpsc channel + oneshot reply for each call. Trait methods are sync API; concrete impl drives the channel. |
|
Selection rule:
-
New trait → DEFAULT to Tier-O (
#[async_trait]). The allocation per call is negligible at CRAIG scale; the future-proofing for polymorphic callers (test doubles, hot-swap impls, registry-based dispatch) is high-value. -
ONLY drop to Tier-T when:
-
Profiling shows the allocation overhead is material (rare); AND
-
All current + foreseeable callers know the concrete type at compile time.
-
-
ONLY use Tier-C when the underlying dep is
!Sendand refactoring toSendis infeasible.
Companion trait when typed + polymorphic both needed: ship a Tier-T trait + a separate Tier-O erased seam trait (per ADR-032 §1.1 ErasedAdapter precedent). The erased seam goes in the same crate as the typed contract; impls auto-derive via a declarative macro (impl_erased_adapter! precedent).
§2. Trait location
A trait MUST live in a crate that the trait declaration’s referenced types + the impls + the callers can all reach via the dep graph. Rust’s orphan rule further constrains: impl Trait for Type is only legal if either the trait OR the type is owned by the crate doing the impl.
The rule has 4 sub-clauses:
-
A trait
Twhose method signatures reference typeRfrom crateC_R→Tmust live inC_ROR a crate downstream ofC_R. -
A trait
Timplemented for typeIfrom crateC_I→ due to orphan rule, the impl must live in eitherC_T(trait’s crate) ORC_I(type’s crate). -
If
TreferencesRAND is implemented for types from N different crates:Tlives in the common upstream. If no common upstream exists, the dep direction is broken; restructure. -
Callers of
Tmay live downstream of `T’s crate (normal) but NEVER upstream of any concrete impl crate (would force callers to depend on every impl — defeats abstraction).
Example resolution (ADR-032 amendment A2):
-
AuditCodec::encodereturnsPartnerAuditEventfromcraig-partner-audit. -
AuditCodecimplementations target the 10 per-partner audit-payload types. -
craig-partner-auditalready depends on all 10 per-partner crates (it wraps their AuditPayload types inPartnerAuditEventvariants). -
Per the orphan rule: per-partner crates CANNOT implement
AuditCodec for <X>AuditPayloadifAuditCodeclives incraig-partner-audit(per-partner crates don’t depend on partner-audit — would create cycle). -
∴ Both the trait AND the impls live IN
craig-partner-audit. The aggregator crate hosts the 10<X>AuditCodectypes (e.g.pub struct CapsAuditCodec;+impl AuditCodec for CapsAuditCodec) as thin wrappers aroundCapsAuditPayload(which it can name because it already depends oncraig-partner-caps). No cycle; orphan rule satisfied.
Anti-pattern check: any time a trait’s return type references a type from a downstream crate, the trait is in the wrong crate. If a trait’s impls require depending on the trait’s crate from a crate already-upstream-of, the impls must move to the trait’s crate (orphan rule + dep direction force this).
Known limitation — closed-enum aggregators vs open registries: when an aggregator type (like PartnerAuditEvent) is a closed enum over known impl-types, the "open registry" semantic that ADR-032 §1.2 promises is incomplete. A non-georgia state adding their own partner adapter cannot add a PartnerAuditEvent variant from outside craig-partner-audit. The architectural resolutions are (a) make the aggregator a Box<dyn Trait> or Value-based seam (loses type safety), (b) per-state aggregators (each state’s bundle ships its own typed aggregator), or (c) accept the closed-enum limitation + edit craig-partner-audit per non-georgia state adoption. Plan T inherits option (c); decoupling the closed-enum aggregator from the registry is filed as a future tracking issue.
§3. Registry factory shape
Registries are immutable lookups built once at boot. Their entries can be:
-
Pre-materialized values (
HashMap<&'static str, Arc<dyn T>>) — when entries can be constructed from data available at bundle-definition time (e.g.Arc::new(CapsAuditCodec)). -
Factories (
HashMap<&'static str, Arc<dyn Fn(&BootContext) → Arc<dyn T>>>) — when entry construction needs a boot-time-only shared resource that bundles don’t own (e.g.Arc<dyn OutboundTransport>, or a registry built earlier by the orchestrator in dep-order).
Per-row and per-request data NEVER flow through registry construction — they flow through the trait method signature (send(format, endpoint, payload)). The factory-shape question is purely about boot-time resource availability.
Selection rule: use factory shape if EITHER of these is true at BOOT TIME:
-
Construction takes a shared resource that’s introduced later in the plan sequence (e.g.
Arc<dyn OutboundTransport>, introduced in Plan V Step 2, which replaced thereqwest::Clientthe Plan TBootContextoriginally carried). -
Construction takes per-state-bundle config that varies across bundles (different bundles pass different
BootContextfields).
Per-row data (e.g. exchange_partners.endpoint + exchange_partners.format from DB) and per-request claims are NOT boot-time inputs — they belong on the trait method signature (send(format, endpoint, payload)), NOT as factory parameters or per-instance state.
Otherwise use pre-materialized values.
Concrete dispatch shape: ErasedAdapter::send_value(endpoint: String, payload: Value, format: Option<&str>) carries per-row format on the method signature. Current SHINES at services/craig-exchange/src/adapters/standard.rs:214 stores format as a per-instance field; ADR-038 §3 says per-row data goes on the call site (not factory). The migration removes the per-instance field + threads format through every call.
Mutability + re-entrancy: registries are write-once at boot; mutation requires a service restart. Cross-registry lookup DURING construction is forbidden — the boot orchestrator materializes registries in dep-order (e.g. partner_type_registry before adapter_registry if adapters consult partner_types). An adapter constructor MAY consult BootContext (boot-time-constructed inputs) but MUST NOT consult another registry by reference; doing so creates boot-order dependencies the orchestrator can’t validate.
Companion: BootContext. Factory shape requires a context type that flows through the orchestrator at materialization time. CRAIG convention: a per-crate BootContext struct that grows additively for genuinely-new resources (Plan W adds plugin_registry; etc.). The lone exception is Plan V Step 2, which replaced the original http_client: reqwest::Client field with transport: Arc<dyn OutboundTransport> rather than adding alongside it — exposing the concrete wire client beside the transport abstraction would leak the very library the seam hides; the additive convention still governs every other resource. The orchestrator constructs BootContext once at boot and passes &BootContext to each factory.
§4. Aggregate field sourcing
Aggregate types like BundleContribution (per ADR-032 §2.1) carry contributions from N bundle authors. Each field has a source-crate constraint: the field’s value type must originate in a crate the bundle author’s crate can see (i.e. upstream of the bundle author’s crate in the dep graph).
Rule: a field F: T on aggregate A whose value comes from bundle crate B_i REQUIRES that type T lives in a crate B_i depends on.
Example resolution (ADR-032 amendment A5 — mock_routes ownership):
-
BundleContribution::mock_routes: Vec<(&str, MockRouteFactory)>per ADR-032 §2.5. -
MockRouteFactory = Arc<dyn Fn() → axum::Router + Send + Sync>— needs axum dep. -
Bundles (e.g.
craig-state-default) need to construct mock_routes entries. -
Mock partner modules currently at
tools/craig-mock-server/src/<partner>.rs— can the bundle crate see them? NO; bundle crates don’t depend on tools/craig-mock-server. -
∴ Mock partner modules MUST migrate from
tools/craig-mock-server/src/<partner>.rstocrates/craig-partner-*/src/mock.rsso per-partner crates own their mock surface. Bundle crates already depend on per-partner crates → can construct factories.
Field-sourcing pre-flight: before adding a field to BundleContribution, trace the shortest dep path from each potential bundle author’s crate to the field’s value type. If the path doesn’t exist, restructure either the field type or the source location BEFORE the field lands.
§4 applies to AGGREGATES contributed-to by multiple author crates. Single-producer aggregates resolve trivially.
Consequences
Positive:
-
Trait extraction questions in future plans (T1/T2/T3, U/V/W/X/Y) resolve by checking ADR-038 §1/§2/§3/§4 rather than re-deriving each time.
-
Object-safety tiering selection is explicit; reviewers can challenge a Tier-T choice with "are all callers really typed?"
-
Factory shape rule prevents the "pre-materialized adapter holds reqwest::Client" bug pattern from recurring.
-
Field-sourcing rule catches mock_routes-style ownership inversion before implementation.
Negative:
-
Yet another convention page agents must internalize.
-
§1 Tier-O default may slow some hot paths via allocation; mitigated by §1’s "drop to Tier-T when profiling shows it" escape hatch.
-
§4 field-sourcing rule restricts where types can live; may force more crate splits to satisfy.
Mitigations:
-
The
coding-conventionsstandard, § Trait + registry conventions subsection (NEW; this MR adds) — points to ADR-038 with one-line summary per tier. -
ADR-038 examples are all drawn from production code (
AuthzEngine,ExchangeAdapter,StaticActorJwksRegistry) so the patterns are immediately verifiable.
Out of scope
-
Specific trait-object profiling thresholds for the §1 Tier-O→Tier-T escape hatch. Case-by-case judgment.
-
Workspace-wide retrofit of existing traits to ADR-038 conventions. Existing traits are grandfathered; only NEW traits + AMENDED ADRs follow.
Alternatives considered
-
No ADR — document per-tier conventions in coding-conventions.md only. Rejected: principles deserve ADR-level treatment for the audit trail. Plans should be able to cite "per ADR-038 §1 Tier-O".
-
Blanket "use #[async_trait] everywhere". Rejected: Plan L invested in RPITIT typed dispatch for
ExchangeAdapter; reverting is wasted work + loses typed-callers' zero-allocation property. -
Blanket "use RPITIT everywhere". Rejected: forces typed enum wrappers (
AnyAdapter-style) for every polymorphic dispatch site; reintroduces the closed-enum pattern Plan T removes. -
Co-locate object-safety tier choice in each trait’s own docstring. Rejected: convention drift across crates; no single source of truth for reviewer challenges.
-
Separate ADRs for §1/§2/§3/§4. Rejected: 4 ADRs file in parallel; reviewers + plan citations get diffuse. Single ADR with 4 numbered decision points is the ADR-027/ADR-030 precedent.
Related decisions
-
ADR-024 — Tier-O + Tier-C precedent for
AuthzEngine. -
ADR-027 — pluggability principle that ADR-038 specializes.
-
ADR-030 — convention-ADR precedent for ADR-038’s principles-flavored shape.
-
ADR-032 — first ADR amended; its
== Amendmentssection cites ADR-038 §1/§2/§3/§4. -
Plan L — RPITIT typed-dispatch precedent + invariants ADR-038 §1 Tier-T preserves.
Source citations
-
crates/craig-auth/src/actor_verifier.rs:59-63(trait) +:73-103(builder impl) —ActorJwksRegistryTier-S precedent. -
crates/craig-authz/src/engine.rs:40-61—AuthzEngineTier-O#[async_trait]precedent. ALSO §2 trait-location precedent: trait + concreteZenAuthzEngineimpl co-located incraig-authzcrate. -
crates/craig-authz/src/eval_thread.rs— Tier-C channel+!Send precedent. -
crates/craig-exchange-contracts/src/lib.rs:175-215—ExchangeAdapterTier-T RPITIT precedent. -
services/craig-exchange/src/adapters/standard.rs:212-343—StandardAdapterValue-boundary precedent that the future erased-seamimpl_erased_adapter!mirrors.