Plan V: Transport + Substrate (child plan of Plan S umbrella)

On this page

Status

Step Description Status

V1

NEW crates/craig-exchange-transport: the OutboundTransport trait + value-objects + DirectHttpTransport + a recording StubTransport. Trait + concrete impls co-located in ONE crate per the craig-store precedent (crates/craig-store/src/store.rs co-locates the Store trait + LocalFs + S3). The trait is OutboundTransport: Send + Sync + 'static with send(TransportRequest) → BoxFuture<Result<TransportResponse, TransportError>> + probe(ProbeRequest) → BoxFuture<Result<(), TransportError>>. The value-objects carry PUBLIC fields (constructed cross-crate by the partner adapters in V2): TransportRequest { endpoint, body: Vec<u8>, headers: Vec<(String,String)>, timeout }, ProbeRequest { endpoint, timeout, method: ProbeMethod } (Head or Get — preserves partners' HEAD vs SHINES’s GET), TransportResponse { status: u16, body: Vec<u8> }, TransportError { Timeout, Connect, BadStatus { status, body }, Transport(String) }. reqwest is a PRIVATE impl detail — no reqwest type appears in any pub signature, and there is NO [from] reqwest::Error public variant. DirectHttpTransport { client: reqwest::Client }: send POSTs body + headers with req.timeout as a per-request override, mapping non-2xx → BadStatus { status, body }; probe issues HEAD or GET per ProbeRequest.method with req.timeout. StubTransport (behind a test-util Cargo feature) returns canned responses/errors AND records the TransportRequest`s it receives (`Arc<Mutex<Vec<TransportRequest>>>) so V2 can assert byte/header identity without a live server. New workspace member (root Cargo.toml members + [workspace.dependencies]); the house lint preamble (![deny(unused_crate_dependencies)] etc., copied from a leaf crate). No consumer yet — the V2 hard-sequencing gate (Plan T2 archived) is already satisfied, and V1 (crate creation) has no blocker.

Done (2026-06-15) — NEW crates/craig-exchange-transport: the OutboundTransport trait (send/probe) + public-field value-objects (TransportRequest/ProbeRequest/TransportResponse/TransportError/ProbeMethod) + DirectHttpTransport + recording StubTransport (behind test-util), co-located per craig-store. reqwest private (no reqwest type in any pub signature; mapped internally timeout→Timeout / connect→Connect / non-2xx→BadStatus / else Transport). 9 unit tests (6 DirectHttpTransport round-trip/non-2xx/content-type/timeout/HEAD+GET-probe against a throwaway axum server + 3 StubTransport); clippy -D warnings clean (default + --all-features). MR !704 / 84c2461b.

V2

