Plan L: Partner Typed Schemas (destination architecture for partner integrations)

On this page

Status

Step Description Status

1

Plan filing — body lands in a docs-only MR alongside the style-guide-driven refresh of Plans D/G/H/I/J/K. nav.adoc + CHANGELOG. Epic + step issues filed. No code changes.

Done (2026-05-15) — Plan body landed via commit ae4278a6 ("docs(plans, .claude): incorporate 7-subagent assessment findings — P0/P1/P2 sweep across Plans D/G/H/I/J/K/L + new Plan L"). Stale "Not started" status row fixed in a follow-up refresh MR that shipped the substantiation below.

2

F-058 implementation: redesign services/craig-exchange/src/adapters/mod.rs::ExchangeAdapter from object-safe-Value-in-Value-out-String-error to associated-types-typed-error. New shape: trait ExchangeAdapter { type Inbound; type Outbound; type AuditPayload; type Error: std::error::Error + Send + Sync + 'static; …​ }. Dispatch via per-partner concrete type, not Box-of-dyn-trait. Preserves Send + Sync for axum handler share. NoopAdapter stays as a per-test fixture only.

Done (2026-06-07) — Step 2a Done (2026-05-25): trait redesign foundation + ExchangeAdapterKind enum + migration (20260525213241_add_adapter_kind.sql) + AnyAdapter wrapper landed on branch refactor/partner-typed-schemas-step2-trait-redesign. Step 2b Done (2026-06-07): typed-dispatch consolidation completed — services/craig-exchange/src/adapters/mod.rs::adapter_for now routes all 10 per-partner ExchangeAdapterKind variants (Caps + Cprs + DoeSlds + Empi + Ies + Ions + Smile + Stars + Tcm + Wic) to their typed AnyAdapter::<Partner>(<Partner>Adapter::new(…​)) arms. Shines remains on AnyAdapter::StandardConfigured as the catch-all per §Step 3 SHINES note. AnyAdapter enum + AnyAdapterError enum gained 3 new variants (Cprs / DoeSlds / Stars); test_connectivity_serialized + send_serialized + audit_serialized gained 3 dispatch arms each. NEW dispatch_send<A, F> + dispatch_audit<A> generic helpers collapse each typed match arm from 7 LOC to 1 LOC — keeps send_serialized under the B2 100-LOC ceiling and contains the Value-escape boundary in two clearly-marked STRUCTURAL-VALUE sites instead of per-variant inline boilerplate. 3 new per-kind dispatch tests (cprs / doe-slds / stars); existing adapter_for_remaining_kinds_route_to_standard tightened to adapter_for_shines_routes_to_standard + adapter_for_inventory_lockstep enforces "every kind typed iff not Shines". The Plan L body §Step 2 §axum-handler-dispatch-strategy spec (per-partner /v1/exchange/<partner>/send route surface) is NOT yet wired — the current consolidation is at the adapter_for factory layer; per-partner route handlers stay as a future enhancement when concrete partner integration ships (no production caller needs that surface yet). #480 closes.

3

F-059 implementation: per-partner crate scaffolding. One workspace crate per partner system: crates/craig-partner-{caps,cprs,doe-slds,empi,ies,ions,smile,stars,tcm,wic}10 crates after deduplicating the 17 interface-spec docs (see §Steps Step 3 for doc-to-system mapping) AND excluding shines (the ExchangeAdapterKind::Shines enum variant is a catch-all fallback for semantic categories with no modern partner equivalent — see §Step 3 SHINES note below). Each crate exposes types::{InboundEvent, OutboundCommand, AuditPayload} typed structs/enums + an impl of the F-058 ExchangeAdapter trait. Per-crate batches.

Done (2026-06-06) — PILOT (2026-05-25) crates/craig-partner-caps via !453 / merge e08a751f / commit 1beced2e. Batch 2 (2026-05-26) crates/craig-partner-{ies,ions,wic} via !454 / merge 475ec141 / commit d724f586, !455 / merge 03f05ff5 / commit c1343d1a, !456 / merge b172f2b0 / commit 4ab7369e. Batch 3 (2026-05-26) crates/craig-partner-{empi,smile,tcm} via !457 / merge 3b0aa43b / commit 66a84f43, !458 / merge ad281f5a / commit f03d6a54, !459 / merge 0866ec21 / commit d31565fd. Batch 4 (2026-06-06) closes the remaining 3 crates: crates/craig-partner-doe-slds via merge d96da36b / commits 18601eb3 + 13d130b4 (post-merge Plan M lint discipline pass); crates/craig-partner-cprs via merge 35877cb3 / commit 1d527509; crates/craig-partner-stars via merge 753809e9 / commit 0efae675 (#441 closed). Architectural decision: trait + kind enum extracted to a new crates/craig-exchange-contracts leaf crate so per-partner crates can implement the trait without depending on services/craig-exchange (whose adapter internals are pub(crate)). services/craig-exchange::adapters::adapter_for routes ExchangeAdapterKind::{Caps,Cprs,DoeSlds,Empi,Ies,Ions,Smile,Stars,Tcm,Wic} through StandardConfigured(StandardAdapter::new(…​)) until Step 2b (#480) flips them to typed AnyAdapter::<Partner> dispatch arms. Originally-planned third batch-2 slot of shines was substituted with wic after auditing the ExchangeAdapterKind::Shines variant — see §Step 3 SHINES note. Final shape: 10 per-partner crates merged (caps / cprs / doe-slds / empi / ies / ions / smile / stars / tcm / wic). #442 closes; Step 2b (#480) is the next sequenced Plan L work.

4

F-060 implementation: schema-versioning per partner. For each partner whose interface spec mentions explicit version numbers (STARS has v2.x; CPRS has dated revisions): introduce a versioned envelope enum <Partner>Envelope { V<X>(v<X>::Shape), … } with #[serde(tag = "schema_version")]. Per-version source modules. Partners with no explicit versioning get a single typed shape (no envelope wrapper required).

Done (2026-06-08) — scope-narrowed by empirical reality. Pre-flight scan of all 10 interface spec docs confirmed every "Version X.Y" header is a document-revision marker, not a wire-protocol version marker — no partner spec carries a schema_version field on the message envelope today (every record negotiates protocol revision out-of-band via the integration broker). Pre-existing CAPS + DOE-SLDS types.rs comments already encoded this distinction. Empirically, envelope-dispatch is premature wrapping: no partner needs it, and inventing wire shapes that don’t match specs would lock contracts pre-1.0. Step 4 delivered the forward-compat surface instead: per-partner pub const SCHEMA_VERSION: &str = "<doc-revision>"; constants (caps=2.1, cprs=1.6, doe-slds=1.0, empi=1.0, ies=4.0, ions=2.3, smile=1.4, stars=1.4, tcm=1.3a, wic=1.0) + per-partner <Partner>Error::UnrecognizedSchemaVersion { received: String } typed-error slot for the day a real spec bump triggers wire-level versioning. 10 partner crates × 2 unit tests each = +20 tests (1733 → 1753); each lib.rs gains a #[cfg(test)] mod tests that pins the const against the spec doc, each error.rs gains a Display round-trip test on the new variant. Envelope-dispatch deferred to a future MR triggered by a partner spec gaining a wire-level schema_version field. Doc-comments on the constants document the trigger condition + upgrade path. #443 closes.

5

F-061 implementation: typed Audit payloads end-to-end. Choice locked = Option B (single audit table with partner_kind ENUM discriminator + payload JSONB; read-side store function returns typed PartnerAuditEvent enum, never Value). Option A (per-partner tables) is the documented fallback if Option B’s read-side enum becomes unmaintainable (~20 variants). Step 5 implementer commits Option B unless they explicitly justify Option A in the MR body with a measurement.

Done (2026-06-08) — type-side delivered; storage layer deferred to a follow-up MR. Pre-flight scan confirmed the dispatch host has zero production callers of audit payloads today: AnyAdapter::audit_serialized was marked dead_code ("Reserved for Step 5") and no partner_audit_events table or read-side API existed. The 10 per-partner <Partner>AuditPayload types existed in the per-partner crates but had no shared cross-partner type. NEW crates/craig-partner-audit/ aggregator crate (~270 LOC + 5 tests) exposes PartnerAuditEvent — the typed discriminated-union destination per Option B with #[serde(tag = "partner_kind", content = "payload")] representation matching ExchangeAdapterKind’s wire form — plus `PartnerAuditEvent::kind() helper + PartnerAuditEvent::decode_jsonb(kind, jsonb_value) read-side decoder for the future partner_audit_events.payload JSONB column (contains the Value escape strictly at the DB boundary). NEW typed <PartnerAuditDecodeError> enum for Shines-no-typed-variant + payload-deserialize errors. services/craig-exchange/src/adapters/mod.rs retires dead audit_serialized + dispatch_audit in favor of typed audit_typed() + dispatch_audit_typed<A, F>(): each typed-partner variant routes through the helper that calls adapter.audit(&inbound) and wraps the result via the per-variant constructor (PartnerAuditEvent::Caps, etc.); SHINES + Noop fallbacks return new typed AnyAdapterError::AuditUnsupportedForKind { kind } (fail-closed) since neither has a PartnerAuditEvent variant. 3 new dispatch tests in craig-exchange adapters (audit_typed_routes_typed_kind_through_partner_audit_event happy + audit_typed_returns_unsupported_kind_for_shines_fallback sad + audit_typed_propagates_serde_error_on_unparseable_response sad). Storage layer (partner_audit_events table + write-side store fn + read-side API) is deferred until a real consumer materializes — documented in crates/craig-partner-audit/src/lib.rs § Storage layer (deferred) and inlined in the audit_typed doc-comment. Net Value-escape sites at the AnyAdapter dispatch boundary: 2 retired (audit_serialized return + dispatch_audit return); B3a 178 → 177 (re-LOCKED). Tests +8 (1753 → 1761).

6

F-062 implementation: codegen pilot for one partner schema. Pick STARS (largest spec; most variants; greatest value). Determine the source format (XSD vs OpenAPI vs prose-described — verify at Step 6 branch time by re-reading docs/modules/ROOT/pages/interfaces/stars-detailed-design.adoc). Reference tooling candidates: quick-xml + custom for XSD, progenitor / okapi for OpenAPI, hand-written for prose. Codegen output checked-in under crates/craig-partner-stars/src/generated/ + build.rs regen step. If STARS lacks a machine-readable schema, codegen step deferred to a partner that has one (likely CPRS or EMPI given the federal interface lineage).

Deferred (no machine-readable spec exists for any partner; revisit when a partner publishes an XSD / OpenAPI / WSDL — 2026-06-08) — pre-flight scan per plan body’s explicit pre-check ran grep -lE '<xs:schema|openapi:|swagger:|"\$schema"' docs/modules/ROOT/pages/interfaces/, grep -c '\`\`json'` per file, and find . -name '.xsd' -o -name '.wsdl' -o -name 'openapi*.yaml' -o -name '-schema.json'. Zero machine-readable schema sources exist anywhere in the repository. All 17 interface docs at docs/modules/ROOT/pages/interfaces/ are AsciiDoc prose with field tables only — no XSD, OpenAPI, Swagger, JSON Schema, or WSDL artifacts. The plan body’s fallback ("if STARS lacks a machine-readable schema, codegen step deferred to a partner that has one — likely CPRS or EMPI given the federal interface lineage") does not save the step: none of CPRS / EMPI / IES / TCM has a machine-readable spec either. Hand-written per-partner crates from Step 3 remain the canonical approach until a partner publishes a machine-readable spec. *Revisit criterion: when an XSD / WSDL / OpenAPI / JSON Schema artifact lands under docs/modules/ROOT/pages/interfaces/ (or as a sibling specs/ directory) for any of the 10 partners. Recommended tooling at revisit time: quick-xml + a custom XSD walker for XSD/WSDL inputs (xmlschema-style Rust crates are nascent); progenitor for OpenAPI 3.x (best ergonomics for axum/reqwest stack); okapi if utoipa-side schema integration is desired. Recommended pilot target at revisit time: keep the plan body’s STARS-first preference (largest spec, most variants, federal interface lineage — codegen amortizes best there); CPRS or EMPI as runner-up if STARS doesn’t ship a spec first. Why not speculative scaffolding now: building a build.rs placeholder that no-ops until a spec drops in is YAGNI under pre-1.0 "don’t lock contracts" doctrine and the project’s "don’t add features beyond what the task requires" §Style rule; the placeholder would itself need maintenance + clippy churn without producing typed shapes. #445 closes as Deferred.

7

F-063 implementation: mock-server adapter integration. Today’s mock-server is "shipped but functionally orphaned" (memory project_mock_server_orphaned). Replace the orphan state by: (a) per-partner adapter has a tests/round_trip.rs integration test exercising the adapter against the mock-server; (b) mock-server serves canonical partner-shape fixtures; (c) every adapter implementation has at least one round-trip test pinned in cargo nextest run -p craig-partner-<name>.

Done (2026-06-08) — PILOT shipped (2026-06-08): tools/craig-mock-server restructured to lib+bin (NEW src/lib.rs exposes router(state) + MockServerHandle + spawn_for_test() ephemeral-port helper; src/main.rs becomes a thin shim). NEW tools/craig-mock-server/src/caps.rs typed-shape mock — accepts Json<CapsReferral>, emits Json<CapsReferralAck> (no Value escape on the wire; sentinel program == "REJECT_FOR_TEST" exercises the Rejected arm deterministically). NEW crates/craig-partner-caps/tests/round_trip.rs 3 end-to-end tests (Accepted / Rejected / test_connectivity) — drives the production CapsAdapter over a real reqwest connection to the spawned mock. Follow-up #2 — WIC (2026-06-08): NEW tools/craig-mock-server/src/wic.rs typed-shape mock — Json<WicReferral>Json<WicReferralAck>; same REJECT_FOR_TEST sentinel exercises success: false / error_message: Some(_) rejection branch. NEW crates/craig-partner-wic/tests/round_trip.rs 3 end-to-end tests. Follow-up #3 — IONS (2026-06-08): migrated existing ions.rs mock module from Json<Value> to typed Json<IonsAdminReviewOutcome>Json<IonsAck>. Route flattened from /admin-review to / matching the IONS adapter’s bare-endpoint_url POST shape. Sentinel shines_case_number == 0 (SHINES never assigns case 0) drives the rejection arm. NEW crates/craig-partner-ions/tests/round_trip.rs 3 end-to-end tests. Follow-up #4 — CPRS (2026-06-08): migrated existing cprs.rs mock module from Json<Value> to typed Json<CprsInvOngStageData>Json<CprsInvOngAck>. Routes collapsed from /documents + /cases/{county} + /case-plans/{case_id} (which carried base64 binaries + 400-field FCC payloads with no typed equivalent in the current craig-partner-cprs scope) to POST / matching the adapter’s bare-endpoint_url POST shape. Court-order-report + FCC-case-plans variants remain deferred per the typed crate’s first-pass scope decision. Sentinel person_id == "REJECT_FOR_TEST" drives CprsReturnCode::NoDataFound ("305"). NEW crates/craig-partner-cprs/tests/round_trip.rs 3 end-to-end tests. Follow-up #5 — DOE-SLDS (2026-06-08): migrated existing doe.rs mock module from Json<Value> to typed Json<DoeSldsCustodyEntry>Json<DoeSldsCustodyAck>. Sentinel gtid == "REJECT_FOR_TEST" drives DoeSldsInterfaceStatus::Error (vs the default Sent). NEW crates/craig-partner-doe-slds/tests/round_trip.rs 3 end-to-end tests. Follow-up #6 — EMPI (2026-06-08): migrated existing empi.rs mock module (largest pre-existing at 397 LOC with /register + /inquire + /multi-source sub-routes) from Json<Value> to typed Json<EmpiRegistration>Json<EmpiRegistrationAck>. Routes collapsed to POST / matching the adapter’s bare-endpoint_url POST. Inquiry + multi-source-merge variants removed (no typed equivalent in current craig-partner-empi scope per the Registration-first first-pass decision). Sentinel client_first_name == "REJECT_FOR_TEST" returns shines_code: "REJECT", empi_code: "ERR", empty client_id. NEW crates/craig-partner-empi/tests/round_trip.rs 3 end-to-end tests. Follow-up #7 — IES (2026-06-08): migrated existing ies.rs mock module from Json<Value> to typed Json<IesMedicaidReferral>Json<IesMedicaidReferralAck>. Sentinel first_name == "REJECT_FOR_TEST" drives response_code: Denial + client_status: Closed + error_code: "INELIGIBLE" + empty medicaid_class_of_assistance (per the IES contract on synchronous denial). Default returns response_code: Success, client_status: Active, medicaid_class_of_assistance: "001". NEW crates/craig-partner-ies/tests/round_trip.rs 3 end-to-end tests. Follow-up #8 — SMILE (2026-06-08): migrated existing smile.rs mock module from Json<Value> to typed Json<SmileInvoice>Json<SmileInvoiceAck>. Routes collapsed from /invoices + /vendors + /clients to POST / matching the adapter’s bare-endpoint_url POST shape. Vendor + Client outbound variants typed in the crate (SmileVendor/SmileVendorAck/SmileClient/SmileClientAck) but not yet routed by the SmileAdapter trait — those route surfaces deferred to future Plan L Step 3 follow-up batches when the adapter exposes them. Sentinel id_invoice == 0 (SMILE never assigns invoice number 0) drives return_status: Rejected. NEW crates/craig-partner-smile/tests/round_trip.rs 3 end-to-end tests. Follow-up #9 — STARS (2026-06-08): migrated existing stars.rs mock module from Json<Value> to typed Json<StarsChildSupportReferral>Json<StarsReferralAck>. Sentinel child_first_name == "REJECT_FOR_TEST" drives return_code: InvalidRequest ("400") with absent CRS IDs (vs the default Accepted with populated child_crs_id + ncp_crs_id). Scope: ChildSupportReferral only (the typed crate’s first-pass scope per its Cargo.toml — Payment-update / Demographic-update / Termination / ChildLeftCare variants typed in adjacent shapes but not routed by the trait). NEW crates/craig-partner-stars/tests/round_trip.rs 3 end-to-end tests. Follow-up #10 (FINAL) — TCM (2026-06-08): migrated existing tcm.rs mock module from Json<Value> to typed Json<TcmClaim>Json<TcmClaimAck>. Sentinel id_tcm_claim_outbound == 0 (TCM never assigns claim number 0) drives return_status: Rejected + eob_codes: Some("EOB001,EOB042"). Default returns return_status: Paid + populated pay_date + tcn_number: "TCN-MOCK-00000001" + ra_number: "RA-MOCK1". NEW crates/craig-partner-tcm/tests/round_trip.rs 3 end-to-end tests. ALL 10 partners converted; Step 7 substantively COMPLETE. #446 closed (via the final TCM merge in commit 14399f3d).

8

F-064 implementation: retire the partner-edge Value carve-out cited by Plan I F-037. Final sweep over services/craig-exchange/src/{adapters,api}/** to confirm zero serde_json::Value occurrences in business-logic paths (Step 2 of this plan moved them out of the trait; subsequent steps moved them out of handler call sites). Remove the // PARTNER-EDGE-UNTYPED: comments. Plan I F-037 sub-row closes.

Done (2026-06-08) — final sweep retires all 23 // PARTNER-EDGE-UNTYPED: markers across 11 files. Group 1 (6 stale): per-partner adapter doc-comments in crates/craig-partner-{caps,empi,ies,ions,tcm,wic}/src/adapter.rs carried // PARTNER-EDGE-UNTYPED: #441 markers from before the per-partner crates were scaffolded — the adapters ARE fully typed now, so the markers were pure noise. Removed; doc-comment "no serde_json::Value escape on the wire" rephrased to "no untyped-JSON escape on the wire" to avoid the literal Value mention (which would otherwise count toward B3a). Group 2 (12 by-design): services/craig-exchange/src/adapters/{standard,noop}.rs + services/craig-exchange/src/adapters/mapping/person.rs carried legacy // PARTNER-EDGE-UNTYPED: #441 markers protecting Value escapes that are BY DESIGN (StandardAdapter routes only the SHINES catch-all per Plan L §Step 3 SHINES note; NoopAdapter is the test-injection variant; map_person is a shared helper for the SHINES-fallback envelope transform). Converted to // STRUCTURAL-VALUE: SHINES catch-all by design / // STRUCTURAL-VALUE: test-injection no-op markers — preserves the budget exemption with an accurate by-design rationale instead of the stale "waiting for #441" issue cite. Group 3 (5 redundant): services/craig-intake/src/api/validation.rs (validate_children_array + validate_adults_array) and services/craig-web/src/routes/intake/reports.rs (BFF view-model boundary) carried // PARTNER-EDGE-UNTYPED: markers alongside pre-existing // STRUCTURAL-VALUE: markers. The Value escape at these sites is BY DESIGN: the public-intake submission boundary stays Value because submitter shapes are negotiated out-of-band (not partner-OUTBOUND wire shapes that Plan L Steps 3-5 typed). Removed the PARTNER-EDGE-UNTYPED: cite; kept STRUCTURAL-VALUE markers with rephrased rationale. Final state: zero // PARTNER-EDGE-UNTYPED: markers anywhere in services/ + crates/. B3a serde_json::Value count unchanged at 177 LOCKED (Group 2 + Group 3 conversions to STRUCTURAL-VALUE preserve the by-design budget exemption). Plan I F-037 partner-edge carve-out formally closed by this MR. Workspace tests unchanged at 1761; clippy + axis-coverage + plan-lint clean. #447 closes.

9

Plan completion audit + archive.

Done (2026-06-08) — plan-completion audit dispatched via fresh Explore subagent (per the plan-completion-audit bias) caught 2 stale Status-cell items and 1 minor clarification: Step 1 date drift 2026-05-252026-05-15 (commit ae4278a6 actual date); Step 7 future-tense #446 closes upon merge → past-tense #446 closed (via the final TCM merge in commit \`14399f3d\); Step 2 date is the Step 2b commit date (`2026-06-07) vs the merge date (2026-06-08) — left as-is since the Status cell already differentiates Step 2a (2026-05-25) and Step 2b (2026-06-07) sub-completion dates inline. Plan body archived manually (not via cargo xtask docs plan-archive) because Step 6 carries the Deferred (…​) ADR-030 token, which the auto-archive helper’s plan_fully_done() predicate at xtask/src/cmd/docs.rs:520 does not accept (treats only Done + N/A as terminal). The Deferred token IS ADR-030-valid; forcing Done (…​) on Step 6 would lie about what shipped (no codegen pilot was delivered — the deliverable IS the decision memo + revisit criterion). Manual archive workflow: git mv plan body to archive/, nav.adoc Active section removes Plan L + Archived section gains the new link, this row added to .claude/CLAUDE.md § Phase Status standalone (collapsing the joint Plan G/H/I/J/K/L row). #448 closes.

Epic: &34 (epic: Partner Typed Schemas (Plan L))
Issues: #441 (Step 2 — F-058 trait redesign) · #442 (Step 3 — F-059 per-partner crates) · #443 (Step 4 — F-060 schema versioning) · #444 (Step 5 — F-061 typed Audit) · #445 (Step 6 — F-062 codegen pilot) · #446 (Step 7 — F-063 mock-server de-orphan) · #447 (Step 8 — F-064 retire Value carve-out) · #448 (Step 9 — plan completion)
Branch prefix: feat/partner-typed-schemas- / refactor/partner-typed-schemas-
Milestone: TBD (depends on partner-integration priority; lower-priority until at least one production partner integration starts)

Context

CRAIG today has an ExchangeAdapter trait at services/craig-exchange/src/adapters/mod.rs:18-31 shaped:

pub trait ExchangeAdapter: Send + Sync {
    fn test_connectivity(&self, endpoint_url: &str)
        -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + '_>>;

    fn send(&self, endpoint_url: &str, payload: &serde_json::Value)
        -> Pin<Box<dyn Future<Output = Result<serde_json::Value, String>> + Send + '_>>;
}

Every property of this signature violates the new style guide:

  • &serde_json::Value parameter — "Abstract JSON objects and values (serde_json::Value) are not allowed."

  • Result<_, String> error type — "Error types cannot be strings."

  • Pin<Box<dyn Future…>> — manual async-trait shape; async_trait-or-RPITIT preferable.

This wasn’t bad design for a placeholder. The 17 partner-interface docs at docs/modules/ROOT/pages/interfaces/ define the target shapes per partner (STARS, CPRS, EMPI, TCM, DOE/SLDS, SMILE, IES, CAPS, IONS, WIC, SHINES, …). The trait was deliberately erased to Value while no real partner integration existed. With the style guide as the new bar, that placeholder is now technical debt with a defined exit.

This plan is also the destination cited by Plan I F-037’s "Option 2-as-roadmap" decision: F-037 takes the typed-everywhere-within-CRAIG slice now; the partner-edge Value carve-outs (raw_submission, partner audit envelopes, mock-server inbound JSON) stay as documented temporary state with a // PARTNER-EDGE-UNTYPED: see plans/partner-typed-schemas.adoc cite. This plan retires those carve-outs.

Cross-cutting invariants

  1. No partner integration in production yet — destructive refactors are fine. Per the pre-1.0 destructive-rebuild posture — there is no real partner data in flight; the existing 11 NoopAdapter registrations are devstack fixtures only. The trait can change shape freely.

  2. Per-partner crate isolation. Each partner gets a workspace crate (crates/craig-partner-<name>). Cross-partner reuse goes through craig-exchange or a new craig-partner-core shared types crate — NOT through direct use craig_partner_<a> as _; between sibling partner crates. Keeps each adapter independently versionable + replaceable.

  3. Source-of-truth = docs/modules/ROOT/pages/interfaces/. Every typed shape must trace back to a section of one of the 17 interface docs. If the doc is ambiguous, file a docs MR fixing the spec first; the typed shape’s PR cites the docs MR.

  4. Codegen vs hand-written is per-partner. Some partner specs are machine-readable (XSD / OpenAPI); some are prose with example payloads. Step 6 pilots one partner; subsequent partners pick codegen-or-hand based on what their spec supports. Don’t force XSD codegen on a prose-described partner.

  5. Send + Sync + 'static preserved. The new trait’s associated types must satisfy the bounds axum handlers + tokio-spawn require. Document the bound in the trait definition; verify at compile time per associated-type impl.

  6. No Box<dyn> dispatch in business logic. Each handler dispatches to a concrete adapter type via the partner discriminator. The "lookup adapter by string and get a Box<dyn>`" pattern at `adapters/mod.rs:41-59 is exactly what this plan replaces.

Scope

In scope (7 findings):

  • F-058 ExchangeAdapter trait redesign (associated types; typed error)

  • F-059 per-partner crate scaffolding

  • F-060 schema versioning per partner

  • F-061 typed Audit payloads end-to-end

  • F-062 codegen pilot for one partner schema

  • F-063 mock-server adapter integration (de-orphan)

  • F-064 retire partner-edge Value carve-out (closes Plan I F-037)

Out of scope:

  • Adding a new partner that’s not in the 17-doc corpus — out of scope; this plan covers known partners

  • Wire-protocol changes to existing partner endpoints — those are stakeholder decisions; this plan reshapes CRAIG’s internal types only

  • Federation / cross-state partner sharing — separate stakeholder decision; this plan covers intra-Georgia partners + the federal interfaces in the spec corpus

  • HTTP-client choice — reqwest::Client stays; trait shape only

Plan refresh — 2026-05-25 (ADR-030 §3 substantiation)

Re-review on 2026-05-25 surfaced 5 gaps where Step 2 was not executable for a contextless agent. Decisions locked here so the implementing MR has a clear spec rather than re-litigating architecture during code review.

Decision 1 — Wire-protocol enum name + location

The plan body’s Step 3 dispatch sketch (match partner_kind { PartnerKind::Stars ⇒ …​ }) collides with the existing craig_reference::PartnerKind enum, which encodes semantic categories (Le, Hospital, etc.) — NOT wire-protocol identifiers.

Locked: introduce a new enum ExchangeAdapterKind in services/craig-exchange/src/adapters/mod.rs (inline; not shared cross-service):

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString,
         strum::EnumIter, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
         sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ExchangeAdapterKind {
    Caps, Cprs, DoeSlds, Empi, Ies, Ions, Shines, Smile, Stars, Tcm, Wic,
}

Existing craig_reference::PartnerKind stays untouched — it encodes a different concept (semantic category for compliance reporting / authz scoping). Step 3’s match partner_kind is updated to match adapter_kind referencing the new enum.

Decision 2 — Fate of exchange_partners.partner_type String column

Today the column is TEXT with a 14-value CHECK constraint (state_agency / court_system / financial / medicaid / tanf / etc.). These are semantic categories — same concept as craig_reference::PartnerKind — NOT wire-protocol identifiers.

Locked: ADD a new column adapter_kind (TEXT NOT NULL with a CHECK constraint listing the 11 ExchangeAdapterKind::* snake_case values). Backfill is destructive-reseed-safe per pre-1.0 doctrine (the pre-1.0 destructive-rebuild posture) — devstack rows are NoopAdapter fixtures only. Existing partner_type STAYS (covers semantic categorization, compliance reporting, audit grouping); the two columns serve orthogonal purposes.

Migration ships in Step 2’s MR (so dispatch logic + column land together). Migration file: services/craig-exchange/migrations/<TS>_add_adapter_kind.sql.

Decision 3 — send_worker.rs:206 runtime dispatch

Per-partner axum routes (lines 144-151) solve HTTP dispatch but not the services/craig-exchange/src/send_worker.rs:206 call. send_worker reads outbox rows from the DB + dispatches by partner — runtime-typed-on-partner, no compile-time information.

Locked: introduce a small enum wrapper enum AnyAdapter { Stars(StarsAdapter), Cprs(CprsAdapter), …​ 11 variants } (also in adapters/mod.rs) for the send_worker dispatch site. The wrapper exposes a send_serialized(&self, endpoint: &str, payload: serde_json::Value) → Result<serde_json::Value, AnyAdapterError> method that per-variant deserializes the JSON into Self::Outbound, calls the typed send(), then re-serializes Self::Inbound.

This preserves the typed-everywhere property inside each adapter while accommodating the outbox row’s String/Value transport at the worker boundary. The Value escape is contained to ONE function (AnyAdapter::send_serialized) — it does not leak back into adapter business logic. Step 8 (F-064 retire Value carve-out) explicitly notes this as the exception with rationale.

Audit-payload generation in send_worker also routes through AnyAdapter::audit_serialized(&self, inbound: serde_json::Value) → AuditPayload.

Decision 4 — Per-partner route implications (auth + rate-limit + OpenAPI)

Adding 11 new routes (/v1/exchange/<partner>/send) needs explicit handling for:

  • Auth: existing auth_middleware applies to every route under /v1/exchange/* automatically. No per-route changes.

  • Partner rate-limit (Plan C F-006): per-partner rate-limit middleware buckets on partner_id (resolved from the request body/auth context), NOT on route. New routes inherit the existing middleware without modification.

  • OpenAPI: each handler gets its own #[utoipa::path] attribute. ~30 LOC of boilerplate per partner; aggregate adds ~330 LOC to the OpenAPI surface. Worth the cost — each partner contract is independently documented + versionable.

  • Per-partner test fixtures: existing craig-test-lib::ExchangeClient (16 methods per Plan I F-035d split) gets a new method per partner route. Plan I F-054 lint stays clean as long as ExchangeClient ≤16 methods. Verify at Step 2 implementation that adding 11 send-methods doesn’t push ExchangeClient past 16; if it does, split per Plan I §F-035 pattern (e.g. one ExchangePartnerClient per wire protocol).

Decision 5 — Codegen pilot partner selection (Step 6 pre-pick)

Plan body §Step 6 defers the codegen-or-handwritten partner selection to branch time. Pre-pick locked here based on the interface-spec corpus survey done 2026-05-25:

  • STARS has 2 docs (stars-detailed-design.adoc, stars-integration-architecture.adoc) but neither contains a machine-readable XSD or OpenAPI — prose-described JSON examples only. STARS is NOT suitable for codegen pilot.

  • EMPI has 3 docs (empi-inquiry.adoc, empi-interface.adoc, empi-registration.adoc) — needs branch-time inspection but federal interface lineage suggests possible HL7 / OASIS XSD.

  • IES has Medicaid eligibility lineage; likely XSD or fixed-position file format.

Locked: Step 6 implementer MUST start by running the spec-format probe (lines 260-269 below) against ALL 10 partner crates (shines excluded per §Step 3 SHINES note) and producing a decision-table MR-first commit. The codegen pilot then targets the partner with the cleanest machine-readable spec — likely EMPI or IES, NOT STARS as the original plan body assumed.

Step 1 status row was stale

The Status table’s Step 1 row said "Not started" through 2026-05-25. The body actually landed via commit ae4278a6 on 2026-05-15. Fixed in the same MR as this refresh substantiation.

Steps

Step 2: F-058 trait redesign

Files:

  • services/craig-exchange/src/adapters/mod.rs — replace existing trait with the following. Uses RPITIT (return-position impl Trait in trait, stable in Rust 2024), not #[async_trait::async_trait] — Plan L invariant 6 drops object-safety (no Box<dyn ExchangeAdapter> in business logic), so the Box-per-call overhead async_trait imposes serves no purpose. Native async-fn-in-trait is cleaner:

    pub trait ExchangeAdapter: Send + Sync + 'static {
        type Inbound: serde::de::DeserializeOwned + Send + Sync + 'static;
        type Outbound: serde::Serialize + Send + Sync + 'static;
        type AuditPayload: serde::Serialize + Send + Sync + 'static;
        type Error: std::error::Error + Send + Sync + 'static;
    
        fn test_connectivity(&self, endpoint_url: &str)
            -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
        fn send(&self, endpoint_url: &str, payload: Self::Outbound)
            -> impl std::future::Future<Output = Result<Self::Inbound, Self::Error>> + Send;
        fn audit(&self, response: &Self::Inbound) -> Self::AuditPayload;
    }

    Decision locked at plan-filing time (NOT MR-time): no connect_timeout / per-call config methods on the trait. Per-adapter config is constructor-injected (StarsAdapter::new(client, timeout, …​)) — keeps the trait surface minimal. If a future partner truly needs trait-level config, file as Plan L errata.

  • services/craig-exchange/src/adapters/standard.rs + noop.rs — update to new trait. NoopAdapter becomes generic over a <C: NoopConfig> so its associated types are concrete per test.

  • All call sites in services/craig-exchange/src/api/** that today take &dyn ExchangeAdapter — convert to per-partner dispatch.

  • axum-handler dispatch strategy (locked: per-partner-routed endpoints): the current POST /exchange/send endpoint that runtime-switches on partner_type: &str (today’s adapter_for at adapters/mod.rs:41-59) gets split into per-partner routes:

    // Before
    .route("/v1/exchange/send", post(send_handler))
    // where send_handler reads partner_type from request body and dispatches via Box<dyn>
    
    // After
    .route("/v1/exchange/stars/send", post(send_stars_handler))
    .route("/v1/exchange/cprs/send", post(send_cprs_handler))
    .route("/v1/exchange/empi/send", post(send_empi_handler))
    // ...one per partner

    Each handler embeds the concrete adapter type via State<Arc<StarsAdapter>>. Trade-off vs the alternative (enum-of-concrete-adapters + hand dispatch): more routes (~10), more handler boilerplate, but no enum + no manual dispatch + each handler is independently versionable. Choice locked per the 2026-05-15-pm assessment.

Branch: refactor/partner-typed-schemas-step2-trait-redesign

MR title: refactor(craig-exchange): redesign ExchangeAdapter trait with associated types + typed error [Step 2 of partner-typed-schemas]

Verification:

  1. cargo build --workspace --all-features clean after trait change

  2. cargo nextest run -p craig-exchange — adapter unit tests + integration tests green

  3. grep -rn "serde_json::Value" services/craig-exchange/src/adapters/ returns zero matches

Step 3: F-059 per-partner crate scaffolding

SHINES note: enum variant kept, per-partner crate intentionally excluded

The ExchangeAdapterKind::Shines enum variant exists for catch-all-fallback routing — it is NOT an external partner system. SHINES (Georgia Statewide Automated Child Welfare Information System) is the legacy CCWIS that CRAIG is replacing, not a partner CRAIG calls. The interface doc at docs/modules/ROOT/pages/interfaces/shines-integration-mapping.adoc opens with an explicit [NOTE] block: "Legacy SHINES reference document … It documents the system being replaced, not CRAIG behavior." Its body is a SHINES↔STARS mapping (how the legacy system used to talk to the STARS partner), not a spec for a SHINES partner.

What SHINES actually does in the dispatch table:

  • Backfill default: services/craig-exchange/migrations/20260525213241_add_adapter_kind.sql sets DEFAULT 'shines' so legacy exchange_partners rows with no explicit adapter_kind route somewhere reasonable.

  • Catch-all for unmapped semantic categories: seed data assigns Shines to partner_type values where no modern partner protocol exists — cwca_provider, tanf, external_data, tribal_authority, sister-state CCWIS systems (all annotated "no SHINES equivalent").

  • Permanent route through StandardAdapter: ExchangeAdapterKind::Shines will NEVER get an AnyAdapter::Shines(ShinesAdapter) dispatch arm or a crates/craig-partner-shines crate. It stays on the placeholder dispatch surface forever.

The originally-planned batch-2 third slot of craig-partner-shines was substituted with craig-partner-wic in !456 after this audit landed. Net effect: Step 3 ships 10 per-partner crates, not 11.

Per-partner crates (10 after SHINES exclusion)

Files (per partner crate; 10 crates after dedup of the 17 interface docs into 11 source partner systems then subtracting SHINES per the note above):

The 10 partner systems (from docs/modules/ROOT/pages/interfaces/):

  1. caps (caps-referral-ies.adoc)

  2. cprs (cprs-court-order-report.adoc, cprs-fcc-case-plans.adoc, cprs-inv-ong-stage-data.adoc)

  3. doe-slds (doe-slds-detail.adoc, doe-slds-interface.adoc)

  4. empi (empi-inquiry.adoc, empi-interface.adoc, empi-registration.adoc)

  5. ies (ies-medicaid-eligibility.adoc)

  6. ions (ions-outbound.adoc)

  7. smile (smile-financial.adoc)

  8. stars (stars-detailed-design.adoc, stars-integration-architecture.adoc)

  9. tcm (tcm-medicaid-claims.adoc)

  10. wic (wic-referral-ies.adoc)

Per partner:

  • crates/craig-partner-<name>/Cargo.toml (NEW)

  • crates/craig-partner-<name>/src/lib.rs (NEW)

  • crates/craig-partner-<name>/src/types.rs (NEW — InboundEvent, OutboundCommand, AuditPayload typed structs/enums; initial set traces to that partner’s interface-spec file(s))

  • crates/craig-partner-<name>/src/adapter.rs (NEW — pub struct <Name>Adapter; impl ExchangeAdapter for <Name>Adapter { …​ })

  • crates/craig-partner-<name>/src/error.rs (NEW — #[derive(thiserror::Error)] pub enum <Name>Error { …​ })

(Repeat for each of the 10 partner crates.) * Cargo.toml (workspace) — add each new crate to members * services/craig-exchange/Cargo.toml — add path deps on each new crate * services/craig-exchange/src/adapters/mod.rs::adapter_for — replace the string-match dispatch with a typed match adapter_kind { ExchangeAdapterKind::Stars ⇒ stars::StarsAdapter::new(…​), …​ }. Note: ExchangeAdapterKind is the NEW enum introduced at Step 2 per refresh Decision 1; it does NOT collide with the existing craig_reference::PartnerKind (which encodes semantic categories like Le / Hospital, not wire-protocol identifiers).

Architectural decision (PILOT — 2026-05-25): The ExchangeAdapter trait + ExchangeAdapterKind enum live in a NEW leaf crate crates/craig-exchange-contracts (NOT in services/craig-exchange, where they would stay pub(crate)). Per-partner crates depend on craig-exchange-contracts only; the host services/craig-exchange re-exports them via pub(crate) use craig_exchange_contracts::{…​}; so its handler / store / worker call sites compile unchanged. AnyAdapter enum + *Error types + adapter_for STAY in the host service — those are implementation glue, not contract. This is the "Choice 2" architecture from the Step 3 brief; it’s the right shape for the 10-partner-crate ecosystem (ExchangeAdapterKind retains 11 variants because Shines stays as the catch-all routing fallback — see §Step 3 SHINES note) because each partner crate stays a strict leaf with no service deps.

Branch (per partner): feat/partner-typed-schemas-step3-<partner> (~10 MRs)

MR title (per partner): feat(craig-partner-<name>): typed adapter crate per partner-typed-schemas [Step 3 of partner-typed-schemas]

Verification (per partner):

  1. cargo build -p craig-partner-<name> clean

  2. cargo build --workspace clean (workspace integration)

  3. cargo nextest run -p craig-partner-<name> — type-system smoke tests (round-trip serde on each typed variant)

Step 4: F-060 schema versioning per partner

Files: per partner — within each crates/craig-partner-<name>/src/types.rs, introduce versioned envelope where the interface doc mentions explicit version numbers:

#[derive(Deserialize)]
#[serde(tag = "schema_version")]
pub enum StarsEnvelope {
    #[serde(rename = "2.3")]
    V2_3(v2_3::Shape),
    #[serde(rename = "2.4")]
    V2_4(v2_4::Shape),
}
  • Versioned-partner crates gain src/v<X>/mod.rs per version.

  • Single-version partners: no envelope wrapper; the bare Shape is the inbound type.

Branch: feat/partner-typed-schemas-step4-schema-versioning

MR title: feat(craig-partner-{stars,cprs,…​}): introduce schema-version envelopes per interface-doc revisions [Step 4 of partner-typed-schemas]

Verification:

  1. Round-trip serde test per versioned variant

  2. Unknown version → typed UnrecognizedSchemaVersion { received: String } variant (NOT a Value fallback)

Step 5: F-061 typed Audit payloads end-to-end

Choice locked = Option B (unified table + typed read API). Option A is the documented fallback.

  • Option B (locked): partner_audit_events(partner_kind ENUM, payload JSONB). Read-side store function returns Vec<PartnerAuditEvent> enum, never Value. Pros: cross-partner queries trivial; one migration. Cons: payload column is JSONB internally; need to keep the read-side typed-enum-only discipline.

  • Option A (fallback): per-partner audit table stars_audit_events, cprs_audit_events, etc. Use ONLY if Option B’s read-side enum becomes unmaintainable (~20 variants). Step 5 MR justifies the switch in body if invoked.

Files (Option B):

  • services/craig-security/migrations/<TS>_typed_partner_audit_events.sql (NEW)

  • services/craig-security/src/store/audit.rs — typed read API returning PartnerAuditEvent

  • services/craig-security/src/api/audit.rs — handlers return typed enum, no Value escape

Branch: feat/partner-typed-schemas-step5-typed-audit

Verification:

  1. Migration applies clean (devstack)

  2. Read-side returns typed PartnerAuditEvent for every audit row

  3. grep -rn "serde_json::Value" services/craig-security/src/{store,api}/audit* returns zero matches

Step 6: F-062 codegen pilot

Pick STARS (or first partner whose spec is machine-readable). Source-format determination + tooling choice happens at branch time.

Pre-check at branch time — verify STARS spec is machine-readable:

# Look for XSD / OpenAPI / Swagger markers
grep -E '<xs:schema|openapi:|swagger:' docs/modules/ROOT/pages/interfaces/stars-*.adoc

# Look for JSON example fenced blocks (suggests prose-described JSON shapes)
grep -c '```json' docs/modules/ROOT/pages/interfaces/stars-*.adoc

# Look for actual XSD or OpenAPI spec files attached to interface dir
find docs/modules/ROOT/attachments -name '*stars*' -name '*.xsd' -o -name '*.yaml' -o -name '*.yml' 2>/dev/null

If none of the above produces machine-readable schema for STARS, fall back to a partner that does (CPRS, EMPI, and the federal interfaces — IES / TCM — most likely candidates per federal interface lineage). Decision memo + the codegen-or-handwritten determination committed alongside this Step’s MR.

Files:

  • crates/craig-partner-<chosen>/build.rs (NEW) — invokes codegen from spec source

  • crates/craig-partner-<chosen>/src/generated/ (NEW — checked-in generated output)

  • crates/craig-partner-<chosen>/Cargo.toml — adds codegen-time build-dep

  • Spec source under docs/modules/ROOT/pages/interfaces/ — may need a fixture extraction step to make it consumable by tooling

Branch: feat/partner-typed-schemas-step6-codegen-pilot-<partner>

Verification:

  1. cargo build -p craig-partner-<chosen> runs codegen, produces typed module

  2. Generated types pass round-trip serde tests

  3. Decision memo committed: which other partners qualify for codegen vs hand-written

Step 7: F-063 mock-server adapter integration

Files:

  • tools/craig-mock-server/src/fixtures/ (NEW subdirectory — does not exist today; current layout has flat <partner>.rs siblings of main.rs)

  • tools/craig-mock-server/src/fixtures/<partner>/ (NEW per partner) — canonical inbound + outbound fixture payloads

  • tools/craig-mock-server/src/{caps,wic}.rs (NEW — 2 missing partner modules; today’s mock-server has 8 partner modules for cprs, doe, empi, ies, ions, smile, stars, tcm). shines does NOT get a mock-server module per §Step 3 SHINES note (it’s the catch-all fallback, not a partner with its own wire shape).

  • tools/craig-mock-server/src/main.rs — fixture-serving routes per partner kind

  • crates/craig-partner-<name>/tests/round_trip.rs (NEW per partner — 10 total) — exercises the adapter against the mock-server

Branch (per partner): feat/partner-typed-schemas-step7-mock-roundtrip-<partner>

Verification:

  1. cargo nextest run -p craig-partner-<name> --test round_trip — green per partner

  2. Mock-server cargo run --bin craig-mock-server boots clean against the new routes

Step 8: F-064 retire partner-edge Value carve-out

Files:

  • services/craig-cases/src/api/reports.rs + adjacent — confirm raw_submission typed (per Plan H F-030 unwrap audit + this plan’s earlier steps)

  • All // PARTNER-EDGE-UNTYPED: comments cited in Plan I F-037 — remove

  • Plan I F-037 sub-row Status update: "Done (closed by Plan L F-064)"

Branch: refactor/partner-typed-schemas-step8-retire-value-carveout

Verification:

  1. grep -rn "serde_json::Value" services/ crates/ — zero occurrences in non-test code (modulo any audit-payload internal-JSONB-column site; the read API still returns typed enum)

  2. grep -rn "PARTNER-EDGE-UNTYPED" services/ crates/ — zero comments left

Step 9: Plan completion audit + archive

Mirror Plan B Step 8 / Plan C Step 18 / Plan F Step 6 pattern.

Files Touched

File Step Change

services/craig-exchange/src/adapters/mod.rs

2,3

EDIT (trait redesign + per-partner dispatch)

services/craig-exchange/src/adapters/{noop,standard}.rs

2

EDIT (update to new trait)

crates/craig-partner-<name>/**

3,4,6,7

NEW (one crate per partner; ~10 total)

Cargo.toml (workspace)

3

EDIT (members)

services/craig-exchange/Cargo.toml

3

EDIT (path deps)

services/craig-security/migrations/<TS>_typed_partner_audit_events.sql

5

NEW

services/craig-security/src/{store,api}/audit.rs

5

EDIT (typed read API)

tools/craig-mock-server/src/fixtures/<partner>/**

7

NEW (per partner fixtures)

tools/craig-mock-server/src/main.rs

7

EDIT (fixture routes)

All sites currently carrying // PARTNER-EDGE-UNTYPED:

8

EDIT (comment + Value field removal)

Verification

After every step: cargo xtask validate --skip-docker + cargo nextest run --workspace.

Risks

Risk Mitigation

Step 3’s ~10 per-partner crates explode workspace member count + compile time

Workspace already has 25 members; +10 brings it to 35. Compile time grows but each crate is independent. Use cargo nextest run -p for per-partner CI parallelism

Step 4 versioned envelopes lock in version names that the partner later renames

Use #[serde(rename = "<version-string>")] so the Rust variant name + the wire form are decoupled. Version names trace to the interface-doc revision date when partner spec is ambiguous

Step 5’s per-partner-table vs unified-table decision is bikeshed-prone

Concrete decision required in Step 5’s MR body, not deferred. Default = Option B (unified + typed read). Owner makes the call + documents tradeoff

Step 6 codegen for STARS turns out to require building an XSD-to-Rust toolchain from scratch

Hand-written types are the fallback. The pilot’s deliverable is "decided how to codegen + at least one partner converted"; if STARS doesn’t support codegen, pick a partner that does (or accept hand-written everywhere)

Step 7 mock-server adapter integration retroactively breaks the existing mock-server fixtures

The mock-server is functionally orphaned per memory project_mock_server_orphaned; there are no existing real fixtures to break. This step is net-additive

Plan L conflicts with Plans D/G/H/I sweeps over services/craig-exchange/

Plan L is the destination; Plans D/G/H/I are the daily-driver cleanup. Run Plans D/G/H/I first; Plan L picks up after they land

After this plan lands

  • ExchangeAdapter trait is associated-types-typed with a typed error type (no Value, no String error)

  • ~10 per-partner crates own their typed schemas + adapter impls + audit payloads

  • Schema versioning enforced at deserialize per partner that has explicit revisions

  • Audit log returns typed PartnerAuditEvent enum from read API (internal JSONB storage is opaque to consumers)

  • Mock-server is no longer orphaned — every adapter has a round-trip integration test against canonical fixtures

  • Partner-edge Value carve-out cited by Plan I F-037 retired; codebase has zero serde_json::Value in non-test, non-codegen code

Edit this page · latest