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:

  1. Trait-object dispatch style — RPITIT vs #[async_trait] (BoxFuture) vs channel+!Send-thread

  2. Trait location — which crate hosts a trait that abstracts types from upstream crates

  3. Registry shape — pre-materialized vs factory-shaped when boot-time resources are needed

  4. 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 via Arc<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 Arc<dyn Trait>.

Plain fn method(&self, …​) → Result<…​>. Object-safe naturally. Builder pattern (new() + with_entry()) for immutable registries.

ActorJwksRegistry at crates/craig-auth/src/actor_verifier.rs:59-63 (+ StaticActorJwksRegistry builder impl at :73-103)

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 — fn method(…​) → impl Future<…​> + Send. Associated types allowed. Zero-allocation dispatch. NOT object-safe.

ExchangeAdapter at crates/craig-exchange-contracts/src/lib.rs:175-215

Tier-O (object async)

Polymorphism is the NORM. Callers hold Arc<dyn Trait> or Box<dyn Trait>. Allocation per call is acceptable.

#[async_trait] from async_trait crate (sugar over BoxFuture<'a, Result<…​>>). Methods return Pin<Box<dyn Future<Output=…​> + Send + 'a>> internally. Fully object-safe.

AuthzEngine at crates/craig-authz/src/engine.rs:40-61

Tier-C (channel)

The underlying library produces !Send futures (e.g. Rc<…​> internal state). Neither RPITIT nor BoxFuture works because the future itself can’t move across threads.

Dedicated single-threaded tokio runtime; mpsc channel + oneshot reply for each call. Trait methods are sync API; concrete impl drives the channel.

ZenAuthzEngine eval thread at crates/craig-authz/src/eval_thread.rs (per ADR-024)

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 !Send and refactoring to Send is 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:

  1. A trait T whose method signatures reference type R from crate C_RT must live in C_R OR a crate downstream of C_R.

  2. A trait T implemented for type I from crate C_I → due to orphan rule, the impl must live in either C_T (trait’s crate) OR C_I (type’s crate).

  3. If T references R AND is implemented for types from N different crates: T lives in the common upstream. If no common upstream exists, the dep direction is broken; restructure.

  4. Callers of T may 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::encode returns PartnerAuditEvent from craig-partner-audit.

  • AuditCodec implementations target the 10 per-partner audit-payload types.

  • craig-partner-audit already depends on all 10 per-partner crates (it wraps their AuditPayload types in PartnerAuditEvent variants).

  • Per the orphan rule: per-partner crates CANNOT implement AuditCodec for <X>AuditPayload if AuditCodec lives in craig-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>AuditCodec types (e.g. pub struct CapsAuditCodec; + impl AuditCodec for CapsAuditCodec) as thin wrappers around CapsAuditPayload (which it can name because it already depends on craig-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 the reqwest::Client the Plan T BootContext originally carried).

  • Construction takes per-state-bundle config that varies across bundles (different bundles pass different BootContext fields).

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>.rs to crates/craig-partner-*/src/mock.rs so 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-conventions standard, § 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

  1. 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".

  2. 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.

  3. Blanket "use RPITIT everywhere". Rejected: forces typed enum wrappers (AnyAdapter-style) for every polymorphic dispatch site; reintroduces the closed-enum pattern Plan T removes.

  4. 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.

  5. 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.

  • 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 == Amendments section 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) — ActorJwksRegistry Tier-S precedent.

  • crates/craig-authz/src/engine.rs:40-61AuthzEngine Tier-O #[async_trait] precedent. ALSO §2 trait-location precedent: trait + concrete ZenAuthzEngine impl co-located in craig-authz crate.

  • crates/craig-authz/src/eval_thread.rs — Tier-C channel+!Send precedent.

  • crates/craig-exchange-contracts/src/lib.rs:175-215ExchangeAdapter Tier-T RPITIT precedent.

  • services/craig-exchange/src/adapters/standard.rs:212-343StandardAdapter Value-boundary precedent that the future erased-seam impl_erased_adapter! mirrors.

Edit this page · latest