ATOMIC swap: the 11 wire consumers + BootContext + the factory macro + the orchestrator move onto Arc<dyn OutboundTransport> — plus the ADR reconciliation. ONE MR (the BootContext field is a shared breaking change; the whole wire path moves together; StubTransport shipped in V1 means no transitional dual-field). BootContext (crates/craig-state-bundle/src/boot_context.rs): REMOVE http_client, ADD transport: Arc<dyn OutboundTransport>, and drop the now-impossible [derive(Debug)] (verified unused; Arc<dyn Transport> is not Debug; keep Clone). The craig-state-ga adapter_factory! macro (src/lib.rs:73-82) wraps ctx.transport.clone(). The orchestrator (services/craig-exchange/src/bundle_orchestrator.rs) wraps the shared client in DirectHttpTransport once at boot (Arc::new(DirectHttpTransport::new(http_client))) and injects SHINES through it; main.rs:103 still builds + owns the shared reqwest::Client. The 10 partner adapters (crates/craig-partner-/src/adapter.rs) + the SHINES StandardAdapter (services/craig-exchange/src/adapters/standard.rs): new(transport); send does serde_json::to_vec(&payload)TransportRequest { …, headers: [("content-type","application/json")], timeout }transport.send; test_connectivity builds a ProbeRequest preserving each adapter’s verb (partners HEAD, SHINES GET) on {endpoint}/health. Error translation is EXPLICIT per-arm matching, NOT a blanket [from]: the partner/SHINES error enum ADDS a Transport(TransportError) catch-all and KEEPS Timeout / BadStatus { status, body } / MalformedResponse — the adapter maps TransportError::Timeout → Self::Timeout, BadStatus{..} → Self::BadStatus, Connect/Transport(_) → Self::Transport (a blanket #[from] would mis-route Timeout/BadStatus into the catch-all). DROP the direct reqwest dep from the 10 partner crates + craig-state-bundle; the SHINES error enum stops naming reqwest::{Error,StatusCode}. The exchange SERVICE keeps reqwest (services/craig-exchange/Cargo.toml) — main.rs still builds the shared client. Update the ~70 mechanical test-construction sites: the 4 inline tests in each of the 10 adapter.rs (→ StubTransport), all 10 tests/round_trip.rs (3 each → wrap a reqwest::Client in DirectHttpTransport, dev-dep), craig-state-ga/src/lib.rs:445, the craig-state-bundle test, services/craig-exchange/src/adapters/mod.rs:52 + standard.rs:300, and the orchestrator tests (:443/:488/:506). ADR reconciliation: amend ADR-038 §Companion-BootContext + ADR-032 A4 + the BootContext doc-comment to record that Plan V *replaces http_client with transport (the "grows additively" convention still governs future resources; A4 already anticipates "swaps to Arc<dyn OutboundTransport>`"). Byte-identity is proved by a recording-`StubTransport unit test (req.body == serde_json::to_vec(&payload) + content-type: application/json + the 30s timeout); the 10 round_trip suites prove functional equivalence. Depends V1.

Done (2026-06-15) — atomic swap landed. BootContext now carries transport: Arc<dyn OutboundTransport> (http_client removed, Debug derive dropped); the craig-state-ga adapter_factory! macro + the orchestrator wrap the shared reqwest::Client in DirectHttpTransport once at boot and inject SHINES through it (main.rs still owns the client). All 11 wire consumers — the 10 partner adapters + the SHINES StandardAdapter — take Arc<dyn OutboundTransport> and route send/test_connectivity through it (serde_json::to_vecTransportRequest with content-type: application/json + per-request timeout; ProbeRequest preserving partners' HEAD vs SHINES’s GET). Error translation is EXPLICIT per-arm matching: each error enum gained a Transport(TransportError) catch-all and kept Timeout/BadStatus/MalformedResponse; reqwest::{Error,StatusCode} no longer named in any partner/SHINES error enum. reqwest dropped from the 10 partner crates' [dependencies] (dev-dep only, for the round_trip DirectHttpTransport wiring) + from craig-state-bundle entirely; the exchange SERVICE keeps it. ADR reconciliation rode this MR: ADR-038 §Companion-BootContext (+ the factory-shape examples) + ADR-032 A4 + the BootContext doc-comment now record the one-time replace (not additive-add). Byte-identity proved by 10 new recording-StubTransport unit tests (one per partner: body == serde_json::to_vec(&payload) + content-type header + 30s timeout); the 30 round_trip suites stay green (functional equivalence). Gates: cargo build/clippy -D warnings --workspace --all-targets clean; 277 partner/state/mock-server + 50 exchange-bin unit tests pass; the 8 cross-cutting invariant greps confirm zero code-level reqwest in the wire path (the residual matches are doc prose + the intended DirectHttpTransport::new(reqwest::Client) seam); quality-budgets LOCKED; state-tx-stub untouched (feature-matrix TX-only path unaffected). MR !705 / 28d31ef6.

V3

WebMethodsTransport runtime stub + a host-owned broker mock route. WebMethodsTransport implements OutboundTransport as a RUNTIME stub (not compile-only, per ADR-032 §3.1) modeling the Software AG Integration Server public invocation surface (/invoke/<folder>:<service>), wrapping the broker envelope rather than the bare partner JSON. The broker mock is NOT a per-partner MockRouteFactory (the partner registry stays stateless + partner-scoped per ADR-032 A5) — instead a NEW tools/craig-mock-server/src/broker.rs mounts /broker/… directly in router(), host-owned like /health. An integration test points WebMethodsTransport at the /broker mock and replays one invocation. Production deployment config (RetryPolicy + IdempotencyPolicy) is DEFERRED to #557 (GA DHS Layer 2 runbook blocker) — documented in the type’s doc comment + the §Open-questions list; the BrokerContract cross-state generalization (MuleSoft/BizTalk/custom) is likewise deferred. NOT wired into production boot — runtime transport selection is out of scope for Plan V (see §Open-questions). Depends V1 only (needs the trait, not the adapter swap) — can run in parallel with V2.

Done (2026-06-15) — WebMethodsTransport shipped in craig-exchange-transport (src/webmethods.rs): a RUNTIME OutboundTransport stub that invokes POST <broker>/invoke/<folder>:<service> (default craig.partner:send, overridable via with_service), wrapping the partner request in a { targetEndpoint, headers, payload } envelope and unwrapping the broker’s { status, body } reply — the partner’s own status surfaces through the same contract as DirectHttpTransport (non-2xx partner status → BadStatus; a non-2xx from the IS itself → Transport); probe GETs the wm.server:ping liveness service. The reqwestTransportError mapping was DRY’d into a shared src/wire.rs (to_transport_error + is_success, reused by both transports). NEW host-owned tools/craig-mock-server/src/broker.rs mounts /broker/invoke/{service} (POST = invoke / GET = ping) directly in router() via .merge (NOT a per-partner MockRouteFactory — registry stays stateless per A5); spawn_for_test() now serves it. RetryPolicy/IdempotencyPolicy deferred to #557 + BrokerContract generalization deferred (both doc-commented on the type; the body §Open-questions list lands at V7); NOT wired into production boot. Gates: clippy -D warnings --workspace --all-targets clean; 4 WebMethodsTransport unit tests (envelope wrap/unwrap, partner-non-2xx→BadStatus, broker-HTTP-fail→Transport, ping) + 2 broker.rs unit tests + 2 tests/broker_round_trip.rs end-to-end tests (real HTTP through the mock) pass; serde_json added to the transport crate; quality-budgets LOCKED. MR !706 / 0abc59dc.

V4

Fault injection in the mock substrate (layer-achievable faults). Add spawn_for_test_with(MockServerOptions) to tools/craig-mock-server (keep spawn_for_test() = spawn_for_test_with(Default::default()) so the existing callers + the 10 round_trip suites are untouched). A host-level tower::Layer wrapping the whole app injects: latency/timeout (sleep past the request deadline), bad status, and malformed body (non-JSON bytes on a 2xx). The fault state lives in the layer (Arc<…>), NOT in the stateless per-partner routers — respecting ADR-032 A5, mirroring how /health is host-owned. A true TCP reset / connection-drop is OUT of a Router layer’s reach (it needs a custom accept loop below axum::serve); it is scoped as a V4 stretch or deferred to a follow-up if not trivially achievable. Tests prove each fault maps to the right error (timeout → Timeout, bad status → BadStatus, malformed → MalformedResponse). Depends V2 (so faults are observable through the transport seam).

Done (2026-06-15) — NEW tools/craig-mock-server/src/fault.rs: MockServerOptions { fault: Option<Fault> } + Fault::{Latency(Duration), Status(u16), MalformedBody} (both pub-re-exported). A host-level fault middleware (axum::middleware::from_fn_with_state, applied via .layer to the WHOLE app — host-owned like /health + /broker, NOT a per-partner router per A5) short-circuits/delays every request: Latency sleeps past the client deadline (→ client timeout), Status returns the forced non-2xx, MalformedBody returns 200 + a non-JSON body. NEW spawn_for_test_with(MockServerOptions); spawn_for_test() = spawn_for_test_with(MockServerOptions::default()) (no fault → transparent passthrough, so the 30 round_trip suites + the broker test are untouched). The TCP-reset/connection-drop fault is DEFERRED (out of a Router layer’s reach — needs a custom accept loop below axum::serve; doc-commented in fault.rs + the §Open-questions list lands at V7). Gates: clippy -D warnings --workspace --all-targets clean; 3 fault.rs unit tests (passthrough / status-override / malformed-non-JSON via tower::oneshot) + 3 end-to-end caps/tests/round_trip.rs tests driving CapsAdapter through DirectHttpTransport against a faulted mock — Fault::LatencyCapsError::Timeout, Fault::Status(503)CapsError::BadStatus, Fault::MalformedBodyCapsError::MalformedResponse; quality-budgets LOCKED. MR !707 / 582a8d1b.

V5

Request-inspection API. The same MockServerOptions/layer gains a capture buffer (Arc<Mutex<Vec<CapturedRequest>>> recording method, path, headers, body bytes); MockServerHandle gains captured() (snapshot) + last_request_to(path). This is the wire-level twin of V2’s StubTransport byte-identity proof: a test captures the CAPS POST and asserts the body == serde_json::to_vec(&referral) + content-type: application/json on the actual socket. Host-owned layer state, not partner-registry state (ADR-032 A5). Depends V4 (shares the MockServerOptions + layer plumbing).

Done (2026-06-15) — NEW tools/craig-mock-server/src/capture.rs: CapturedRequest { method, path, headers, body } (pub-re-exported; header(name) case-insensitive lookup helper) + a host-level capture middleware (axum::middleware::from_fn_with_state) buffering every request’s method/path/headers/body into a shared Arc<Mutex<Vec<CapturedRequest>>>, then replaying the buffered body downstream so the inner handler (and any fault layer) sees an unchanged request. The capture layer is applied LAST in spawn_for_test_with so it is the OUTERMOST middleware — it records the raw incoming request before any fault layer can short-circuit it. MockServerHandle gained captured() (snapshot in arrival order) + last_request_to(path) (most-recent match). Host-owned layer state (the buffer is created in spawn_for_test_with and threaded into both the layer (writer) and the handle (reader)), NOT per-partner router state (A5); always-on, so every spawn_for_test() handle can inspect — the 30 round_trip suites + the broker + fault tests are untouched (capture is a transparent record-and-replay). Gates: clippy -D warnings --workspace --all-targets clean; 2 capture.rs unit tests (method/path/headers/body recording + case-insensitive header() + absent-header None; body replayed-downstream-unchanged) + 1 end-to-end caps/tests/round_trip.rs test (caps_post_is_captured_byte_identical_on_the_wire: drives a real CAPS POST through DirectHttpTransport, then last_request_to("/partner/caps") asserts body == serde_json::to_vec(&referral) + content-type: application/json + captured().len() == 1) pass; quality-budgets LOCKED. MR !708 / 3c38dd72.

V6

Property tests + fuzzing. proptest (the workspace already ships proptest = "1.7" + the tests/properties/ layout in 5 members) — in craig-exchange-transport: TransportError / status-split mapping totality; in a partner crate: the adapter serialize → bytes → deserialize round-trip for arbitrary valid payloads. cargo-fuzz (NEW — no fuzz/ exists): the untrusted response-body parse (serde_json::from_slice::<Inbound>) NEVER panics — only Ok / MalformedResponse. The fuzz target lives where the parse lives — under a PARTNER crate (CAPS pilot), since adapters own deserialization and the transport only moves bytes. The fuzz/ crate is a separate, publish = false crate EXCLUDED from the workspace members (cargo-fuzz injects -Z/libfuzzer that do not belong in the stable MSRV-1.88 workspace); it is nightly-only (rustup run nightly cargo fuzz), run locally + as an OPTIONAL non-blocking CI job, never in the default --workspace gate. Depends V2.

Done (2026-06-15) — proptest follows the existing tests/properties/ house pattern (no dep added — proptest = "1.7" already in [workspace.dependencies]). craig-exchange-transport (tests/properties/status_split.rs): the 2xx/non-2xx mapping is total — for any status in 200..=599, DirectHttpTransport::send (driven against a single shared status-echo axum server over a real loopback round-trip) returns Ok(resp) with resp.status == s iff s is 2xx, else Err(BadStatus { status: s, .. }), no panic, no gap (128 cases, covering the 200/299/300 boundaries; reqwest is already a normal dep of the crate so the integration test reaches it). craig-partner-caps (tests/properties/referral_round_trip.rs): (a) referral_survives_json_round_trip — any valid CapsReferral (a prop_compose!-built strategy over all 16 fields incl. the nested child/contacts/caregiver blocks, dates, UUIDs, optionals) survives serde_json::to_vecfrom_slice value-unchanged (256 cases); (b) inbound_ack_parse_never_panicsserde_json::from_slice::<CapsReferralAck> on arbitrary bytes only ever Ok/Err, never panics (1024 cases) — the stable-toolchain twin of the fuzz target. cargo-fuzz (NEW crates/craig-partner-caps/fuzz/, the repo’s first fuzz/): parse_inbound drives the same untrusted ack-parse via libFuzzer; publish = false + its own empty [workspace] table + listed in the root [workspace] exclude so it stays out of the stable MSRV-1.88 --workspace gate (verified: cargo metadata shows it is not a workspace member; clippy/fmt/quality-budgets/validate all unaffected). Verified locally on nightly: cargo +nightly fuzz build parse_inbound clean + a 20s cargo fuzz run did 3.3M iterations with zero crashes. OPTIONAL non-blocking CI job fuzz-smoke (nightly image, when: manual + allow_failure: true). clippy -D warnings --workspace --all-targets clean; quality-budgets LOCKED. MR !709 / 556c265b.

V7

Coverage-matrix audit + interface-doc predicates + open-questions doc. All 10 crates/craig-partner-/tests/round_trip.rs ALREADY exist (Plan L Step 7 — typed mocks migrated, full battery green); V7 does NOT add suites and does NOT typed-migrate mocks. It (a) AUDITS the per-partner coverage matrix as a doc table (each partner has a round_trip + transport-fault coverage + an interface predicate); (b) adds interface-doc predicates — golden-JSON fixtures asserting each adapter’s Outbound/Inbound serde shape matches its docs/modules/ROOT/pages/interfaces/.adoc wire contract; (c) writes this body’s §Open-questions enumerating the deferrals (#557 RetryPolicy/IdempotencyPolicy; BrokerContract generalization; runtime transport selection; the SHINES-vs-partner probe-verb asymmetry), each cross-referenced to its deferring ADR/issue. Depends V2 (signatures), optionally V4/V5/V6 (richer assertions).

Done (2026-06-15) — (a) NEW == Partner coverage matrix (V7 audit) section above: all 10 partner crates carry round_trip + types_round_trip + the V7 interface predicate; transport-fault is the CAPS pilot only (the fault layer is host-owned + partner-agnostic, so CAPS is representative — per-partner duplication not added); SHINES StandardAdapter is host-owned with no typed payload (mapping-doc contract), so no predicate. (b) interface-doc predicates — a _outbound_wire_shape_matches_interface_doc test per partner (CAPS pilot inline + the other 9 via a 9-agent Ultracode workflow, each reusing its existing types_round_trip sample) asserting the serialized wire shape carries every field the partner’s interfaces/.adoc documents (top-level + nested blocks + enum casings), each assertion citing its doc §. ADAPTATION (recorded): the interface docs are PROSE field-inventories, not canonical JSON — so the predicate asserts documented field-name presence + casing/structure (each assertion traceable to a doc §field) rather than an opaque golden blob that would merely snapshot our own serialization; this is more readable, more maintainable, and a truer doc predicate. The audit surfaced two genuine doc/type divergence notes (cprs county-collapse + merge-indicator modeling; empi EMPI-Create superset + CDOBVER code-set) — intentional, doc-commented in types.rs, recorded in the matrix † notes, not bugs. (c) §Open-questions expanded with the probe-verb-asymmetry deferral (the prior 4 deferrals were already present). Gates: clippy -D warnings --workspace --all-targets clean; 10 interface-predicate tests pass; the 10 new tests blessed into the axis-coverage opt-out (house convention for serde property/round-trip tests); quality-budgets LOCKED. MR !710 / 472613ae.

V8

Plan-completion audit + archive. A fresh Explore plan-completion-audit subagent verifies every V-step cell carries a concrete !MR / sha cite. Run the cross-cutting invariant greps (below) — including the SHINES check the umbrella’s partner-only completion grep misses, and flag the umbrella line-395 grep as needing a SHINES widen at umbrella Step 20. cargo xtask docs plan-archive (dry-run then execute): nav.adoc Active→archive move + a plans/archive.adoc § Architecture row + sibling-xref rewrites to the plans/archive/ path; flip :status: Active → Complete. Flip umbrella Step 15 → Done + close epic &47 (description PUT then a separate state_event PUT — the combined PUT 500s). Finalize the deferred docs (.claude/docs/shared-crates.md new-crate entry; architecture.adoc the seam). Append a .claude/CLAUDE.md § Completed Plans name. Memory sync. Depends all.

Done (2026-06-15) — this archive MR. Fresh Explore plan-completion-audit subagent confirmed every V1–V7 cell carries a concrete !MR / sha (V1 !704/84c2461b, V2 !705/28d31ef6, V3 !706/0abc59dc, V4 !707/582a8d1b, V5 !708/3c38dd72, V6 !709/556c265b, V7 !710/472613ae). All 8 cross-cutting invariants verified (see the §V8 verification note below the invariants list — INV4/7/8 exact; INV1/2/5/6/INV3 residuals are doc-comments / the intended constructor seam / the intended [dev-dependencies] reqwest, all benign — the seam is complete). Body git-mv’d to plans/archive/; :status: Active→Complete; nav Active entry removed; sibling xrefs repointed; plans/archive.adoc § Architecture row added (V1–V8 cites). Umbrella Step 15 → Done (Step 14 cite backfilled !703 / 2a6b7b57); the umbrella’s partner-only program-completion grep (line ~395) flagged for a SHINES widen at umbrella Step 20. Epic &47 closed. .claude/CLAUDE.md § Completed Plans + Active-Work row updated; shared-crates.md craig-exchange-transport entry confirmed final. !MR / sha backfilled at the next umbrella step (Plan W kickoff).

Epic: &47 (Plan V)
Scoped label: Plan::V (filed with this body MR; one Plan::* label per issue — scoped-label collisions 404)
Branch prefix: <type>/plan-v-step<N>- for child code-execution MRs
*Parent
: Plan S umbrella Steps 14 (this body) + 15 (execution)
Anchor ADR: ADR-032 §3.1 (the OutboundTransport trait + co-located impls + the webMethods runtime stub)

Context

Plan S Phase 3 makes CRAIG a state-neutral CCWIS platform. Plan T opened adapter dispatch (ErasedAdapter + the registries); Plan U opened bundle composition (StateBundle + BundleContribution + theme/terminology/seed). Plan V opens the last partner-axis seam ADR-032 §3.1 specifies: outbound transport.

Today the wire path is hardcoded to direct HTTPS. All 10 per-partner adapters (crates/craig-partner-*/src/adapter.rs) and the SHINES StandardAdapter (services/craig-exchange/src/adapters/standard.rs) hold a reqwest::Client directly and call .post() / HEAD-or-GET {endpoint}/health inline; each carries reqwest as a direct dependency and a Http(#[from] reqwest::Error) error variant. There is no abstraction a non-Georgia jurisdiction could substitute to route through its own broker (webMethods, MuleSoft, BizTalk).

Plan V introduces a NEW crates/craig-exchange-transport holding the OutboundTransport trait + a concrete DirectHttpTransport + a WebMethodsTransport runtime stub, co-located in one focused crate per the craig-store precedent. The partner crates depend on the transport abstraction (not reqwest); the BootContext the orchestrator threads into every adapter factory carries Arc<dyn OutboundTransport> instead of reqwest::Client; and the mock substrate grows broker-faithful testing (fault injection + request inspection + property/fuzz coverage). craig-exchange-contracts stays runtime-dep-free and never learns transport exists — CLI / SDK / contract tests consume contracts without pulling reqwest.

The success condition is concrete: the partner adapters contain no reqwest::Client / self.client.<verb> on the wire path; SHINES likewise sheds its reqwest::{Client,Error,StatusCode}; the WebMethodsTransport stub is test-reachable against a host-owned /broker mock route; and every existing round-trip suite stays green against the new constructor with byte-identical wire bytes.

Key decisions

Trait surface: request value-objects + a probe method

ADR-032 §3.1 sketches send(endpoint, body, headers) with …​ placeholders. The accepted, architecturally-correct surface elaborates that sketch into a parameter object plus a probe method:

// crates/craig-exchange-transport
pub trait OutboundTransport: Send + Sync + 'static {
    fn send(&self, req: TransportRequest)  -> BoxFuture<'_, Result<TransportResponse, TransportError>>;
    fn probe(&self, req: ProbeRequest)     -> BoxFuture<'_, Result<(), TransportError>>;
}

// PUBLIC fields — partner crates in OTHER crates construct these:
pub struct TransportRequest  { pub endpoint: String, pub body: Vec<u8>, pub headers: Vec<(String, String)>, pub timeout: Duration }
pub struct ProbeRequest      { pub endpoint: String, pub timeout: Duration, pub method: ProbeMethod /* Head or Get */ }
pub struct TransportResponse { pub status: u16, pub body: Vec<u8> }
pub enum   TransportError    { Timeout, Connect, BadStatus { status: u16, body: Vec<u8> }, Transport(String) }
// reqwest stays PRIVATE: no reqwest type in any pub signature; no `#[from] reqwest::Error` pub variant.

ADR-032 §4 mandates additive aggregate growth, and #557 defers RetryPolicy + IdempotencyPolicy that attach to every outbound call. The only shape that absorbs those (plus idempotency keys, correlation headers) additively — without a signature break rippling through 11 adapters and both impls — is a parameter object (the classic "introduce parameter object", applied exactly when a parameter list is expected to grow). It preserves wire behavior byte-for-byte: probe.method keeps the partners' HEAD vs SHINES’s GET, and the per-request timeout keeps the 5s connect / 30s send split. It matches the repo’s house style for taming fat signatures (the clap Args-struct convention). The literal send-only alternative was rejected — it regresses behavior (drops the HEAD /health probe and the timeout split); the positional-args alternative was rejected — every future field is an 11-call-site break.

Error translation: explicit per-arm matching, not a blanket #[from]

Each partner/SHINES error enum ADDS a Transport(TransportError) catch-all and KEEPS its Timeout / BadStatus { status, body } / MalformedResponse variants. The adapter maps TransportError explicitly: Timeout → Self::Timeout, BadStatus { status, body } → Self::BadStatus { status, body: String::from_utf8_lossy(&body).into_owned() }, Connect/Transport() → Self::Transport(). A blanket Transport([from] TransportError) with ?-propagation would wrap TransportError::Timeout as Self::Transport(..) rather than the existing top-level Self::Timeout — silently changing the error taxonomy callers match on. [from] is therefore used only for the residual Transport variant (if at all); the timeout/bad-status arms are mapped by hand, preserving today’s behavior.

BootContext: swap http_clienttransport, reconciled by amending the ADRs

BootContext’s only consumers are the adapter factories (`craig-state-ga macro) and the SHINES injection — both move to transport. A retained http_client would be a dead field. ADR-032 A4 already states Plan V "swaps reqwest::ClientArc<dyn OutboundTransport>`"; ADR-038 §Companion-BootContext loosely says BootContext "grows additively (Plan V adds `transport)". V2 reconciles the two by performing the swap (remove http_client, add transport) AND amending ADR-038 §Companion-BootContext + ADR-032 A4 + the BootContext doc-comment to state that Plan V replaces http_client with transport — rationale: exposing the concrete client in the shared context would leak the very abstraction Plan V introduces. The "grows additively" convention still governs FUTURE shared resources (Plan W plugin_registry, etc.). The amendment rides the V2 code MR so the ADRs never describe stale code.

Byte-identity oracle: a recording StubTransport, not the round-trip

The existing round-trip suites deserialize JSON at the mock — they prove FUNCTIONAL round-trip, not that the wire bytes + content-type header are unchanged after .json(&payload) becomes serde_json::to_vec(&payload) + a manual header. So StubTransport (V1) records the TransportRequest`s it receives, and V2’s byte-identity gate is a unit test asserting `req.body == serde_json::to_vec(&payload) + a ("content-type","application/json") header + the 30s timeout (and for probe: the right method + {endpoint}/health) — mechanizing equivalence to the old .json() path at the point of change, with no server. V5 (mock capture) re-proves it at the wire level.

V2 delivery: one atomic MR

The BootContext field swap is a shared breaking change; the whole wire path moves together. StubTransport shipping in V1 means the atomic swap needs no transitional dual-field.

Step DAG

V1 (transport crate: trait + value-objects + DirectHttpTransport + recording StubTransport)
   │
   ├──► V2 (ATOMIC swap: 10 partners + SHINES + BootContext + macro + orchestrator
   │        + explicit error-mapping + ADR-038/032 amendment)
   │         │
   │         ├──► V4 (mock fault injection) ──► V5 (request-inspection API)
   │         ├──► V6 (proptest in transport + partner; cargo-fuzz under the partner crate)
   │         └──► V7 (coverage-matrix audit + interface predicates + open-questions)
   │                                                            │
   │                                                            ▼
   │                                                      V8 (audit + archive)
   │
   └──► V3 (WebMethodsTransport stub + host-owned /broker mock route)  [needs the trait only;
            parallel to V2]

Gate summary: V1 (crate creation) has no blocker; its StubTransport is what lets V2 be a clean atomic swap. V2’s hard-sequencing gate — Plan T2 archived (the AdapterRegistry lands in T2.1) — is already satisfied. V3 needs only the V1 trait and can run in parallel with V2. V4/V5/V6/V7 all need the V2 adapter swap to observe transport behavior; V5 builds on V4’s layer. V8 archives after all.

Risk register

Risk Mitigation

The SHINES StandardAdapter (the 11th wire consumer) breaks the atomic V2 build, and the umbrella’s partner-only completion grep would miss it.

V2’s file inventory explicitly includes services/craig-exchange/src/adapters/standard.rs + its error enum; the invariants check ALL reqwest types (Client / Error / StatusCode) in standard.rs; V8 flags the umbrella line-395 grep for a SHINES widen at umbrella Step 20.

The BootContext swap contradicts ADR-038’s "grows additively" convention.

V2 amends ADR-038 §Companion-BootContext + ADR-032 A4 + the BootContext doc-comment to record the one-time swap (the convention still governs future resources); ADR-032 A4 already anticipates "swaps to `Arc<dyn OutboundTransport>`".

A blanket #[from] TransportError would mis-route Timeout/BadStatus into the catch-all, silently changing the error taxonomy.

Explicit per-arm matching in each adapter; #[from] only on the residual Transport variant; the Timeout / BadStatus / MalformedResponse taxonomy is preserved.

Byte-identity drift: .json()serde_json::to_vec + a manual content-type header changes the wire bytes or header set.

V1’s StubTransport records requests; V2’s unit test asserts body == to_vec(payload) + content-type: application/json + the timeout; V5 re-proves it at the wire level.

Timeout semantics change (client-level vs per-request override).

DirectHttpTransport applies TransportRequest.timeout as a per-request override exactly as today; the 30s/5s values stay adapter-owned.

Probe verb/path drift — partners use HEAD, SHINES uses GET, both on {endpoint}/health.

ProbeRequest.method preserves each verb; the adapter keeps format!("{endpoint}/health"); the mock already mounts both .head() and .get() on /health.

reqwest re-leaks through a pub TransportError variant.

TransportError stores String / structured data, never a reqwest type in a pub signature; an invariant grep enforces it.

The BootContext Debug derive cannot hold Arc<dyn OutboundTransport>.

Drop the verified-unused Debug derive; keep Clone (Arc is Clone).

The ~70 mechanical test-site edits in the atomic V2 MR.

The per-adapter change is uniform across the 10; gated by the wire-path grep == 0 + the recording-StubTransport byte-identity proof + the 10 round-trip suites staying green.

cargo-fuzz needs nightly and would break the stable MSRV-1.88 --workspace gate.

The fuzz/ crate is exclude`d from the workspace `members (homed under the partner crate that owns the parse); nightly-only, run as an optional non-blocking CI job, never in the default build.

A connection-drop / TCP-reset fault exceeds a Router layer’s reach.

V4 ships the layer-achievable faults (latency/status/malformed); a true TCP reset is a scoped stretch or deferred (it needs a custom accept loop below axum::serve).

Forcing the broker mock into the partner registry would pollute the stateless A5 design.

The broker mock is a host-owned /broker route (V3), not a partner MockRouteFactory.

Cross-cutting invariants

Each invariant is a runnable check; together they are the Plan-V-relevant subset of the umbrella’s program-complete criteria (the bundle and UI-composability rows belong to Plans U/W/X/Y).

  1. Partner wire-path gone (umbrella §Verification): git grep -nE "reqwest::Client|self\.client\.(post|get|put|delete)" crates/craig-partner-*/src/adapter.rs returns 0.

  2. SHINES wire-path + types gone (the consumer the partner-only grep misses): git grep -nE "reqwest::(Client|Error|StatusCode)" services/craig-exchange/src/adapters/standard.rs returns 0.

  3. reqwest is no longer a direct dep of the partner crates or craig-state-bundle: git grep -nE "^reqwest" crates/craig-partner-*/Cargo.toml crates/craig-state-bundle/Cargo.toml returns 0. (The exchange service legitimately retains reqwest for main.rs; craig-exchange-transport declares it — both expected.)

  4. Contracts crate stays reqwest-free: grep -c reqwest crates/craig-exchange-contracts/Cargo.toml returns 0.

  5. No partner/SHINES error enum names reqwest: git grep -nE "reqwest::Error|reqwest::StatusCode" crates/craig-partner-*/src/error.rs services/craig-exchange/src/adapters/standard.rs returns 0.

  6. TransportError does not leak reqwest publicly: git grep -nE "pub .*reqwest::|#\[from\] reqwest" crates/craig-exchange-transport/src/ returns 0.

  7. The WebMethodsTransport stub is test-reachable: git grep -n "WebMethodsTransport" crates/craig-exchange-transport/tests/ tools/craig-mock-server/ returns at least 1.

  8. The round-trip suites survive the swap: ls crates/craig-partner-*/tests/round_trip.rs | wc -l returns 10 (already present before Plan V), and cargo test --workspace is green (V2 did not regress them).

V8 verification (2026-06-15): all 8 invariants checked. INV4 (0), INV7 (7 ≥ 1), and INV8 (10) are exactly as stated. The residual matches on the "expect 0" greps are all benign and confirm — not contradict — a complete seam: INV1 (10), INV2 (1), and INV5 (11) match only //!//// doc-comments that narrate the former reqwest path ("…rather than holding a reqwest::Client…" / "Replaces the former reqwest::Error-wrapping Http variant") — the wire calls and error variants themselves are gone; INV3 (10) matches the intended [dev-dependencies] reqwest each partner keeps for the round_trip DirectHttpTransport wiring (not a [dependencies] edge); INV6 (2) matches the intended DirectHttpTransport::new(reqwest::Client) / WebMethodsTransport::new(reqwest::Client, …) constructor seam — the one place reqwest is a pub-signature input, while it is hidden from every return type + error variant. The greps are deliberately broad (they catch comments + the dev-dep + the constructor) so a future reader re-runs them with these expected-benign categories in mind.

Partner coverage matrix (V7 audit)

The per-partner test coverage across the three axes Plan V touches: the end-to-end round_trip suite (Plan L Step 7, real HTTP through the mock), the types_round_trip serde suite, transport-fault coverage (V4), and the V7 interface-doc predicate (*_outbound_wire_shape_matches_interface_doc, pinning the serialized wire shape to the documented field inventory). All 10 partner crates carry round_trip + types_round_trip + an interface predicate; transport-fault coverage is the CAPS pilot only (see the note).

Partner Outbound Inbound round-trip types-round-trip transport-fault interface predicate

caps

CapsReferral

CapsReferralAck

cprs

CprsInvOngStageData

CprsInvOngAck

✓ †

doe_slds

DoeSldsCustodyEntry

DoeSldsCustodyAck

empi

EmpiRegistration

EmpiRegistrationAck

✓ †

ies

IesMedicaidReferral

IesMedicaidReferralAck

ions

IonsAdminReviewOutcome

IonsAck

smile

SmileInvoice

SmileInvoiceAck

stars

StarsChildSupportReferral

StarsReferralAck

tcm

TcmClaim

TcmClaimAck

wic

WicReferral

WicReferralAck

Transport-fault coverage is intentionally the CAPS pilot only: the fault layer is host-owned and partner-agnostic (spawn_for_test_with
MockServerOptions, V4), so CAPS’s Fault::Latency → Timeout / Status(503) → BadStatus / MalformedBody → MalformedResponse mapping is representative of every adapter’s identical map_transport_error path. Per-partner fault tests would be low-value duplication and are not added.

SHINES StandardAdapter (the 11th wire consumer) is host-owned in services/craig-exchange with no partner crate, typed payload, or round_trip/types_round_trip suite — its contract is the SHINES mapping doc (a SHINES↔CRAIG field map, not a typed wire-payload spec), so it carries no interface-doc predicate; it is exercised by the standard.rs unit tests
the orchestrator injection tests.

Documented doc/type notes (not bugs — intentional, doc-commented in each types.rs): the interface predicate asserts the documented field inventory the typed shape DOES carry; a few partners deliberately diverge from the legacy SHINES field tables:

  • cprs — the doc’s separate Legal County + Case County collapse into one county_fips key (FIPS codes externalized to workspace reference data); the doc’s merge/split date + id-correlation columns are modeled as the stage_closed / person_merged boolean indicators.

  • empiEmpiRegistration carries EMPI-Create web-service fields (birth_state_code, primary_language, alias, …) beyond the legacy empi-registration.adoc §Table-of-Page-Fields UI list, and the EmpiDobVerification enum’s code set only partially overlaps the doc’s CDOBVER table. The predicate pins the page-field-documented keys + an overlapping verification code (BC).

Across the other partners the divergences are limited to CRAIG-side correlation ids (transaction_id), conditional/optional fields that are None in the canonical sample (omitted by skip_serializing_if, covered by the round-trip suites), and broker-bookkeeping columns the typed wire payload deliberately omits — all recorded in each predicate’s /// notes.

Open questions (deferrals)

  • RetryPolicy + IdempotencyPolicy for WebMethodsTransport — deferred to #557 (GA DHS Layer 2 deployment-runbook blocker). The stub ships as a test-purposes runtime stub only.

  • BrokerContract cross-state generalization beyond WebMethodsTransport (MuleSoft / BizTalk / custom) — deferred until a second-state-adoption milestone; the trait already makes them swappable.

  • Runtime transport selection — none in Plan V. Transport is wired at boot (DirectHttp); WebMethodsTransport is test-reachable only. A future CRAIGOUTBOUND_TRANSPORT=direct|webmethods would mirror the CRAIGACTIVE_STATE_BUNDLES pattern when #557 lands.

  • Connection-drop / TCP-reset fault — V4 ships the Router-layer-achievable faults; a true socket reset needs a lower-level accept-loop harness and is a stretch/deferral.

  • Probe-verb asymmetry — the 10 partner adapters probe HEAD /health; the SHINES StandardAdapter probes GET /health. ProbeRequest.method preserves each adapter’s existing verb byte-for-byte (the seam’s whole point is to NOT change wire behavior), so the asymmetry is deliberate, not a defect. Whether SHINES should converge on HEAD is a partner-contract question for a future SHINES-interface revision, not Plan V.

  • Plan S — Multi-Jurisdiction Foundation — the umbrella; this body is Step 14, execution is Step 15.

  • ADR-032 — §3.1 the OutboundTransport trait + co-located impls + the webMethods runtime stub (V1/V2/V3); §4 + A11 additive BundleContribution/BootContext growth; A4 the factory shape + the Plan-V "swaps to `Arc<dyn OutboundTransport>`" note (V2 amends it); A5 the stateless mock registry (V3/V4/V5).

  • ADR-038 — §3 the registry factory shape + §Companion-BootContext (V2 amends the additive wording to record the swap); §4 trait location / orphan rule (drives where the transport trait lives).

  • Multi-Jurisdiction Extensibility — the "how to add a jurisdiction" guide a new state’s broker transport plugs into.

  • Design engineering contracts — Plan V realizes the partner+broker substrate the contracts assume beneath the UI-composability axes.

Edit this page · latest