Plan V: Transport + Substrate (child plan of Plan S umbrella)
On this page
Status
| Step | Description | Status |
|---|---|---|
V1 |
NEW |
Done (2026-06-15) — NEW |
V2 |
ATOMIC swap: the 11 wire consumers + |
Done (2026-06-15) — atomic swap landed. |
V3 |
|
Done (2026-06-15) — |
V4 |
Fault injection in the mock substrate (layer-achievable faults). Add |
Done (2026-06-15) — NEW |
V5 |
Request-inspection API. The same |
Done (2026-06-15) — NEW |
V6 |
Property tests + fuzzing. proptest (the workspace already ships |
Done (2026-06-15) — proptest follows the existing |
V7 |
Coverage-matrix audit + interface-doc predicates + open-questions doc. All 10 |
Done (2026-06-15) — (a) NEW |
V8 |
Plan-completion audit + archive. A fresh Explore plan-completion-audit subagent verifies every V-step cell carries a concrete |
Done (2026-06-15) — this archive MR. Fresh Explore plan-completion-audit subagent confirmed every V1–V7 cell carries a concrete |
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_client → transport, 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::Client → Arc<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.
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 |
V2’s file inventory explicitly includes |
The |
V2 amends ADR-038 §Companion-BootContext + ADR-032 A4 + the |
A blanket |
Explicit per-arm matching in each adapter; |
Byte-identity drift: |
V1’s |
Timeout semantics change (client-level vs per-request override). |
|
Probe verb/path drift — partners use HEAD, SHINES uses GET, both on |
|
|
|
The |
Drop the verified-unused |
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- |
cargo-fuzz needs nightly and would break the stable MSRV-1.88 |
The |
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 |
Forcing the broker mock into the partner registry would pollute the stateless A5 design. |
The broker mock is a host-owned |
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).
-
Partner wire-path gone (umbrella §Verification):
git grep -nE "reqwest::Client|self\.client\.(post|get|put|delete)" crates/craig-partner-*/src/adapter.rsreturns 0. -
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.rsreturns 0. -
reqwestis no longer a direct dep of the partner crates orcraig-state-bundle:git grep -nE "^reqwest" crates/craig-partner-*/Cargo.toml crates/craig-state-bundle/Cargo.tomlreturns 0. (The exchange service legitimately retainsreqwestformain.rs;craig-exchange-transportdeclares it — both expected.) -
Contracts crate stays
reqwest-free:grep -c reqwest crates/craig-exchange-contracts/Cargo.tomlreturns 0. -
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.rsreturns 0. -
TransportErrordoes not leakreqwestpublicly:git grep -nE "pub .*reqwest::|#\[from\] reqwest" crates/craig-exchange-transport/src/returns 0. -
The
WebMethodsTransportstub is test-reachable:git grep -n "WebMethodsTransport" crates/craig-exchange-transport/tests/ tools/craig-mock-server/returns at least 1. -
The round-trip suites survive the swap:
ls crates/craig-partner-*/tests/round_trip.rs | wc -lreturns 10 (already present before Plan V), andcargo test --workspaceis 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 |
|
|
✓ |
✓ |
✓ |
✓ |
cprs |
|
|
✓ |
✓ |
— |
✓ † |
doe_slds |
|
|
✓ |
✓ |
— |
✓ |
empi |
|
|
✓ |
✓ |
— |
✓ † |
ies |
|
|
✓ |
✓ |
— |
✓ |
ions |
|
|
✓ |
✓ |
— |
✓ |
smile |
|
|
✓ |
✓ |
— |
✓ |
stars |
|
|
✓ |
✓ |
— |
✓ |
tcm |
|
|
✓ |
✓ |
— |
✓ |
wic |
|
|
✓ |
✓ |
— |
✓ |
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 Countycollapse into onecounty_fipskey (FIPS codes externalized to workspace reference data); the doc’s merge/split date + id-correlation columns are modeled as thestage_closed/person_mergedboolean indicators. -
empi —
EmpiRegistrationcarries EMPI-Create web-service fields (birth_state_code,primary_language,alias, …) beyond the legacyempi-registration.adoc§Table-of-Page-Fields UI list, and theEmpiDobVerificationenum’s code set only partially overlaps the doc’sCDOBVERtable. 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+IdempotencyPolicyforWebMethodsTransport— deferred to #557 (GA DHS Layer 2 deployment-runbook blocker). The stub ships as a test-purposes runtime stub only. -
BrokerContractcross-state generalization beyondWebMethodsTransport(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);
WebMethodsTransportis test-reachable only. A futureCRAIGOUTBOUND_TRANSPORT=direct|webmethodswould mirror theCRAIGACTIVE_STATE_BUNDLESpattern 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 SHINESStandardAdapterprobesGET /health.ProbeRequest.methodpreserves 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 onHEADis a partner-contract question for a future SHINES-interface revision, not Plan V.
Related decisions
-
Plan S — Multi-Jurisdiction Foundation — the umbrella; this body is Step 14, execution is Step 15.
-
ADR-032 — §3.1 the
OutboundTransporttrait + co-located impls + the webMethods runtime stub (V1/V2/V3); §4 + A11 additiveBundleContribution/BootContextgrowth; 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.