Plan T3 — Open Closures & Hardening (sub-plan of Plan T umbrella)

On this page

Status

Step Description Status

1

[#575] T3.1 Open ExchangeAdapterKind: enum deletion + String DTOs + temp 11-token validator. Per ADR-032 §1.3 (open-TEXT carriage + dual-site validation) + D5 + D9. Gated on Plan T2 archived (registry dispatch live; T2.4 boot membership validation live). Scope: * DELETE ExchangeAdapterKind from crates/craig-exchange-contracts/src/lib.rs:120-156 + its 2 inline tests (adapter_kind_round_trips_snake_case :223, adapter_kind_inventory_matches_eleven_partners :238). * DTO conversion at services/craig-exchange/src/api/partners_dtos.rs: CreatePartnerRequest.adapter_kind: String (:32) KEEPING the serde default — default_adapter_kind() (:53) returns "shines".to_string() (behavior-preserving; omitted field still defaults to shines); UpdatePartnerRequest.adapter_kind: Option<String> (:79). utoipa schemas follow the type change automatically. * Store: store/models.rs:19String (TEXT column round-trips directly; the sqlx::Type enum derive dies with the enum); store/partners.rs create/update/list signatures (:31/:46/:54/:78/:98/:113) → String/&str. * adapters/standard.rs::config_for_kind (pub(crate) const fn, total match over the enum at :195-209 mapping 11 StandardAdapterConfig consts) collapses — post-T2.1 the orchestrator constructs StandardAdapter for SHINES only, so replace with direct use of the SHINES const (or a shines_config() fn); DELETE the 10 partner consts (CAPS..WIC) if T2.1 left them unreferenced (verify at MR time — typed per-partner adapters carry their own config). * utoipa: no component-level OpenAPI schema registration for ExchangeAdapterKind exists in api/mod.rs (verified 2026-06-09) — the String field schemas generate automatically; no openapi wiring changes. * Dispatch seam OPENS: ExchangeRegistries::resolve(kind: &str, endpoint_url: Option<&str>) (T2.1 shipped it taking the enum; the strum token conversion at the seam dies). send_worker.rs passes &partner.adapter_kind; connectivity handler likewise. ResolveError::UnknownKind carries String. * TEMP write-path validator per D9: const KNOWN_ADAPTER_KINDS: [&str; 11] (the 11 tokens verbatim) local to services/craig-exchange; create/update handlers reject unknown tokens with 400. D9’s contract is STATUS-level 400→400, not problem-type-identical: today an unknown adapter_kind is rejected by axum JSON deserialization (serde enum parse fails BEFORE the handler), whose wire shape differs from a handler-level RFC 9457 validation 400 — VERIFY the current rejection shape at MR time and document the problem-type change (deserialization-rejection → VALIDATION catalogue URL per the #311 convention) in the CHANGELOG. Replaced by registry membership in T3.2; the const dies there. * Cargo.toml dep cleanup (gated by cargo machete [9b/14] + unused_crate_dependencies posture): crates/craig-exchange-contracts drops sqlx + strum + utoipa (enum-only deps per the Cargo.toml leaf-crate comment; verify the serde_json dev-dep is still used by remaining trait/macro tests); crates/craig-partner-audit KEEPS craig-exchange-contracts (post-T1.3 the codec seam imports ErasedAdapterError from it; only the ExchangeAdapterKind usage converts to tokens — drift-corrected 2026-06-10 during T1.3 execution per risk row 8); sweep services/craig-exchange dev-deps for enum-only strum usage. * crates/craig-partner-audit token conversion (surfaced by 2026-06-09 src research; absent from umbrella row 5(a)): PartnerAuditEvent::kind() returns &'static str (the discriminator tokens are already static); decode_jsonb dispatches on a &str token match; PartnerAuditDecodeError::UnsupportedKind { kind: String }. This was the LAST typed consumer outside the exchange service — composes with T2.2’s encode_audit already keying on adapter.kind(): &'static str. * Lockstep test replacement: T2.4’s registry_inventory_matches_adapter_kind_enum (name per the T2.4 spec — verify the REALIZED test name at MR time; pre-T2 the ancestor is adapter_kind_inventory_matches_migration_check_constraint at adapters/mod.rs:435) premise (the enum) dies → REPLACE with registry_inventory_matches_known_kinds asserting ExchangeRegistries.adapters keys == the 11 literal tokens (pinned inline; keeps registry ↔ seed lockstep documentation; "noop" asserted ABSENT per A3). * Tests: existing partner CRUD + send_worker + connectivity devstack integration tests green (regression gate); NEW sad-path create_partner_unknown_adapter_kind_sad (400 + problem-type assert); token round-trip on "doe_slds" (the only multi-word token).

Done (2026-06-10) — shipped per spec; both verify-at-MR-time clauses resolved: (1) config_for_kind + the 10 partner consts were ALREADY deleted at T2.1 (its deviation (b) pulled the collapse forward — no work remained); (2) the realized lockstep test name was registry_inventory_matches_adapter_kind_enum, replaced with registry_inventory_matches_known_kinds as spec’d. D9 wire-shape finding (live-probed pre-implementation): the pre-T3.1 rejection was 422 PLAIN-TEXT (axum JsonDataError), NOT the 400 this cell assumed — the new handler validator emits 400 + INVALID_ENUM_VALUE matching the sibling partner_type/direction token fields in the same DTOs; realized change is 422 plain-text → 400 RFC 9457 (structure upgrade; CHANGELOG’d). validate_adapter_kind + KNOWN_ADAPTER_KINDS live in api/partners_dtos.rs; both String DTO fields gained ENUM_STRING_MAX garde caps per #486 (the enum needed none). resolve(kind: &str, …); ResolveError::UnknownKind { kind: String }; partner-audit converted as spec’d with a NEW unknown-token fail-closed sad test. Dep cleanup: contracts dropped sqlx/strum/utoipa; exchange dropped its now-unused strum dep; partner-audit KEEPS contracts (drift-corrected clause confirmed). Tests: workspace 1841 → 1839 run (−2 contracts enum tests, −1 exchange enum round-trip, +1 partner-audit sad) + 2 NEW devstack-gated (unknown-kind 400 sad / doe_slds round-trip). !679 / 59bd3c92

2

[#576] T3.2 Registry-driven create/update validation + registry discovery endpoints + CLI consumption. Per ADR-032 §1.3 (dual-site validation: write-path here + boot at T2.4). Scope: * Create/update handlers swap KNOWN_ADAPTER_KINDS const → registries.adapters.contains_key(kind) via the Extension(Arc<ExchangeRegistries>) already layered at T2.1; the const DELETES. Unknown token still 400 with the same problem-type as T3.1’s validator (the swap is invisible at the API surface). * NEW GET /v1/exchange/registry/adapter-kinds ({"adapter_kinds": ["caps", …​]}, 11 entries with DefaultBundle, sorted) + NEW GET /v1/exchange/registry/partner-types ({"partner_types": […​]}, 14 entries, sorted — keys of ExchangeRegistries.partner_types) — read-only registry introspection following existing exchange GET handler conventions (Claims-authenticated, utoipa-documented, RFC 9457 on error). Gives operators + UIs a discovery surface for BOTH open token sets so neither is a guess-the-token API (partner-types endpoint also feeds T3.3’s web-form consumption). * CLI consumption: services/craig-cli/src/cmd/exchange.rs partner create/update gain --adapter-kind <String> args (today the CLI cannot set adapter_kind at all — server default shines applies silently; partner_type is already a free String arg). Open-token args, no client-side token list. * Tests: both endpoints happy (sorted counts; "noop" absent from adapter-kinds); create-with-registry-validation happy + sad; CLI arg pass-through test per existing cli test patterns.

Done (2026-06-10) — shipped per spec. ExchangeRegistries gained adapter_kinds() / partner_type_tokens() / validate_adapter_kind() (registry-membership write-path validator; partner_types field’s #[expect(dead_code)] lifted — first consumer arrived); KNOWN_ADAPTER_KINDS + its helper DELETED from partners_dtos.rs; the create-with-registry-validation happy+sad coverage is the T3.1 pair unchanged (the swap is wire-invisible, which IS the assertion). NEW api/registry.rs handler module (authz mirrors list_partners — admin/supervisor via auto_scope_list, caseworker default-deny 403; realization detail since the cell only said Claims-authenticated). Endpoint tests via raw ServiceClient GETs, NOT new typed-client methods (B7 budget bans new untyped client methods — siting note). Lockstep test keeps its own inline PINNED_KINDS array as the external reference now the shared const is dead. CLI --adapter-kind on create+update with pass-through test. T3.1 MR cite backfilled (!679 / 59bd3c92). !680 / 38b35d44

3

[#577] T3.3 PartnerType enum deletion + temp 14-token validator. Per ADR-032 §1.3 + §2.2 (taxonomy opening) + D9. Scope: * DELETE PartnerType from crates/craig-reference/src/enums.rs:667-694 + its round-trip unit tests. (Distinct from PartnerKind (security semantic categories, enums.rs:1754) — untouched.) * REWRITE crates/craig-reference/src/validation.rs:129-132 validate_partner_type from enum-parse to a 14-token const check — tokens VERBATIM from services/craig-exchange/migrations/20260321100000_expand_partner_types.sql (state_agency court_system federal_agency tribal_authority private_provider cwca_provider financial medicaid child_abuse_registry tanf child_support external_data education health_agency). New signature fn validate_partner_type(value: &str) → Result<(), String> (drops the PartnerType return — both call sites at api/partners.rs:158/:229 already discard the value via ? + map_err, so they compile unchanged). * Deliberate behavioral widening, documented: the current validator accepts only the 5 enum tokens while the DB CHECK + seeded rows carry 14 — the API today 400s on 9 token values that exist in the database (latent API/DB mismatch; the craig-web comment at routes/exchange/partners.rs:47-52 documents the gap). T3.3 aligns the API with the DB: 5→14 accepted; unknown tokens still 400 (the D9 contract is "unknown → 400 preserved", not "accepted-set frozen"). * Web/CLI/test-lib already carry partner_type: String — no DTO changes; UPDATE the craig-web staleness comments (:47-52, :199-202) to cite the registry instead of the dead enum. * Web-form consumption (closes the last hardcoded token list): services/craig-web/templates/…​/new_partner.html hardcodes the OLD 5 partner_type <option>`s and the form handler (`routes/exchange/partners.rs:218 area) sends no adapter_kind — the form select is populated from the T3.2 GET /v1/exchange/registry/partner-types endpoint via the BFF (fetch in the form-render handler, options from registry tokens) + the form gains an adapter-kind select fed by /registry/adapter-kinds. New registry tokens then surface in the UI with ZERO code changes (the Plan S north star at the UI layer). If BFF plumbing proves disproportionate at MR time, the explicit fallback is rendering the 14+11 tokens server-side from the same BFF fetch — NOT re-hardcoding. * Tests: validator unit tests (14 happy, unknown sad); existing partner CRUD integration green; tests/api/evil_corpus.rs + tests/constraints/invalid_partner_type_enum.rs comment-cites updated (logic unchanged — the CHECK still exists until T3.5).

Done (2026-06-10) — shipped per spec; the BFF plumbing was NOT disproportionate (fallback clause unused): new_partner_form fetches both discovery endpoints concurrently (tokio::join!) and ?-propagates per §D7 — a NEW shared fetch_json helper (non-paginated sibling of fetch_page, which now delegates to it) carries the parse+error semantics. Form gains the adapter-kind select (shines preselected to match the API serde default) and now sends adapter_kind; the 5 hardcoded <option>`s + their 5 Fluent keys DELETED. Behavior note: the form page now 403s for caseworkers (the registry fetch propagates) — consistent with §D7-era list-page behavior the rbac E2E spec pins. The enum had NO dedicated round-trip tests to delete (the spec’s "+ its round-trip unit tests" was empty in reality). Extras beyond spec: +1 devstack widening proof (`create_partner_expanded_taxonomy_token_accepted, "education" now creatable), seed-model + CLI-test stale comments swept. Verified live: 4 devstack tests + exchange E2E 12/12 against the dynamic form. !681 / 20cc658b

4

[578] T3.4 FederalPartnerMappingRegistry seam (report-emit-time per A7; reporting wire-up deferred per D10). Scope is the SEAM, not the consultation — services/craig-reporting row-emit code does not exist yet (NCANDS/AFCARS generation is placeholder MVP: api/ncands.rs:77-89 writes zero-count submissions; verified 2026-06-09). Scope: * services/craig-reporting gains deps on craig-state-bundle + craig-state-default and boots its OWN FederalPartnerMappingRegistry::from_bundles([Box::new(DefaultBundle)]) (per-service registry boot is the ADR-032 consumption model; hardcoded DefaultBundle per A10 — env-var activation is Plan U Step 4). Registry stored as an axum::Extension layered in craig-reporting’s router setup (mirror the T2.1 exchange ExchangeRegistries Extension wiring; verify the realized seam — setup_router/attach_standard_extensions — post-T2 archive). Boot here MATERIALIZES the registry only — no completeness validation at boot per A7. * Typed error slot: NEW service-local ReportingError enum with MissingFederalMapping { partner_type: String } (the A7-specified per-row failure shape). craig-reporting currently has NO service-local error type — it uses craig_common::ApiError throughout (verified 2026-06-09); the new enum follows the Plan H typed-everywhere policy and converts into ApiError at the handler boundary. * Resolution helper: resolve_federal_category(registry, token) → Result<FederalPartnerCategory, ReportingError> — the single seam future row-emit code calls. * NO consultation wired into the placeholder submission paths — file follow-up issue "wire federal-mapping resolution into AFCARS/NCANDS row emit" at this step (cites A7 + D10; lands when real row-emit code materializes). Because the seam has no production caller yet, resolve_federal_category + the new error variant carry ONE [expect(dead_code, reason = "Plan T3.4 report-emit seam; production caller lands via follow-up issue <filed-above>")] (per the Plan N Step 6 [expect] discipline; clippy -D warnings + unfulfilled_lint_expectations auto-flag it stale when the wire-up lands) — mirrors the Plan L Step 5 audit_typed seam precedent. * Tests: resolve happy (5 mapped DefaultBundle tokens) + sad ("state_agency"MissingFederalMapping); boot smoke (registry materializes with 5 entries).

Done (2026-06-11) — shipped per spec. Realized seam: NEW federal_mapping.rs module (boot fn + active_bundles() mirroring the exchange orchestrator + resolve helper + ReportingError); registry boot sits in main() between init_object_store and spawn_workers, Extension layered on api::routes(…​) inside build_router (the realized wiring — reporting has no setup_router indirection). Follow-up issue filed: 583. Realization details: the dead-code expect is [cfg_attr(not(test), expect(dead_code))] on resolve_federal_category ONLY — module tests calling the seam would otherwise leave the expect unfulfilled in --all-targets builds (ReportingError stays live through the fn signature, so one attribute suffices, matching the cell’s ONE-marker intent); the ApiError conversion impl deliberately deferred to #583 (YAGNI — the cell’s "converts at the handler boundary" is policy, and there is no handler boundary yet). Deps realized: + craig-reference (FederalPartnerCategory import) + thiserror (first typed error in this service) beyond the 2 the cell names. Risk-row-4 cargo tree verified: 10 partner crates enter reporting’s graph with zero axum edges (mock feature off). !682 / 848dbc3c

5

[#579] T3.5 Drop partner_type CHECK constraint + boot membership validation + registry-driven validators. Per ADR-032 §1.3 + §2.4-as-amended-by-A7. Scope: * NEW migration services/craig-exchange/migrations/<ts>_drop_partner_type_check.sqlALTER TABLE exchange_partners DROP CONSTRAINT chk_exchange_partners_partner_type; (pre-1.0 destructive OK; commit cites the pre-1.0 destructive-rebuild posture). * Boot membership validation EXTENDS the T2.4 orchestrator check (sited in services/craig-exchange/src/bundle_orchestrator.rs per the T2 body — add the partner_type check immediately after the realized adapter_kind check, same typed-boot-error pattern; verify exact placement post-T2 archive): SELECT DISTINCT partner_type FROM exchange_partnersALL rows, no WHERE active filter (an inactive row with an unregistered token could otherwise be re-activated via PATCH {active: true} without ever revalidating; validating all rows closes that hole at the boot site; pre-1.0 reseed handles any stale rows). Every value must be a key in ExchangeRegistries.partner_types (the PartnerTypeRegistry; 14 DefaultBundle tokens) → typed boot error naming offenders. ALSO retrofit the T2.4 adapter_kind check to all-rows in the same MR (same activation hole; one-line filter drop). A7 scope: membership ONLY, no federal-mapping completeness at boot. * Activation-path write validation: the update handler validates only SUPPLIED fields today (partners.rs:229 validates partner_type only when present; active is forwarded independently at :250) — extend the update path to validate the EFFECTIVE (post-merge) adapter_kind + partner_type against the registries whenever the update leaves the row active. Closes the API-side of the same hole; dual-site per §1.3 stays coherent (write path = effective-row, boot = all-rows). * Write-path restrengthening: create/update handlers swap the T3.3 const check → registries.partner_types membership; DELETE craig_reference::validation::validate_partner_type + the 14-token const (registry is now the single source of truth; dual-site per §1.3 = handler + boot). * tests/constraints/invalid_partner_type_enum.rs premise (the CHECK) dies → REPLACE with a boot-validation sad-path devstack test: direct-SQL insert of an unknown partner_type (no CHECK blocks it now) → orchestrator boot validation rejects with the typed error — the TOCTOU-closure proof per §1.3, mirroring T2.4’s adapter_kind twin. * Tests: boot validation happy (seeded rows all registered — seed uses 12 of the 14 tokens) + the sad-path above; registry-driven create/update happy + sad.

Done (2026-06-11) — shipped per spec. Realizations: (1) the boot-validation TOCTOU replacement test lives beside its T2.4 adapter_kind sibling in bundle_orchestrator.rs src tests, NOT in tests/constraints/ExchangeRegistries is crate-internal, unreachable from integration tests (same siting reality T2.4 hit); the constraints FILE was not deleted but RENAMED unregistered_partner_type.rs because its actual content was a handler-400 API assertion (still valid post-swap as the write-path half), not a CHECK test as the cell premise assumed. (2) The TOCTOU twin’s synthetic row is INACTIVE (active=false) — proving the all-rows retrofit in the same assertion; cross-test safety with the T2.4 twin via disjoint evil fields (valid shines/state_agency on the non-evil column), verified by concurrent green runs. (3) Effective-row update validation realized inline in the update handler after the supplied-field checks (body.X.as_deref().unwrap_or(&existing.X) when body.active.unwrap_or(existing.active)); API-level sad path is unreachable by construction (stale stored tokens require direct SQL — the boot twin covers it), so the API test is the reactivation HAPPY path. (4) validate_partner_type error message mirrors validate_adapter_kind’s "expected one of" shape (message text changed from the T3.3 const validator; status + problem-type stable per the D9 invariant). Boot placement: `validate_partner_type_membership called in main() immediately after the adapter_kind check, per spec. Verified live: reload boot ran both validations against seeded rows; pg_constraint shows only chk_exchange_partners_direction remains; 10/10 devstack-gated green. !683 / 09a49395

6

[580] T3.6 Remove georgia seed default at datagen.rs:1630 + CRAIG_SEEDJURISDICTION plumbing. Per ADR-032 A8 + A9 (REDUCED scope: 3 of 4 §2.6 sites are [cfg(test)] helpers; only this seed-default site is production). One MR, full plumbing: * tools/craig-seed/Cargo.toml: add "env" to the clap dep features (deviation from umbrella’s [features] env = ["clap/env"] flag — an env-less seed build has no use case; unconditional is simpler). * main.rs Cli (:65-86) gains #[arg(long, env = "CRAIG_SEEDJURISDICTION", value_parser = non_empty_string)] jurisdiction: String — REQUIRED, no clap default (a code default would re-introduce the A8 violation; absence fails fast at parse), and the value_parser rejects empty/whitespace-only values (an empty env var must not silently produce jurisdiction: "" rows). * SeedConfig (lib.rs:89-92, currently { seed, families }) gains jurisdiction: String; lib.rs::generate() threads it into the SeedGenerator::new call at lib.rs:105; main.rs maps Cli → SeedConfig. * SeedGenerator (datagen.rs:233-246) gains jurisdiction: String field; new(rng, uuid_rng, families, jurisdiction); the SeedRateTable row at :1630 becomes jurisdiction: self.jurisdiction.clone(). * Devstack: devstack/seed/seed.sh:11-14 appends --jurisdiction "${CRAIG_SEED__JURISDICTION:-georgia}"; docker-compose seed service env gains the passthrough. The georgia default now lives ONLY at the deployment-config layer (compose/shell), which is correct — devstack IS a georgia deployment. * ALL direct invocations updated in the same MR (a required arg bricks every caller): xtask/src/cmd/e2e.rs:~105 runs cargo run -p craig-seed directly for E2E manifest regeneration — gains --jurisdiction georgia (or env); the AUTO-GENERATED manifest header’s reproduction command in tools/craig-seed/src/manifest.rs (render_manifest’s "Regenerate with: cargo run -p craig-seed — --seed …​ --families …​" line) gains the `--jurisdiction flag so the printed repro command stays runnable. Grep cargo run -p craig-seed + craig-seed -- workspace-wide at MR time for stragglers. * Plan Q hash-pinned SqlRow tests: constructor call sites updated to pass "georgia"; pinned hashes UNCHANGED (byte-identical SQL when jurisdiction = georgia) — any hash change is a regression signal, not a bless. * Explicitly OUT of scope per A8: the other georgia-flavored seed CONTENT (GEORGIA_ADMIN_UNITS :49, school-name templates :334-335, admin-unit picks :444/:1049, georgia-safety-assessment ruleset name :826, State::Georgia admin-unit iteration :2118-2130) — demo data, not jurisdiction-binding defaults. A jurisdiction-parameterized seed CONTENT pack is Plan U/V-era work (bundles carry seed_data: SeedContribution per BundleContribution). * Tests: clap parse happy (flag + env forms) + sad (absent → parse error; empty string → parse error); rate-table rows carry the passed jurisdiction; hash-pin battery green.

Done (2026-06-11) — shipped per spec. Realizations: (1) the spec’s "Plan Q hash-pin SqlRow constructor call sites" were actually SeedConfig {…​} literals (the tests drive through generate(&SeedConfig), not SeedGenerator::new directly) — 22 literals across integration.rs + sql_byte_identity.rs gained jurisdiction: "georgia".into(); the SQL hash-pin (render_sql_byte_identity_seed42_families9) is byte-IDENTICAL (georgia == the old literal), confirming no regression. (2) Rather than re-hardcode "georgia" in manifest.rs’s repro string (which would be a fresh A8-adjacent literal), threaded the real jurisdiction through `render_manifest + write_output so the printed command echoes the actual value — the committed E2E manifest header was regenerated (SEED body byte-identical; jurisdiction touches only rate-table SQL, not the manifest entities). (3) --jurisdiction REQUIRED via clap env feature + non_empty_string value-parser as spec’d. (4) The clap wiring tests (flag/env happy, absent/empty sad) run the REAL binary via CARGO_BIN_EXE_craig-seed + Command::env/.env_remove in tests/integration.rs — chosen over in-process Cli::try_parse_from because Rust-2024 set_var/remove_var are unsafe, and a [allow(unsafe_code)] (even test-only, xtask-precedented) would regress the B4 [allow] budget 86→87. The child-process form is both budget-flat AND a stronger assertion: the happy tests confirm the jurisdiction reaches the emitted financial SQL ('delaware' present, no 'georgia' leak), not just that clap parsed it. The two pure non_empty_string checks stay as unit tests in main.rs. Straggler grep found + fixed the known-issues.md regen command. Verified live: cargo xtask dev reseed seeded rate_tables.jurisdiction = 'georgia' through the required arg end-to-end. !684 / c32477de

7

[581] T3.7 Integration-test lockstep at per-service paths. Per Plan N Step 8a axis-coverage convention (workspace is virtual; per-service tests/ dirs are the convention targets — NO concurrency/fault/security/ dirs exist anywhere yet; T3.7 creates the first). Scope: * NEW services/craig-exchange/tests/security.rs harness root (Cargo only compiles top-level tests/*.rs; the existing api.rs + constraints.rs harnesses use the same [path = "…​"] mod …​; pattern — mirror it) declaring #[path = "security/open_enum_validation.rs"] mod open_enum_validation; + NEW services/craig-exchange/tests/security/open_enum_validation.rs — evil-input corpus sweep (central corpus at crates/craig-test-lib/src/evil/, 12 categories / 83 cases, per Test Framework Hardening + testing.md § Evil Input Corpus) against the now-String adapter_kind + partner_type fields: SQLi/XSS/oversize/unicode-confusable/control-char payloads → uniform 400s; registry endpoints probed with evil Accept/query params. The tests/security/ file path auto-tags the security axis per the 3-mechanism convention (the harness-root indirection preserves the path-based tag — verify xtask axis-coverage recognizes it at MR time). * Axis-tag audit of every test added across T3.1-T3.6 (@axis: comment or suffix); xtask axis-coverage opt-out stays monotonic (no new entries). * Lockstep sweep: grep for stale ExchangeAdapterKind / PartnerType references in test code, docs snippets, and craig-web/CLI comments missed by T3.1/T3.3. * Audit-as-deliverable clause (per the audit-as-deliverable bias): if T3.1-T3.6 already shipped equivalent coverage inline, this step ships the audit EVIDENCE — a per-surface coverage table (surface → test cite or gap) recorded in the MR description — plus only the gap-filling tests; 0 new tests for a sub-item is an acceptable honest outcome ONLY with its coverage cite in the table.

Done (2026-06-11) — shipped per spec. NEW tests/security.rs harness root + tests/security/open_enum_validation.rs (the workspace’s first tests/security/ dir); xtask axis-coverage recognizes the path (0 drift, no new opt-out entries). Coverage realization (audit-as-deliverable, no duplication): adapter_kind swept for all applicable categories (String/Unicode/Path/Html/EnumValue) — NEW (the generic api/evil_corpus.rs adoption only smuggled partner_type); partner_type text-shape categories swept here, its EnumValue baseline CITED to api/evil_corpus.rs rather than duplicated; registry discovery endpoints probed with evil query params (ignored → 200). The evil-corpus macro emits fixed evil_<category> names, so the two field sweeps live in mod adapter_kind_evil / mod partner_type_text_shapes sub-modules (per the macro’s documented wrap-in-mod guidance). Empirical result: of the 25 nextest entries the two macros + probe emit, the 10 that do real assertion work (5 adapter_kind categories + 4 partner_type text-shape categories + the registry probe; the other 15 inapplicable categories early-return) all reject/handle cleanly — 0 validator gaps (the open String fields are as hard as the old enums: garde length cap + registry membership both 4xx). Inapplicable categories (Jwt/Upload/Uuid/Json/Date/Multipart/Signature) skipped — plain JSON string members, not multipart/signed inputs. Lockstep sweep: 0 stale enum refs in live code (only deletion-history doc comments). Coverage table for the audit lives in the MR description. !685 / 8b17286b

8

[#582] T3.8 Plan-completion audit + archive (Plan T3). Standard close-out per .claude/docs/delivery-protocol.md § Plan Completion Audit. Fresh plan-completion-audit Explore subagent per the plan-completion-audit bias (verifies T3.1-T3.7 cells carry concrete !MR / sha cites). cargo xtask docs plan-archive --dry-run then execute. nav.adoc Active entry REMOVED + archive.adoc row ADDED. .claude/CLAUDE.md § Phase Status row appended above Testing. Plan T umbrella (plans/adapter-registry-pivot.adoc) Status Step 6 (Plan T3 executed) → Done (YYYY-MM-DD). Plan S Step 9 is NOT closed by this step alone — Plan S folds "Plan T3 executed end-to-end + Plan T umbrella audit + archive" into one Step 9 close-out (multi-jurisdiction-foundation.adoc line ~45 expects the umbrella audit+archive "in this step’s final MR"): execute the Plan T umbrella audit+archive (umbrella Step 7) in this MR or an immediately-following one (decide at MR time — plan-archive tooling handles one candidate cleaner per MR), and only then flip Plan S Step 9 → Done (YYYY-MM-DD). Memory: mark Plan T3 Done in Plan S. Epic &53 closed with cross-refs (#575-#581).

Done (2026-06-11) — plan-completion audit (fresh Explore subagent) caught + fixed 2 unresolved MR-cite placeholders (T3.1 cell still read "backfilled at T3.2"; T3.7 "backfilled at T3.8") — both now carry inline !MR / sha; all other cells verified concrete. This MR archives Plan T3 (body → archive/, nav Active entry removed, archive.adoc Architecture row added), flips umbrella Step 6 → Done, removes the CLAUDE.md Active-Work row + appends "Adapter Registry T3" to § Completed Plans, and closes epic &53 (#575-#581 + follow-up #583). Plan T umbrella audit+archive (umbrella Step 7) + Plan S Step 9 flip ship in the immediately-following MRplan-archive handles one candidate per MR, and the umbrella close-out carries its own audit subagent + epic &45 closure (one-candidate-per-MR decision per this cell). This MR (Plan T3 audit + archive close-out)

Context

Plan T1 lands the foundations (ErasedAdapter trait/macro, AuditCodec, craig-state-bundle registries, DefaultBundle, FederalPartnerCategory enum); Plan T2 materializes them into running dispatch (ExchangeRegistries, bundle orchestrator, boot adapter_kind membership validation, mock registry, adapter_kind CHECK drop). Plan T3 finishes the pivot by OPENING the closures Plans T1/T2 deliberately left closed:

  • Type-system closures (T3.1-T3.3): the ExchangeAdapterKind + PartnerType enums die; DTOs carry validated open String tokens; validation is registry-driven (write-path) + boot membership — a new jurisdiction bundle adds partners without touching CRAIG code, per the Plan S north star.

  • DB closure (T3.5): the partner_type CHECK drops (its adapter_kind twin dropped at T2.4); the dual-site validation (§1.3) replaces it.

  • Federal seam (T3.4): partner_type → FederalPartnerCategory resolution per A7, report-emit-time, seam-only (reporting row-emit code does not exist yet).

  • Seed closure (T3.6): the last production georgia default dies; CRAIG_SEED__JURISDICTION required-from-invocation.

  • Hardening (T3.7): the open String surfaces get evil-corpus coverage at convention paths.

After Plan T3 archives: Plan T umbrella audit+archive (umbrella Step 7); Plans U/V execute against stable trait+registry surfaces.

Decisions inherited

  • D5: ExchangeAdapterKind deleted in T3.1 (per ADR-032 §1.3 open-TEXT carriage). D9: temp hardcoded validators preserve 400→400 during T3.1 + T3.3 (no transition window).

  • D10 / A7: T3.4 ships the federal-mapping seam only; report-emit-time check; reporting wire-up deferred to a follow-up issue.

  • A8/A9: T3.6 scope is the 1 production seed-default site; env var is CRAIG_SEEDJURISDICTION (per-service convention); per-service CRAIG_<SVC>JURISDICTION wiring already exists in production settings.

  • A10: hardcoded DefaultBundle activation everywhere in Plan T (env-var activation is Plan U Step 4) — T3.4’s reporting boot follows.

  • A3: "noop" is never a registry entry — inventory tests assert ABSENT.

  • Plan L invariant: typed ExchangeAdapter trait untouched; T3 changes only kind CARRIAGE (enum → token), not adapter contracts.

Sequencing gates

  • T3 execution start (T3.1 MR opens): Plan T2 archived.

  • T3.1 → T3.2 (registry validation replaces the temp const; endpoints read the registry).

  • T3.2 → T3.3 (the web-form partner_type select consumes T3.2’s /registry/partner-types endpoint).

  • T3.3 → T3.5 (registry validators replace the temp 14-token check; CHECK drop is safe only with dual-site validation ready).

  • T3.4 independent (reporting-side; consumes T1.5/T1.6 artifacts only). T3.6 independent (seed tool). May run parallel after T3.1.

  • T3.7 after T3.1-T3.6 (it audits their surfaces). T3.8 last.

Calendar

Step Scope Anticipated MRs Calendar

T3.1

ExchangeAdapterKind deletion + String DTOs + partner-audit conversion + temp validator + dep cleanup

1

~1.5 days

T3.2

Registry validation swap + 2 discovery endpoints + CLI adapter-kind args

1

~1 day

T3.3

PartnerType deletion + 14-token validator + web-form registry consumption

1

~1 day

T3.4

Federal mapping seam in craig-reporting

1

~1 day

T3.5

partner_type CHECK drop + all-rows boot validation + effective-row update validation + registry validators

1

~1 day

T3.6

Seed jurisdiction plumbing (CLI + SeedConfig + devstack + xtask e2e + manifest repro)

1

~0.5 day

T3.7

Test lockstep + evil-corpus sweep

1

~1 day

T3.8

Audit + archive

1

~0.5 day

Total: 8 MRs / ~1.5 wk (sequential sum 7.5 working days; T3.4/T3.6 parallelism alongside the T3.1→T3.2→T3.3→T3.5 chain can compress the critical path to ~6 days — ~1.5 wk is the sequential upper bound matching Plan T umbrella Calendar row 3 "~8 | ~1.5 wk").

Step DAG

            [gate: Plan T2 archived]
                      ↓
        T3.1 (adapter_kind opens)
          ↓           ↘ (parallel after T3.1)
        T3.2           T3.4    T3.6
          ↓ (partner-types endpoint feeds the web form)
        T3.3
          ↓
        T3.5
          └──────────────┴───────┘
                  ↓
           T3.7 (lockstep)
                  ↓
           T3.8 (audit + archive)

T3.4/T3.6 are independent of the T3.1→T3.2→T3.3→T3.5 chain and of each other; T3.5 additionally leans on T2.4’s orchestrator (already archived by the gate).

Threat model

  • Open String DTOs widen the injection surface — enum-backed serde rejection was a free input filter; String fields accept arbitrary bytes until the validator runs. Mitigation: D9 validators land IN THE SAME MR as each enum deletion (no window); T3.7 evil-corpus sweep proves uniform 400s; existing DTO length-cap lint ([4e/14]) already covers the new String fields.

  • Validator/registry drift — temp const lists could drift from registry contents between steps. Mitigation: temp validators live exactly one step each (T3.1→T3.2, T3.3→T3.5); inventory tests pin the token sets; both swaps are 400→400 behavior-preserving.

  • partner_type widening surprises an API consumer — T3.3 accepts 9 previously-rejected tokens. This ALIGNS the API with the DB + seed reality (rows with those tokens already exist and are served by GET/LIST); pre-1.0 posture per the pre-1.0 API-contract-stability guidance (no external integrators to break). Documented in the T3.3 cell + CHANGELOG.

  • CHECK drop opens a TOCTOU hole if boot validation is weaker than the constraint — direct-SQL writes bypass the handler. Mitigation: §1.3 dual-site design; T3.5’s direct-SQL sad-path test IS the closure proof (mirrors T2.4’s twin).

  • Required seed jurisdiction arg bricks devstack reseed — any invocation path missed = parse failure. Mitigation: research enumerated the SINGLE invocation path (compose env → seed.sh args); T3.6 updates it in the same MR; cargo xtask dev reseed smoke in verification.

  • T1/T2 execution deviates from the body specs this body cites — T3 authored before T1 execution begins (planning sprint). Mitigation: risk row 8; T3.1 opens only after T2 archived, so all drift is visible before execution; refresh is a small docs MR.

Cross-cutting invariants

Invariant Enforced by Verification

400→400 preserved (STATUS-level, per D9) across every validator swap

Temp validators land in the SAME MR as enum deletions; the T3.1 serde→handler move MAY change the problem-type wire shape (verified + CHANGELOG’d at T3.1); subsequent swaps (T3.2/T3.5) assert the problem-type stays stable from there

T3.1/T3.2/T3.3/T3.5 sad-path tests

Dual-site validation (§1.3): write-path handler + boot membership; DB CHECK retired

T3.2/T3.5 handler swaps + T2.4/T3.5 orchestrator checks

Direct-SQL TOCTOU sad-path tests (T3.5)

No federal-mapping completeness at boot (A7)

T3.5 boot check consults partner_types membership only; T3.4 seam is report-emit-shaped

Code review + A7 cite in both commits

"noop" never a registry entry (A3)

Inventory tests assert absent

T3.1 registry_inventory_matches_known_kinds

Typed ExchangeAdapter trait unchanged (Plan L invariant)

T3 touches kind carriage only

git diff review on crates/craig-exchange-contracts/src/lib.rs:175-215

No new code-level jurisdiction defaults (A8)

T3.6 clap arg REQUIRED; defaults live at compose/shell layer only

T3.6 parse-sad test

Quality budgets monotonic; axis coverage tagged

xtask validate [4i/14] + [4j/14]; STRUCTURAL-VALUE markers travel with moved code

Pre-push gate

Hash-pinned seed SQL stable (Plan Q Step 4)

T3.6 passes "georgia" in test ctors; hash change = regression not bless

SqlRow hash-pin battery

Risk register

# Risk Impact Likelihood Mitigation

1

A missed ExchangeAdapterKind consumer breaks compile post-deletion

T3.1 scope growth

Low

2026-06-09 inventory enumerated every site (contracts + exchange + partner-audit; seed/CLI/web already String); compiler is the backstop

2

partner-audit token conversion churns Plan L typed-audit tests

Test churn

Med

Discriminator tokens already match kind() strings; conversion is mechanical; round-trip tests pin tokens not enum variants

3

T3.3 14-token widening flagged as a regression in review

Re-litigation

Low

Cell documents the latent 5/14 API-DB mismatch + pre-1.0 posture; CHANGELOG entry

4

craig-reporting bundle-boot dep pulls partner crates into reporting’s build graph

Build-time growth

Low

craig-state-default deps are the 10 partner crates (default features, no axum); acceptable per ADR-032 consumption model; cargo tree check in T3.4 verification

5

Boot validation bricks devstack on stale partner_type rows

Dev friction

Low

Seeded tokens are 12 of the 14 registered; reseed in T3.5 verification; pre-1.0 destructive posture

6

Seed jurisdiction plumbing breaks E2E manifest determinism

E2E flake

Low

Jurisdiction only feeds SeedRateTable.jurisdiction today; manifest content unchanged for georgia; hash-pin battery is the gate

7

T3.7 evil-corpus sweep surfaces validator gaps late

Late rework

Med

That is its JOB — gaps found here are cheap (validators are 1-line registry lookups); audit-as-deliverable keeps the step honest if no gaps

8

Plan T1/T2 execution deviates from the body specs this body cites (registry shapes, resolve signature, orchestrator internals)

T3 body refresh

Med

T3 execution gated on T2 archive — all drift visible before T3.1 opens; refresh is a small docs MR; D-decisions + ADR anchors bound the drift

  • ADR-032 — §1.3 dual-site validation / open-TEXT carriage + §2.2 taxonomy opening + §2.3 FederalPartnerCategory + §2.4-as-amended (A7 report-emit-time) + §2.6-as-amended (A8 single production site; A9 env naming) + A10 hardcoded DefaultBundle.

  • ADR-038 — §3 registry materialization (T3.4 reporting boot follows).

  • ADR-030 — Status vocabulary.

  • Plan T umbrella — parent; this body fills Status row 5.

  • Plan T1 — T1.4 FederalPartnerCategory + T1.5 registries + T1.6 DefaultBundle federal_mapping consumed by T3.4; T1.2 trait untouched.

  • Plan T2 — T2.1 ExchangeRegistries (resolve seam T3.1 opens; federal_mapping field T3.4 consumes) + T2.4 boot-validation pattern T3.5 extends.

  • Plan S — grandparent; Plan T3 sits at Plan S Step 8 (body filed) / Step 9 (executed).

  • Plan L — typed ExchangeAdapter + PartnerAuditEvent invariants; T3.1 converts audit kind CARRIAGE only.

  • the pre-1.0 destructive-rebuild posture — T3.5 destructive constraint drop cite.

  • the pre-1.0 API-contract-stability guidance — T3.3 widening posture.

  • the audit-as-deliverable bias — T3.7 honest-outcome clause.

  • #558 (closed-aggregator) — unchanged by T3; audit registry misses stay fail-closed.

Edit this page · latest