Plan: canopy-test-lib world-class testing port (closes #436)

On this page
NOTE

Scope note: #436 as filed proposes a 4-phase CRAIG-pattern port (contracts crates, typed clients, fault injection, multi-replica fixture). This plan extends that with 7 world-class-testing adds (snapshot testing, proptest, time mocking, per-test DB isolation, PDF goldenfile, coverage gating, observability assertions) per user direction 2026-05-14. Per-MR scope grows but the phase boundaries are unchanged.

Status

Step Description Status

1

Phase A — MR A1 (pilot, canopy-eligibility) + cross-cutting primitives. Create crates/canopy-contracts-eligibility/; lift DetermineRequest/DetermineResponse/ProgramResult/MemberContext/ApplicationContext (services/canopy-eligibility/src/orchestrator.rs:401-470); add Serialize to Requests + Deserialize to Responses (current asymmetry); paths::DETERMINE = "/v1/eligibility/determine". World-class adds in this MR: (a) proptest scaffolding — crates/canopy-contracts-eligibility/tests/roundtrip.rs proves serde round-trip on every DTO; (b) cargo xtask coverage command wrapping cargo-llvm-cov + GitLab CI job + 60% baseline threshold gate.

Done (2026-05-14) — !296

2

Phase A — MR A2 (shared/leaf services). canopy-rules, canopy-persons, canopy-applications, canopy-verification. Same A1 pattern. Proptest round-trip per crate.

Done (2026-05-14) — !297

3

Phase A — MR A3 (program services). canopy-snap, canopy-tanf, canopy-medicaid, canopy-caps, canopy-wic. Each defines its OWN standalone ApplicationContext (per-program — Option 1 ratified at A3 start; see Design § Per-program ApplicationContext decision). Proptest round-trip per crate.

Done (2026-05-14) — !298

4

Phase A — MR A4 (downstream services). canopy-enrollment, canopy-renewals, canopy-notices, canopy-appeals, canopy-reporting, canopy-security. Proptest round-trip per crate.

Done (2026-05-14) — !299

5

Phase B — MR B1 (typed-client infrastructure + canopy-eligibility migration) + cross-cutting primitives. Add crates/canopy-test-lib/src/clients/{mod,eligibility}.rs; TestApiError + TestResponse::into_typed to client.rs; migrate 4 canopy-eligibility test files ({eligibility,envelope_roundtrip,pipeline,profile}_test.rs — 12 determine call sites + 4 helper-fn updates). World-class adds in this MR: (a) new crates/canopy-test-lib/src/time.rs — tokio time-pause helpers + axum-test integration; (b) insta snapshot testing scaffolded in canopy-eligibility’s migrated tests — assert_yaml_snapshot!(response) alongside typed assertions for shape-drift detection.

Done (2026-05-14) — !300

6

Phase B — MRs B2–B16 (per-service rollout). One MR per remaining service in order: canopy-snap, canopy-tanf, canopy-medicaid, canopy-caps, canopy-wic, canopy-persons, canopy-applications, canopy-enrollment, canopy-renewals, canopy-notices, canopy-appeals, canopy-reporting, canopy-security, canopy-rules, canopy-verification. Each adds typed client + insta snapshots + test migration. World-class add: B11 (canopy-notices) also wires PDF goldenfile testing — crates/canopy-test-lib/src/goldenfile.rs + reference PDFs in tests/notices/golden/ for a representative template subset. canopy-web + canopy-portal not included.

Done (2026-05-15) — B2-B16 all shipped (!301, !304-!317). Phase B complete.

7

Phase C — fault-injection harness + observability assertions. New crates/canopy-test-lib/src/evil.rs with EvilLayer + evil_proxy(). New crates/canopy-test-lib/src/observability.rs with assert_span_emitted / assert_metric_recorded primitives (tracing-test + opentelemetry-stdout sink). New crates/canopy-test-lib/tests/evil_proxy_test.rs with 4 chaos tests (inbox dedup @ 100% failure; eligibility circuit breaker; JWKS rotation; outbox catches up). Each chaos test also asserts the expected observability signals (retry spans, circuit-breaker-opened metric). All #[ignore = "…​"].

Done (2026-05-15) — !318. EvilLayer + evil_proxy + SpanCapture primitives + 4 #[ignore]’d chaos tests landed. `circuit_breaker instrumentation added in-MR; the other 3 production-side span targets (retry / jwks / outbox) tracked at #462 for follow-up.

8

Phase D — multi-replica fixture + per-test DB isolation. New crates/canopy-test-lib/src/multi_replica.rsMultiReplicaFixture::spawn(service, n) via tokio::process::Command::new("target/debug/{service}") (pre-built once). New crates/canopy-test-lib/src/db.rsephemeral_schema() helper that creates a randomized PG schema per test, scopes via SET search_path, drops at test end. New crates/canopy-test-lib/tests/multi_replica_test.rs with 4 invariant tests (inbox dedup race, hash chain, outbox SKIP LOCKED, SSE broadcast). All #[ignore = "…​"].

Done (2026-05-16) — MultiReplicaFixture + ReplicaHandle + spawn/kill_replica/restart_replica with CARGO_TARGET_DIR-honoring binary path resolution and 30s /healthz readiness gate; EphemeralSchema + define_ephemeral_schema_for! macro with 15 service constructors (canopy-verification has no migrations; will land if/when added); 4 #[ignore]’d invariant tests; `CANOPY_MQ_QUEUE_PREFIX plumbed through subscriber::{queue_prefix,replica_queue_name,dlq_queue_name,derive_dlx} with 6 prefix integration tests. Phase D structurally fixes the parallel-nextest devstack-state flake (disability_status_roundtrip / medicaid_tmsis_snapshot / etc.) once Phase E adopts the fixture across existing tests.

9

Phase E — backfill ephemeral-schema isolation to existing tests. Apply ephemeral_schema() to every integration test in services//tests/.rs that touches DB state. Mechanical sweep. One MR per service-group bundle (matching Phase A’s domain bundles).

Done (2026-05-16) — three independent subagent audits converged: only 3 integration test files under services/*/tests/ open direct Postgres pools (capability_flag_test.rs, orchestrator_dispatch_test.rs, fti_audit_hash_chain_test.rs). Rest of the suite goes through service HTTP APIs and doesn’t pattern-match "touches DB state" per the plan’s narrow reading at line 866 ("replaces direct cfg.{service}_db_url use"). Migrated all 3 to EphemeralSchema::new_for_{eligibility,tanf} in one combined MR rather than 4 mostly-empty bundle MRs. fti_audit_hash_chain test drops its pg_advisory_xact_lock(CHAIN_TEST_LOCK_ID=9998) + TRUNCATE fti_audit_log + teardown() helper — ephemeral-schema isolation makes them redundant; ADR-014’s production pg_advisory_xact_lock(2) inside PostgresFtiAuditLogger::log_access stays. Three additional structural fixes landed in this MR: (1) Phase D primitive hardenedEphemeralSchema::cleanup(self).await consuming method (synchronous DROP guarantee via mem::forget to suppress fallback Drop), sweep_orphans(base_url) static helper, schema-name generator switched from UUID v7 (timestamp-derived collisions exposed during testing) to UUID v4 (122 bits randomness), all 10 Phase E call sites now use explicit cleanup().await. 2 new integration tests pin the contract. (2) .ports.env reconcile — extracted reconcile_ports_env_and_heal_drift() helper in xtask/src/devstack_guard.rs and wired it into both auto_refresh (before the staleness early-return so it always runs) AND the cold-start path of ensure_ready. Found .ports.env had CANOPY_PORT_POSTGRES_5432=46255 while actual binding was 5432 — every test reading the env hit PoolTimedOut after 30s. THIS was the actual cause of every parallel-nextest "devstack-state" flake observed all session (612 → 1 failure improvement after one reconcile). (3) IdempotencyCache::with_pool CREATE INDEX race — diagnosis confirmed by 10 contextless subagent reviews (10/10 chose cross-test-race hypothesis: 3 tests in idempotency_persistence_test.rs under test-threads = 4 race on CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at because Postgres IF NOT EXISTS is not atomic with the create). Fix: wrap DDL in pg_advisory_xact_lock(IDEMPOTENCY_DDL_LOCK_ID=9999) inside a transaction (sqlx::migrate! approach rejected because every service already runs its own sqlx::migrate! against the shared _sqlx_migrations table — layering a second migrate! from canopy-api breaks startup with "migration X was previously applied but is missing in the resolved migrations"). Final validate: all 15 checks pass in 724s (1725 nextest + 14 idempotency tests + 136 Playwright E2E green).

10

Plan archival + docs sweep. Plan moves to plans/archive/. .claude/docs/testing.md updated comprehensively: contracts-crate convention, typed-client examples, snapshot pattern, proptest pattern, time-mocking pattern, fault-injection pattern, multi-replica pattern, ephemeral-schema pattern, goldenfile pattern, observability assertion pattern. CHANGELOG === Changed entry per phase (5 entries — Phase A, B, C, D, E).

Not started

Issue: #436
Branch root: feat/canopy-test-lib-port-{a1…​a4, b1…​b16, c, d, e1…​e4, archive} (one per MR; see Branch + label table)
Plan repo location: docs/modules/ROOT/pages/plans/canopy-test-lib-port.adoc (AsciiDoc; created in MR A1)
Origin: External review 2026-05-09; world-class scope expansion 2026-05-14

Context

What we have today

crates/canopy-test-lib/:

  • client.rs:53TestClient thin reqwest wrapper with bearer-token auth and GET/post_json/put_json/delete taking serde_json::Value.

  • auth.rs:18 — Keycloak token acquisition.

  • infrastructure.rsinfrastructure_available devstack probe.

  • mock.rs:69MockBehaviour::{happy, with_delay, always_fail, tampered}; spawn_mock_persons.

  • poll, rules, scheduler, threshold_sync — narrow utility modules.

100+ tests in services/*/tests/ build request bodies via c.post_json("…​", &serde_json::json!({…})). Renaming a field in DetermineRequest at services/canopy-eligibility/src/orchestrator.rs:403 doesn’t break the test compile — surfaces at runtime.

What we document but don’t test

  • ADR-014 audit hash chain via pg_advisory_xact_lock(1) (services/canopy-security/src/store/mod.rs:46)

  • ADR-018 outbox FOR UPDATE SKIP LOCKED (crates/canopy-mq/src/outbox_drainer.rs:101)

  • #433 inbox dedup across redelivery and replicas

  • #458 SSE broadcast fan-out via replica_queue_name (crates/canopy-mq/src/subscriber.rs:88)

  • Circuit-breaker behavior (crates/canopy-api/src/circuit_breaker.rs)

  • JWKS rotation retry under unknown-kid (crates/canopy-auth/src/jwks.rs)

  • Time-sensitive logic across canopy-renewals (certification periods), canopy-tanf (federal time limits), canopy-mq (retry backoffs), canopy-auth (JWT exp), canopy-scheduler (tick cadence), canopy-notices (advance-notice math)

Why now

Tier 1 production hardening complete (#438, #435, #437+#433, #456, #457, #458). UAT 4 months out (September 2026). External review 2026-05-09 flagged the CRAIG-pattern gap. The 7 world-class adds beyond CRAIG’s pattern address gaps that are independent of CRAIG but no-brainer for a system that issues signed determinations workers and households rely on (proptest for eligibility computation; insta for response-shape stability; time mocking for the pervasive time-sensitive logic; per-test DB isolation to remove devstack-state coupling; PDF goldenfile for notice templates; coverage gates; observability assertions).

Intended outcome

After Phase E lands:

  • Tests import typed Request/Response structs; contract drift breaks compilation.

  • Every test call site uses {Service}Client.{operation}(req).

  • Every response assertion has an insta snapshot guard against shape drift.

  • DTO serde round-trips have proptest coverage.

  • Time-sensitive logic tests use tokio::time::pause/advance for determinism.

  • Every integration test runs in an ephemeral schema — no devstack state coupling.

  • canopy-notices template churn caught by PDF goldenfile diff.

  • CI gates PRs on coverage threshold.

  • Chaos + multi-replica tests verify the documented invariants and assert the expected observability signals.

Scope

In scope:

  • 16 new per-service crates/canopy-contracts-{service}/ crates with proptest round-trip tests.

  • New crates/canopy-test-lib/src/{clients,time,goldenfile,evil,multi_replica,db,observability}.rs modules.

  • Mechanical migration of every serde_json::json!-bodied test call to typed clients + insta snapshots (~150-200 call sites).

  • 4 chaos-property tests; 4 multi-replica invariant tests; both also assert observability signals.

  • PDF goldenfile coverage for canopy-notices representative templates.

  • Phase E ephemeral-schema sweep across existing integration tests (~12-15 MRs).

  • New cargo xtask coverage xtask command + GitLab CI gate + 60% baseline threshold.

  • .claude/docs/testing.md comprehensive update.

  • CHANGELOG entries (5 — one per phase) + plan archival per ADR-013.

Out of scope:

  • OpenAPI-generated clients (handled by #352 once Phase A’s contracts crates exist).

  • External-SDK shape (productisation; separable).

  • CRAIG’s craig-evil CLI tool (chaos setup lives in test bodies).

  • WebSocket + SSE upgrades in evil_proxy (JSON only).

  • Mutation testing (cargo-mutants). Powerful but slow; file separately if desired post-#436.

  • Coverage-guided fuzzing (cargo-fuzz). ATO-driven; file separately for Pub 1075 §9 evidence.

  • Realistic load-test workload generator. Separate from correctness testing; tests/k6/smoke.js is the seed.

  • canopy-web and canopy-portal contracts crates (HTML-rendering BFFs).

Design

Contracts crates (Phase A)

Per-service crate layout:

crates/canopy-contracts-{service}/
    Cargo.toml          # See template below
    src/
        lib.rs          # pub mod {operation_family}; pub mod paths;
        {family}.rs     # Request/Response/sub-DTOs for one operation family
        paths.rs        # pub const {OPERATION}: &str = "/v1/...";
    tests/
        roundtrip.rs    # proptest serde round-trip for every DTO

Cargo.toml template (per contracts crate):

# SPDX-License-Identifier: AGPL-3.0-or-later

[package]
name = "canopy-contracts-{service}"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
canopy-common = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }      # only if any DTO carries serde_json::Value
chrono = { workspace = true }          # only if any DTO carries DateTime / NaiveDate
rust_decimal = { workspace = true }    # only if any DTO carries Decimal
utoipa = { workspace = true }

[dev-dependencies]
proptest = { workspace = true }
serde_json = { workspace = true }      # round-trip tests serialise to/from JSON

Workspace Cargo.toml additions (MR A1 adds the first 4 lines; subsequent A-bundle MRs add their entries):

[workspace]
members = [
    # … existing members …
    "crates/canopy-contracts-eligibility",   # A1
    "crates/canopy-contracts-rules",         # A2
    "crates/canopy-contracts-persons",       # A2
    "crates/canopy-contracts-applications",  # A2
    "crates/canopy-contracts-verification",  # A2
    "crates/canopy-contracts-snap",          # A3
    "crates/canopy-contracts-tanf",          # A3
    "crates/canopy-contracts-medicaid",      # A3
    "crates/canopy-contracts-caps",          # A3
    "crates/canopy-contracts-wic",           # A3
    "crates/canopy-contracts-enrollment",    # A4
    "crates/canopy-contracts-renewals",      # A4
    "crates/canopy-contracts-notices",       # A4
    "crates/canopy-contracts-appeals",       # A4
    "crates/canopy-contracts-reporting",     # A4
    "crates/canopy-contracts-security",      # A4
]

[workspace.dependencies]
# … existing deps …
canopy-contracts-eligibility = { path = "crates/canopy-contracts-eligibility" }
canopy-contracts-rules = { path = "crates/canopy-contracts-rules" }
# … one entry per contracts crate, added in the same MR that adds the crate …

# Cross-cutting dev-deps added per-MR:
# - MR A1 adds: proptest (used by Phase A round-trip tests)
# - MR B1 adds: insta (snapshot testing from Phase B onwards)
# - MR D adds: paste (only needed by the define_ephemeral_schema_for! macro)
proptest = "1"
insta = { version = "1", features = ["yaml", "redactions", "filters"] }
paste = "1"

canopy-verification contracts crate caveat: services/canopy-verification has no #[utoipa::path] annotations — its routes are internal service-to-service (3 endpoints across IEVS/SAVE/SSA). The DTOs at services/canopy-verification/src/api/{ssa,ievs,save}.rs ARE called by tests in other services though, so the contracts crate has value even without OpenAPI exposure. Bundled into Phase A2 (shared/leaf services).

Constraints (apply to every contracts crate):

  • NO axum, sqlx. Pure data.

  • IDs from canopy-common::id::define_id! (crates/canopy-common/src/id.rs:23) — never bare Uuid.

  • Derive symmetry: every Request gets Serialize + Deserialize; every Response gets Serialize + Deserialize. Current asymmetric service code (Requests are Deserialize-only, Responses are Serialize-only) becomes symmetric.

  • Standard derives: Debug, Clone, Serialize, Deserialize, utoipa::ToSchema. Add PartialEq, Eq only when a test compares for equality (round-trip tests do compare → add for proptest scope).

  • Path constants exposed under paths module; values are the FULL post-axum-mount path. Verify per-service.

Mirror-struct pattern for dual-purpose sqlx::FromRow types (ratified in MR A2, 2026-05-14):

Some pre-A2 wire DTOs (Application, Person, Income, Asset, RuleEvaluation, …) double as sqlx::FromRow-decorated row types in their service’s store layer. The "NO sqlx" rule on contracts crates would break sqlx::query_as::<_, T>(…​) call sites if the wire type were lifted naively.

Resolution: contracts crate holds the pure wire T (no sqlx::FromRow); service-side store/models.rs (or domain.rs) holds a mirror TRow struct with the sqlx::FromRow derive and identical fields, plus a field-by-field impl From<TRow> for T. Store fns query_as::<_, TRow> and project to T at the return:

let rows = sqlx::query_as::<_, ApplicationRow>("SELECT …").fetch_all(pool).await?;
Ok(rows.into_iter().map(Application::from).collect())

Why mirror, not feature-flag: the conversion fn is the single canonical projection from DB shape to API shape. A reader who needs to know whether a sensitive DB column ever leaves the persistence boundary has one file to read (From<TRow> for T). Pub 1075 FTI handling, HIPAA, and ADR-008 applicant-portal field-subsetting all benefit from making this projection visible. A #[cfg_attr(feature = "sqlx", derive(FromRow))] shortcut would hide the projection inside a feature flag, breaking the architectural property the contracts-crate boundary is supposed to provide. The plan’s "NO sqlx" constraint stands and the mirror struct is how it stands.

Acid test for whether a wire type needs a *Row mirror: does any store layer call sqlx::query_as::<_, T>(…​) against it? If yes → mirror. If no (pure request/response/params DTOs) → lift directly to the contracts crate, no mirror needed.

Per-program ApplicationContext decision (ratified in MR A3, 2026-05-14):

Each program service (canopy-snap, canopy-tanf, canopy-medicaid, canopy-caps, canopy-wic) has its own ApplicationContext struct as the request body for POST /v1/determine. The five shapes already diverge significantly today: canopy-medicaid carries 20+ Phase A-F boolean gates (Pickle / DAC / Disabled Widow / Q-Track / TMA / waiver / institutional) that no other program needs; canopy-snap carries SUA / categorical / alien eligibility extensions; canopy-tanf carries deprivation type + applicant_person_id; canopy-caps carries child age / special needs / provider_id; canopy-wic carries participant_category / adjunctive_program / nutritional_risk_documented.

Resolution: each program contracts crate defines its OWN standalone ApplicationContext reflecting that program’s actual wire shape. No base type in canopy-contracts-eligibility is reused; no #[serde(flatten)] extension pattern. The canopy-eligibility orchestrator’s ApplicationContext is a SEPARATE type (the orchestrator-side view, lifted in A1) — it overlaps in fields but is not byte-identical.

Why standalone over base+extension: a shared base struct would require either (a) a massive union of every program’s fields (defeating per-program type safety; canopy-snap could accidentally accept a Q-Track field), or (b) a per-program Extension struct flattened in — same maintenance cost as standalone with extra indirection. The standalone approach preserves wire bytes verbatim (zero risk during the lift) and lets each program’s contract evolve at its own pace.

Future refactor path: if a meaningful base shape emerges (e.g. multiple programs grow the same field at the same time), the base lives in canopy-contracts-eligibility and each program crate `#[serde(flatten)]`s its remaining program-specific fields. Decision deferred until concrete duplication appears.

Proptest round-trip pattern (tests/roundtrip.rs in each contracts crate):

use canopy_contracts_eligibility::determine::DetermineRequest;
use proptest::prelude::*;

proptest! {
    #[test]
    fn determine_request_serde_roundtrip(req in arb_determine_request()) {
        let json = serde_json::to_string(&req).expect("serialize");
        let parsed: DetermineRequest = serde_json::from_str(&json).expect("deserialize");
        prop_assert_eq!(req, parsed);
    }
}

fn arb_determine_request() -> impl Strategy<Value = DetermineRequest> {
    (
        any::<[u8; 16]>().prop_map(|bytes| ApplicationId(Uuid::from_bytes(bytes))),
        any::<[u8; 16]>().prop_map(|bytes| HouseholdId(Uuid::from_bytes(bytes))),
        prop::collection::vec("snap|tanf|medicaid|caps|wic", 1..5),
        any::<String>(),
    ).prop_map(|(application_id, household_id, programs, requested_by)| DetermineRequest {
        application_id, household_id, programs, requested_by,
    })
}

One arbitrary-generator function per DTO. Proptest finds edge cases hand-written tests miss (boundary lengths, unicode in strings, empty collections).

Nested-DTO pattern: when a Response/Request contains another DTO (e.g. DetermineResponse.programs_approved: Vec<ProgramResult>), the parent’s generator delegates to the child’s:

fn arb_program_result() -> impl Strategy<Value = ProgramResult> {
    (
        "snap|tanf|medicaid|caps|wic",
        "approved|denied|pending",
        prop::option::of(any::<i64>().prop_map(|n| Decimal::from(n / 100))),
        prop::option::of(any::<String>()),
    ).prop_map(|(program, status, benefit_amount, basis)| ProgramResult {
        program: program.into(), status: status.into(), benefit_amount, basis,
    })
}

fn arb_determine_response() -> impl Strategy<Value = DetermineResponse> {
    (
        any::<[u8; 16]>().prop_map(|bytes| EligibilityRequestId(Uuid::from_bytes(bytes))),
        any::<[u8; 16]>().prop_map(|bytes| ApplicationId(Uuid::from_bytes(bytes))),
        prop::collection::vec(arb_program_result(), 0..5),
        prop::collection::vec(arb_program_result(), 0..5),
        prop::collection::vec(arb_program_result(), 0..5),
        any::<i64>().prop_map(|n| Decimal::from(n / 100)),
        // ISO-8601 timestamp; restrict to a stable range to avoid year-9999 nondeterminism
        ("[12][0-9]{3}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]Z".prop_map(String::from)),
    ).prop_map(/* … construct DetermineResponse … */)
}

For DTOs with serde_json::Value payloads, use prop::sample::select over a small set of canonical shapes — proptest can’t generate arbitrary JSON safely without bounding tree depth.

Typed clients (Phase B)

Per-service client (crates/canopy-test-lib/src/clients/{service}.rs):

// SPDX-License-Identifier: AGPL-3.0-or-later

use canopy_contracts_eligibility::determine::{DetermineRequest, DetermineResponse};
use canopy_contracts_eligibility::paths;
use crate::client::{TestClient, TestApiError};

pub struct EligibilityClient {
    inner: TestClient,
}

impl EligibilityClient {
    pub fn new(base_url: &str) -> Self { Self { inner: TestClient::new(base_url) } }
    pub fn with_token(mut self, token: String) -> Self { self.inner = self.inner.with_token(token); self }

    /// Canonical handler: services/canopy-eligibility/src/api/handlers.rs:determine
    pub async fn determine(
        &self,
        req: &DetermineRequest,
    ) -> Result<DetermineResponse, TestApiError> {
        self.inner.post_json(paths::DETERMINE, req).await.into_typed()
    }
}

Supporting additions to client.rs (Phase B MR B1):

#[derive(Debug)]
pub struct TestApiError {
    pub status: u16,
    pub body: String,
}

impl TestResponse {
    pub fn into_typed<T: serde::de::DeserializeOwned>(self) -> Result<T, TestApiError> {
        if (200..300).contains(&self.status) {
            serde_json::from_slice(&self.body).map_err(|e| TestApiError {
                status: self.status,
                body: format!("deserialise failed: {e} — body: {}", String::from_utf8_lossy(&self.body)),
            })
        } else {
            Err(TestApiError { status: self.status, body: String::from_utf8_lossy(&self.body).into_owned() })
        }
    }
}

Insta snapshot pattern (added to typed-client tests in every Phase B MR):

let resp = client.determine(&DetermineRequest { /* … */ }).await.expect("determine");
assert_eq!(resp.programs_approved.len(), 1);   // typed structural assertion
insta::assert_yaml_snapshot!("determine_snap_approved", resp);  // shape-drift guard

Snapshots land in services/{service}/tests/snapshots/ next to the test file (insta’s default convention). cargo insta accept updates after intended schema changes; cargo insta review audits.

Time-mocking primitive (crates/canopy-test-lib/src/time.rs, new in Phase B MR B1):

//! Deterministic clock control for time-sensitive tests.
//!
//! Tests that exercise scheduler ticks, retry backoffs, certification
//! periods, JWT expiry, advance-notice math, etc. should pause real
//! time and advance it manually. Avoid `tokio::time::sleep` in tests.

pub async fn pause() { tokio::time::pause().await }
pub async fn advance(d: std::time::Duration) { tokio::time::advance(d).await }

/// Run a future to completion while time is paused, advancing the
/// clock each time the future yields. Useful for testing retry
/// loops without sleeping real time.
pub async fn run_paused_advancing<F: Future>(fut: F, step: std::time::Duration) -> F::Output {
    pause().await;
    tokio::pin!(fut);
    loop {
        tokio::select! {
            v = &mut fut => return v,
            _ = tokio::time::sleep(step) => advance(step).await,
        }
    }
}

tokio::time::pause requires the runtime to be configured with start_paused = true; tests using this module use [tokio::test(start_paused = true)] instead of [tokio::test].

Fault-injection harness + observability assertions (Phase C)

EvilLayer + evil_proxy (crates/canopy-test-lib/src/evil.rs):

use std::ops::Range;
use std::time::Duration;

pub struct EvilLayer { /* composition state */ }

impl EvilLayer {
    pub fn new() -> Self;
    pub fn with_latency_jitter(self, range: Range<Duration>) -> Self;
    pub fn with_failure_rate(self, p: f64) -> Self;     // probability of synthesized 503
    pub fn drop_connection_after(self, n: u32) -> Self;
    pub fn tamper_payload<F>(self, f: F) -> Self
        where F: Fn(&mut serde_json::Value) + Send + Sync + 'static;
}

pub struct EvilProxyHandle {
    pub url: String,    // 127.0.0.1:{dynamic_port}
    _task: tokio::task::JoinHandle<()>,
}

pub fn evil_proxy(target_url: &str, layer: EvilLayer) -> EvilProxyHandle;

evil_proxy is an axum reverse proxy on 127.0.0.1:0 with a wildcard catch-all that forwards to the target URL via reqwest::Client, applying the configured layer per-request. Tests construct {Service}Client::new(&handle.url) instead of the real service URL.

Observability assertions (crates/canopy-test-lib/src/observability.rs, new in Phase C):

use std::sync::{Arc, Mutex};

/// Capture-and-assert helpers for tests verifying that production
/// code emits the tracing spans + metrics operators rely on.

pub struct SpanCapture { /* tracing-subscriber test layer */ }

impl SpanCapture {
    pub fn install() -> Self;
    pub fn assert_span_emitted(&self, name: &str);
    pub fn assert_span_field(&self, span_name: &str, field: &str, value: &str);
    pub fn drain(self) -> Vec<CapturedSpan>;
}

pub struct MetricCapture { /* opentelemetry-stdout sink */ }

impl MetricCapture {
    pub fn install() -> Self;
    pub fn assert_metric_recorded(&self, name: &str);
    pub fn assert_metric_value(&self, name: &str, expected: f64);
}

Chaos tests use these to verify retries emit the expected spans, circuit breakers record the expected counter increments. Without observability assertions, "did the system observably do X" goes untested — a runbook signal regression is invisible to the existing test suite.

Implementation skeleton:

tracing::subscriber::set_global_default is a process-global one-shot — the second test that calls it silently no-ops, so all subsequent tests capture into the first test’s buffer. Use tracing::subscriber::with_default(subscriber, || { …​ }) to scope the subscriber to a closure, OR have callers hold a per-test DefaultGuard from tracing::subscriber::set_default (which IS scoped, returning a guard that resets on drop).

// crates/canopy-test-lib/src/observability.rs
use std::sync::{Arc, Mutex};
use tracing::{Event, Subscriber};
use tracing::subscriber::DefaultGuard;
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;

#[derive(Clone)]
pub struct SpanCapture {
    events: Arc<Mutex<Vec<CapturedEvent>>>,
}

#[derive(Debug, Clone)]
pub struct CapturedEvent {
    pub target: String,
    pub level: tracing::Level,
    pub message: String,
    pub fields: std::collections::HashMap<String, String>,
}

impl SpanCapture {
    /// Install a per-test tracing subscriber. Returns a guard the caller
    /// MUST hold for the test scope; dropping the guard restores the
    /// previous default. Use:
    ///
    ///     let (capture, _guard) = SpanCapture::install_scoped();
    ///     /* … run code under test … */
    ///     capture.assert_span_emitted("event_process");
    ///
    /// Tests MUST run with `#[tokio::test(flavor = "current_thread")]`.
    /// On a multi-threaded runtime, `set_default` is thread-local — the
    /// subscriber doesn't reach work-stealing tasks on other threads.
    pub fn install_scoped() -> (Self, DefaultGuard) {
        let events = Arc::new(Mutex::new(Vec::new()));
        let layer = SpanCaptureLayer { events: events.clone() };
        let subscriber = tracing_subscriber::Registry::default().with(layer);
        let guard = tracing::subscriber::set_default(subscriber);
        (Self { events }, guard)
    }

    pub fn assert_span_emitted(&self, name: &str) {
        let events = self.events.lock().unwrap();
        let found = events.iter().any(|e| e.target == name || e.message.contains(name));
        assert!(found, "expected span '{name}' not emitted; events: {events:#?}");
    }

    pub fn assert_field(&self, span_name: &str, field: &str, value: &str) { /* … */ }
}

struct SpanCaptureLayer { events: Arc<Mutex<Vec<CapturedEvent>>> }

impl<S: Subscriber> Layer<S> for SpanCaptureLayer {
    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
        let mut visitor = FieldCollector::default();
        event.record(&mut visitor);
        self.events.lock().unwrap().push(CapturedEvent {
            target: event.metadata().target().to_string(),
            level: *event.metadata().level(),
            message: visitor.message.unwrap_or_default(),
            fields: visitor.fields,
        });
    }
}

// MetricCapture follows the same pattern — opentelemetry-stdout's
// in-memory exporter wrapped in a per-test scope guard.

Concurrency caveat: even with set_default (thread-local), the per-test subscriber doesn’t propagate to tasks scheduled on OTHER worker threads. [tokio::test(flavor = "current_thread")] is required for SpanCapture-using tests to keep all task execution on the same thread. Multi-threaded runtimes (default [tokio::test]) silently miss captures from work-stealing tasks.

Multi-replica fixture + per-test DB isolation (Phase D)

MultiReplicaFixture (crates/canopy-test-lib/src/multi_replica.rs):

pub struct MultiReplicaFixture {
    service: String,
    replicas: Vec<ReplicaHandle>,
    queue_prefix: String,
}

pub struct ReplicaHandle {
    pub port: u16,
    pub replica_id: String,
    process: tokio::process::Child,
}

impl MultiReplicaFixture {
    pub async fn spawn(service: &str, n: usize) -> Result<Self, FixtureError>;
    pub fn client_for<C: TypedClient>(&self, idx: usize) -> C;
    pub async fn kill_replica(&mut self, idx: usize);
    pub async fn restart_replica(&mut self, idx: usize) -> Result<(), FixtureError>;
}

Process model: pre-built binary via cargo build --bin {service} (once at fixture startup), exec via tokio::process::Command::new("target/debug/{service}") per replica. Per-replica env:

CANOPY_{SERVICE_UPPER}__PORT={free_port}   # canopy-common settings overlay (no source change)
CANOPY_MQ_REPLICA_ID={service}-replica-{idx}
CANOPY_MQ_QUEUE_PREFIX={random_prefix}

{SERVICE_UPPER} is the service slug uppercased and de-hyphenated — e.g. CANOPY_ELIGIBILITY__PORT for canopy-eligibility (per crates/canopy-common/src/settings.rs env-overlay rules). No new env var; uses the existing per-service port-override pattern.

Binary path: target/debug/{service} is the default. If CARGO_TARGET_DIR env is set (common in CI runners), the binary lives at ${CARGO_TARGET_DIR}/debug/{service} instead. Fixture spawn code must honor CARGO_TARGET_DIR if set:

let target = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
let binary = format!("{target}/debug/{service}");

CANOPY_MQ_QUEUE_PREFIX source-change locations — new env var prepended to every queue NAME a service declares. The prefix only matters where queue names are constructed; the topic exchange routes by routing-key (not by queue name), so publisher / outbox-drainer paths are NOT affected.

Sites to update in crates/canopy-mq/src/:

  • subscriber.rs:replica_queue_name (line ~88) — wrap return: format!("{prefix}{queue}", prefix = std::env::var("CANOPY_MQ_QUEUE_PREFIX").unwrap_or_default()).

  • subscriber.rs:dlq_queue_name (line ~64) — same prefix so the auto-derived DLQ (derive_dlx at subscriber.rs:51) wires to the prefixed DLQ.

  • subscriber.rs:subscribe_broadcast, subscribe_durable, and any other queue-declare site — apply prefix uniformly. Audit by grepping queue_declare\|basic_consume within the file.

  • metrics.rs:spawn_dlq_depth_metrics (lines ~41-108) — DLQ-depth poller does queue_declare(passive=true) on DLQ names; if the queue is prefixed but the metric poller isn’t, the passive-declare fails. Same prefix applied.

Sites that do NOT change:

  • publisher.rs — publishes to EVENTS_EXCHANGE by routing-key; topic exchange routes to whatever queues are bound, independent of queue name.

  • outbox_drainer.rs — same as publisher (publishes to exchange).

Default behavior preserved: empty prefix means no change. Add unit test in crates/canopy-mq/tests/queue_prefix_test.rs verifying the prefix applies. Document the env var in .claude/docs/testing.md and canopy-mq’s crate-level rustdoc.

Per-test DB isolation (crates/canopy-test-lib/src/db.rs, new in Phase D):

Multi-service migration constraint: sqlx::migrate! is a compile-time macro taking a string literal — cannot be parameterized at runtime. Solution: one constructor per service, each calling sqlx::migrate! with the service’s migration path. A macro generates these uniformly:

// crates/canopy-test-lib/src/db.rs

//! Per-test schema isolation. Each test creates a unique Postgres schema,
//! scopes connections via `SET search_path`, and drops the schema at end.

pub struct EphemeralSchema {
    pub name: String,            // e.g. "test_a3f9b2"
    pool: sqlx::PgPool,
    base_url: String,            // for DROP SCHEMA cleanup
}

impl EphemeralSchema {
    pub fn pool(&self) -> &sqlx::PgPool { &self.pool }
}

impl Drop for EphemeralSchema {
    fn drop(&mut self) {
        let name = self.name.clone();
        let base_url = self.base_url.clone();
        // Schema drop runs in a detached task using the admin connection;
        // we cannot await inside Drop. Best-effort cleanup; periodic devstack
        // refresh sweeps any survivors.
        tokio::spawn(async move {
            if let Ok(admin) = sqlx::PgPool::connect(&base_url).await {
                let _ = sqlx::query(&format!("DROP SCHEMA {name} CASCADE")).execute(&admin).await;
            }
        });
    }
}

// Internal constructor that the per-service macro expands to:
async fn create_schema(base_url: &str, name: &str) -> Result<sqlx::PgPool, sqlx::Error> {
    let admin = sqlx::PgPool::connect(base_url).await?;
    sqlx::query(&format!("CREATE SCHEMA {name}")).execute(&admin).await?;
    let url = format!("{base_url}?options=-c%20search_path%3D{name}");
    sqlx::PgPool::connect(&url).await
}

#[macro_export]
macro_rules! define_ephemeral_schema_for {
    ($service:ident, $migrations:expr) => {
        impl $crate::db::EphemeralSchema {
            paste::paste! {
                pub async fn [<new_for_ $service>](base_url: &str) -> Result<Self, sqlx::Error> {
                    let suffix = uuid::Uuid::now_v7().to_string().replace('-', "");
                    let name = format!("test_{}", &suffix[..12]);
                    let pool = $crate::db::create_schema(base_url, &name).await?;
                    sqlx::migrate!($migrations).run(&pool).await?;
                    Ok(Self {
                        name,
                        pool,
                        base_url: base_url.to_string(),
                    })
                }
            }
        }
    };
}

// One-line per service (lives in crates/canopy-test-lib/src/db_constructors.rs):
define_ephemeral_schema_for!(eligibility, "../../services/canopy-eligibility/migrations");
define_ephemeral_schema_for!(snap,        "../../services/canopy-snap/migrations");
// … 14 more …

Path-resolution gotcha: sqlx::migrate! resolves its path argument relative to the calling crate’s CARGO_MANIFEST_DIR. For crates/canopy-test-lib/, the relative form ../../services/canopy-X/migrations should resolve to the workspace’s services/canopy-X/migrations. MR D verifies this with one cargo check -p canopy-test-lib before fleshing out all 16 constructors — if path resolution fails, fall back to concat!(env!("CARGO_MANIFEST_DIR"), "/../../services/canopy-X/migrations") form. Doing this for one service first is the safe sequence.

Call site (in a service’s integration test):

let schema = EphemeralSchema::new_for_eligibility(&cfg.eligibility_db_url).await?;
let pool = schema.pool();   // pool scoped to the ephemeral schema
// run test work; schema drops on test scope exit

Phase D’s multi-replica fixture uses these constructors; Phase E backfills the pattern to existing integration tests.

Snapshot testing (Phase B)

insta crate as workspace dev-dep. Pattern (used in every Phase B MR’s migrated tests):

let resp = client.determine(&req).await?;
insta::with_settings!({
    filters => vec![
        // UUID: case-insensitive to match both serde's default (lowercase) and any
        // upstream service that emits uppercase. UUID v7 IDs in canopy serialize
        // as standard 8-4-4-4-12 hex per canopy-common/src/id.rs.
        (r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", "[uuid]"),
        (r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})", "[timestamp]"),
    ]
}, {
    insta::assert_yaml_snapshot!("determine_snap_approved", resp);
});

Audit crates/canopy-common/src/id.rs once in MR B1 to confirm the serialization format matches the regex (current define_id! uses Uuid::Display which is lowercase 8-4-4-4-12 — regex matches).

Snapshot files land under services/{service}/tests/snapshots/ and are checked into git. Reviewers see snapshot diffs in MR review; cargo insta accept updates after intended schema changes.

Workspace addition (Cargo.toml):

[workspace.dependencies]
insta = { version = "1", features = ["yaml", "redactions", "filters"] }

Workspace .insta.toml (new at repo root, created in MR B1):

[behavior]
# Tests fail unmatched snapshots rather than auto-creating (CI safety).
auto_review = "no"
auto_accept_unseen = false

[diff]
# Use the standard diff format; reviewers see snapshot changes in MR diffs.

Per-call filters (UUID, timestamp) handle per-test nondeterminism. Add per-call filters as new DTO fields introduce nondeterminism.

PDF goldenfile testing (Phase B canopy-notices MR)

crates/canopy-test-lib/src/goldenfile.rs:

//! Byte-comparison goldenfile testing for deterministic outputs
//! (canopy-notices PDFs today; future use cases land here too).

pub fn assert_matches_golden(actual: &[u8], golden_path: &Path) {
    // Hard guard: in CI, UPDATE_GOLDEN must not be set. Catches a
    // developer's shell-rc leak that would silently accept stale goldens.
    if std::env::var("CI").is_ok() && std::env::var("UPDATE_GOLDEN").is_ok() {
        panic!(
            "UPDATE_GOLDEN must not be set in CI \
             (would silently accept template changes without review)"
        );
    }

    if std::env::var("UPDATE_GOLDEN").is_ok() {
        std::fs::write(golden_path, actual).expect("write golden");
        return;
    }
    let expected = std::fs::read(golden_path).expect("read golden");
    if actual != expected.as_slice() {
        let diff_path = golden_path.with_extension("actual");
        std::fs::write(&diff_path, actual).expect("write actual");
        panic!(
            "goldenfile mismatch: {golden_path:?}\nactual written to {diff_path:?}\nset UPDATE_GOLDEN=1 to accept"
        );
    }
}

canopy-notices test fixture: render a representative template subset (initial: SNAP approval, SNAP denial, expedited SNAP), compare PDF bytes to services/canopy-notices/tests/notices/golden/{template_name}.pdf. Catches accidental template churn — manifest tracks template version, goldenfile closes the loop.

CI behavior: UPDATE_GOLDEN env unset in CI (default). If a test fails goldenfile comparison in CI, the test panics with the diff message and writes the actual bytes to {name}.actual for debugging. The CI runner does NOT have write access to commit golden updates; the developer accepts updates locally with UPDATE_GOLDEN=1 cargo nextest run then commits the new bytes.

Coverage tracking + CI gate (Phase A MR A1)

Baseline measurement first: before setting the threshold in xtask coverage, MR A1 runs cargo llvm-cov --workspace --summary-only --json > .coverage-baseline.json once and records the current line-coverage percent in the MR description. The threshold is baseline - 2% to start (small slack for transient CI flake). Per-MR delta-tightening is a future concern — recorded as an Open decision.

xtask/src/cmd/coverage.rs:

pub async fn run(args: CoverageArgs) -> anyhow::Result<()> {
    let threshold = args.threshold.unwrap_or(58.0);  // baseline - 2%, set after A1 baseline measurement
    let status = tokio::process::Command::new("cargo")
        .args([
            "llvm-cov", "--workspace", "--summary-only",
            "--fail-under-lines", &threshold.to_string(),
        ])
        .status().await?;
    if !status.success() { anyhow::bail!("coverage below {threshold}% threshold"); }
    Ok(())
}

GitLab CI addition (.gitlab-ci.yml):

coverage:
  stage: test
  script:
    - cargo install cargo-llvm-cov --locked   # cache via GitLab job artifacts
    - cargo xtask coverage
  rules:
    - if: $CI_MERGE_REQUEST_IID
  coverage: '/^TOTAL.*\s(\d+\.\d+)%\s/'        # GitLab regex parses coverage from output

Per-PR drop limit (cargo-llvm-cov supports baseline-comparison via --baseline): document as a follow-up after Phase A ships and we have multiple data points.

Steps

Step 1: Phase A MR A1 — pilot + cross-cutting primitives

Files: crates/canopy-contracts-eligibility/ (new), crates/canopy-contracts-eligibility/tests/roundtrip.rs (new), xtask/src/cmd/coverage.rs (new), xtask/src/main.rs (+command), .gitlab-ci.yml (+job), services/canopy-eligibility/src/orchestrator.rs (DTOs move), services/canopy-eligibility/src/api/handlers.rs (path constant), workspace Cargo.toml (+member +dep), Cargo.toml workspace dev-deps (+proptest +insta if not already).

  1. Inventory services/canopy-eligibility/src/orchestrator.rs:401-470 — 5 DTOs to move.

  2. Create crates/canopy-contracts-eligibility/ per the template; lift DTOs; add Serialize to Requests / Deserialize to Responses; paths::DETERMINE = "/v1/eligibility/determine".

  3. Write tests/roundtrip.rs with proptest round-trip tests for all 5 DTOs.

  4. Replace local DTO definitions in orchestrator.rs with pub use canopy_contracts_eligibility::determine::*;.

  5. Update services/canopy-eligibility/src/api/handlers.rs:41 axum route to reference path constant.

  6. Add workspace member + workspace dependency.

  7. Implement xtask/src/cmd/coverage.rs + register in xtask/src/main.rs.

  8. Add coverage GitLab CI job.

  9. cargo build -p canopy-contracts-eligibility -p canopy-eligibility -p xtask clean.

  10. cargo nextest run -p canopy-contracts-eligibility clean (proptest tests pass).

  11. cargo xtask coverage runs (records baseline %).

Acceptance: existing canopy-eligibility integration tests still pass (axum routes unchanged in behavior); proptest round-trips green; cargo xtask coverage exits 0 with a recorded baseline.

Step 2-4: Phase A rollout — MR A2 / A3 / A4

Mechanical repeat of Step 1’s pattern across the bundles. Per-MR acceptance: contracts crate compiles + proptest tests pass + service compiles + existing integration tests pass + workspace coverage ≥ baseline.

Step 5: Phase B MR B1 — typed-client infrastructure + canopy-eligibility migration + time module + insta scaffolding

Files: crates/canopy-test-lib/src/clients/{mod,eligibility}.rs (new), crates/canopy-test-lib/src/client.rs (`TestApiError` +`into_typed`), `crates/canopy-test-lib/src/time.rs` (new), `crates/canopy-test-lib/src/lib.rs` (+modules +re-exports), `crates/canopy-test-lib/Cargo.toml` (insta, +tokio time-pause feature), services/canopy-eligibility/tests/{eligibility,envelope_roundtrip,pipeline,profile}_test.rs (migrate 34 call sites + add insta snapshots).

  1. Add TestApiError + TestResponse::into_typed to client.rs.

  2. Create crates/canopy-test-lib/src/clients/ module family + EligibilityClient.

  3. Create crates/canopy-test-lib/src/time.rs with paused-clock helpers.

  4. Wire lib.rs re-exports.

  5. Migrate the 4 test files. Per call site: typed EligibilityClient.determine(&req).await? + typed assertion + insta::assert_yaml_snapshot!(…​).

  6. Add .insta.toml config at workspace root if not present (redactions for UUIDs/timestamps).

  7. cargo nextest run -p canopy-eligibility green; review snapshot files under services/canopy-eligibility/tests/snapshots/.

Acceptance: zero serde_json::json! in request-body positions across the 4 migrated files; every response assertion paired with an insta snapshot.

Step 6: Phase B MRs B2–B16

One MR per remaining service in the order listed in Status row 6. B11 (canopy-notices) additionally adds crates/canopy-test-lib/src/goldenfile.rs + reference PDFs.

Step 7: Phase C — fault injection + observability assertions

Files: crates/canopy-test-lib/src/evil.rs (new), crates/canopy-test-lib/src/observability.rs (new), crates/canopy-test-lib/tests/evil_proxy_test.rs (new), crates/canopy-test-lib/Cargo.toml (+axum, +tracing-subscriber/test, +opentelemetry-stdout).

Step 8: Phase D — multi-replica fixture + ephemeral schema

Files: crates/canopy-test-lib/src/multi_replica.rs (new), crates/canopy-test-lib/src/db.rs (new), crates/canopy-test-lib/tests/multi_replica_test.rs (new), crates/canopy-mq/src/subscriber.rs (+CANOPY_MQ_QUEUE_PREFIX in replica_queue_name), crates/canopy-mq/src/publisher.rs (+queue-prefix in outbox routing).

Step 9: Phase E — ephemeral-schema backfill

4 MRs matching Phase A bundles:

  • E1: canopy-eligibility, canopy-rules, canopy-persons, canopy-applications, canopy-verification

  • E2: canopy-snap, canopy-tanf, canopy-medicaid

  • E3: canopy-caps, canopy-wic, canopy-enrollment, canopy-renewals

  • E4: canopy-notices, canopy-appeals, canopy-reporting, canopy-security

Per-MR: every integration test in the bundle’s services that touches DB state replaces direct cfg.{service}_db_url use with EphemeralSchema::new_for_{service}(…​) (the constructor defined in Phase D — Phase E is purely mechanical application of the already-existing constructor, no new test-lib code). Tests that don’t touch DB state stay unchanged.

Step 10: Plan archival + docs sweep

mv docs/modules/ROOT/pages/plans/canopy-test-lib-port.adoc plans/archive/. .claude/docs/testing.md comprehensive update. CHANGELOG finalised.

Files Touched

File group Change

crates/canopy-contracts-{service}/ × 16

New crates: DTOs lifted, Serialize/Deserialize symmetry, paths module, proptest round-trip tests.

services//src/api/.rs, src/orchestrator.rs

pub use canopy_contracts_*::*; re-exports.

services//tests/.rs (all integration tests)

Phase B: migrate to typed clients + add insta snapshots. Phase E: adopt EphemeralSchema.

services/*/Cargo.toml × 16

Add contracts crate as dependency; add insta as dev-dep.

crates/canopy-test-lib/src/clients/ (new)

16 per-service typed-client modules.

crates/canopy-test-lib/src/client.rs

TestApiError + TestResponse::into_typed.

crates/canopy-test-lib/src/time.rs (new)

Paused-clock helpers.

crates/canopy-test-lib/src/evil.rs (new)

EvilLayer + evil_proxy.

crates/canopy-test-lib/src/observability.rs (new)

SpanCapture + MetricCapture.

crates/canopy-test-lib/src/multi_replica.rs (new)

MultiReplicaFixture.

crates/canopy-test-lib/src/db.rs (new)

EphemeralSchema.

crates/canopy-test-lib/src/goldenfile.rs (new)

assert_matches_golden.

crates/canopy-test-lib/tests/{evil_proxy,multi_replica}_test.rs (new)

4+4 ignored devstack tests.

crates/canopy-test-lib/Cargo.toml

+insta, +axum, +tracing-subscriber/test, +opentelemetry-stdout, every contracts crate (incremental per Phase B MR).

crates/canopy-mq/src/subscriber.rs

replica_queue_name honors CANOPY_MQ_QUEUE_PREFIX.

crates/canopy-mq/src/publisher.rs

Outbox routing honors CANOPY_MQ_QUEUE_PREFIX.

xtask/src/cmd/coverage.rs (new)

cargo xtask coverage.

xtask/src/main.rs

Command registration.

.gitlab-ci.yml

Coverage CI job.

services/canopy-notices/tests/notices/golden/ (new)

Reference PDFs for representative templates.

Cargo.toml

+16 workspace members, +16 workspace deps, +insta, +proptest.

docs/modules/ROOT/pages/plans/canopy-test-lib-port.adoc (new)

Plan filed in MR A1; moves to archive after Phase E.

CHANGELOG.adoc

5 === Changed entries (one per phase).

.claude/docs/testing.md

Comprehensive testing-pattern guide.

.insta.toml (new at workspace root)

Insta config for redactions + snapshot review behavior.

Existing utilities to reuse

  • crates/canopy-test-lib/src/client.rs:53TestClient (typed clients wrap this).

  • crates/canopy-test-lib/src/auth.rs:18acquire_token / acquire_service_token.

  • crates/canopy-test-lib/src/infrastructure.rsinfrastructure_available.

  • crates/canopy-test-lib/src/mock.rs:35MockHandle / spawn_router (evil_proxy mimics the spawn-router pattern).

  • crates/canopy-test-lib/src/poll.rspoll_until / wait_for_event.

  • crates/canopy-common/src/id.rs:23define_id! macro.

  • crates/canopy-mq/src/subscriber.rs:88replica_queue_name (multi-replica fixture sets matching env).

  • services/canopy-security/src/store/mod.rs:109verify_chain (hash-chain test reuses for assertion).

Branch + label hygiene

Scoped labels per .claude/CLAUDE.md#gitlab-labels (authoritative; the .claude/docs/gitlab-workflow.md flat-labels table is out of sync). All MRs: type::feature, priority::medium, service::shared-crates, program::infrastructure, workflow::in-review. Final archive MR: type::chore, priority::low, …​.

MR Branch

A1

feat/canopy-test-lib-port-a1-eligibility-and-primitives

A2

feat/canopy-test-lib-port-a2-shared-crates

A3

feat/canopy-test-lib-port-a3-program-services

A4

feat/canopy-test-lib-port-a4-downstream

B1

feat/canopy-test-lib-port-b1-clients-infra-time-insta

B2–B16

feat/canopy-test-lib-port-b{n}-{service}

C

feat/canopy-test-lib-port-c-evil-and-observability

D

feat/canopy-test-lib-port-d-multi-replica-and-ephemeral-schema

E1–E4

feat/canopy-test-lib-port-e{n}-ephemeral-schema-backfill-{bundle}

Final

chore/canopy-test-lib-port-archive

Only the final archive MR carries Closes #436. Intermediate MRs use Step N of #436.

CHANGELOG entries (one per phase)

5 === Changed entries — Phase A, B, C, D, E. Shape mirrors !290/!292/!293; concrete example below for Phase A.

=== Changed

* *Per-service contracts crates + proptest round-trip + coverage gate (closes #436 Phase A).*
  Lifts every service's public Request/Response DTOs from `services/*/src/api/*.rs`
  and `src/orchestrator.rs` into dedicated `crates/canopy-contracts-{service}/` crates.
  Services depend on their own contracts crate and `pub use` the types so internal
  references compile unchanged; contract drift between service and test now breaks
  compilation rather than surfacing as runtime 400 Bad Request.
+
*Per-crate proptest round-trip tests* prove serde symmetry on every DTO; previously
asymmetric service code (Requests Deserialize-only, Responses Serialize-only) becomes
symmetric.
+
*New `cargo xtask coverage` command* wraps cargo-llvm-cov + GitLab CI gate at 60%
baseline threshold. PRs that drop coverage below the threshold fail CI. Threshold
ratchets up as Phase B-E land more tests.
+
*Crates added*: canopy-contracts-{eligibility, rules, persons, applications,
verification, snap, tanf, medicaid, caps, wic, enrollment, renewals, notices,
appeals, reporting, security}. canopy-web + canopy-portal excluded (HTML-rendering
BFFs).
+
*ADR-001 dependency direction respected*: program services do not depend on each
other's contracts crates. canopy-eligibility depends on every program service's
contracts crate as the orchestrator. Program-service contracts crates depend on
`canopy-contracts-eligibility` for `MemberContext` / `ApplicationContext`.

Verification

Per-MR

  1. cargo build --workspace clean.

  2. cargo clippy --all-targets — -D warnings clean.

  3. cargo nextest run --lib --workspace — no regressions.

  4. cargo fmt --check --all clean.

  5. cargo xtask docs plan-lint clean.

  6. cargo xtask coverage — coverage at or above baseline.

  7. Pre-push: cargo xtask validate --skip-docker + cargo xtask seed + cargo xtask e2e --no-refresh passes (136 Playwright tests).

Phase acceptance

Phase A (after MR A4): 16 contracts crates compile; proptest round-trip tests pass for each; every service depends on its contracts crate; grep -rn 'pub use canopy_contracts_' services/*/src/ returns ≥1 hit per service; coverage baseline recorded.

Phase B (after MR B16): grep -rn 'serde_json::json!' services//tests/.rs zero hits in request-body positions; every typed client exists; every migrated test has insta snapshots under services/{service}/tests/snapshots/; canopy-notices has goldenfile coverage for representative templates; crates/canopy-test-lib/src/time.rs exists and is used by ≥1 test that exercises time-sensitive logic deterministically.

Phase C: 4 chaos tests pass under --run-ignored only; each chaos test also asserts ≥1 observability signal (span emitted, metric recorded); EvilLayer composable layers exercised.

Phase D: 4 multi-replica invariant tests pass under --run-ignored only; EphemeralSchema works (concurrent test runs don’t cross-contaminate); CANOPY_MQ_QUEUE_PREFIX source change in canopy-mq is tested.

Phase E: every existing DB-touching integration test under services/*/tests/ uses EphemeralSchema; concurrent cargo nextest run --workspace is reliably clean (no devstack-state coupling).

Sanity smoke (after Phase E)

Chaos-multi-replica scenario: 3 canopy-eligibility replicas via MultiReplicaFixture, EligibilityClient through evil_proxy at 30% failure, 100 determine requests, fixture.kill_replica(0) mid-flight. Assertions: no domain-state corruption, inbox 100 rows (no duplicates), audit chain intact via verify_chain, outbox fully drained, observability — eligibility_determine_circuit_open metric increments past threshold, retry spans emitted.

Documentation Updates

  • docs/modules/ROOT/pages/plans/canopy-test-lib-port.adoc — filed in MR A1.

  • CHANGELOG.adoc — one === Changed entry per phase (5 total).

  • .claude/docs/testing.md — comprehensive update covering: contracts-crate convention, typed-client pattern, insta snapshot pattern, proptest pattern, time-mocking pattern, fault-injection pattern, observability-assertion pattern, multi-replica fixture pattern, ephemeral-schema pattern, goldenfile pattern, EvilLayer vs MockBehaviour decision tree.

  • .insta.toml — workspace insta config (created in MR B1).

  • xtask/README.md (if present) — document cargo xtask coverage.

  • Plan moves to plans/archive/ after Phase E.

Pre-commit Q1-Q8 expectations (every MR)

  • Q1 — every Phase A MR adds proptest round-trip tests; Phase B MRs add insta snapshots; Phase C/D add new ignored devstack tests.

  • Q2 — zero unwrap() outside tests, zero unsafe, zero new #[allow(…​)].

  • Q3 — zero #[ignore] without rationale; zero test deletions.

  • Q4 — deviations update the .adoc Design/Scope.

  • Q5 — only final archive MR closes #436.

  • Q6 — out-of-scope items stay deferred.

  • Q7 — per-phase CHANGELOG + plan Status row update + incremental .claude/docs/testing.md updates.

  • Q8 — zero new TODO/FIXME tokens.

Risk + Rollback

  • Risk: Phase B’s insta snapshots churn excessively as DTO shapes settle. Mitigation: .insta.toml redactions for nondeterministic fields; reviewers gate on snapshot diffs the same as code diffs.

  • Risk: Phase A’s Serialize/Deserialize symmetry change breaks a service that depends on the current asymmetry (e.g. relies on Request types being Deserialize-only at the trait-bound level). Mitigation: per-service cargo check is the gate; if a trait bound breaks, fix at the service side.

  • Risk: Per-test EphemeralSchema is slow if every test runs a full migration sweep. Mitigation: cache the migrated schema as a template; CREATE SCHEMA … LIKE TEMPLATE … copy is fast. If still too slow, fall back to txn-rollback isolation for tests that don’t span transactions.

  • Risk: CANOPY_MQ_QUEUE_PREFIX source change in canopy-mq breaks production deployments that don’t set it. Mitigation: default to empty string (current behavior preserved); only test-injected values change queue names.

  • Risk: coverage gate at 60% fails MRs that legitimately reduce coverage (e.g. removing dead code drops the denominator). Mitigation: threshold is a floor, not a strict per-PR delta — adjust if pattern emerges.

  • Risk: contract-crate API instability during Phase A2-A4 partial rollout. clients/mod.rs is a moving target. Mitigation: clients/mod.rs extends incrementally per Phase B MR. Contracts crates land BEFORE their typed clients. Per-MR mechanical merge conflicts on clients/mod.rs are expected (every B-MR adds one pub use clients::{Service}Client; line) — implementer rebases the simple line-add on each MR.

  • Risk: Phase D’s process-spawning hits build-lock contention with concurrent cargo builds. Mitigation: pre-build once at fixture startup; exec from target/debug/{service} per replica.

  • Risk: EvilLayer axum proxy is heavier than expected. Mitigation: Phase C scope is JSON; explicit out-of-scope on WebSocket/SSE.

  • Risk: PDF goldenfile diff churn (Typst version updates change output bytes). Mitigation: pin Typst version in canopy-typst crate; goldenfile MR-bundle each Typst upgrade.

  • Risk: observability-assertion helpers couple tests to internal span/metric names; refactor pain. Mitigation: span/metric names are part of the operational contract (runbooks reference them); coupling is intentional. If a refactor changes them, the test failure is the signal that the runbook needs updating too.

  • Rollback: revert the offending MR. Each phase’s MRs are independent; partial-phase rollback leaves the workspace consistent.

Open decisions revisited when Phase A lands

  • Phase C chaos-test count: starting at 4. May scale to 6-8 if Phase A surfaces fault modes worth covering.

  • Phase D process model: separate OS processes is the starting bet; fallback to in-process tokio tasks if heavyweight.

  • CANOPY_MQ_QUEUE_PREFIX source change: small but real source change. If higher cost than expected, fall back to nextest --test-threads=1 for the multi-replica suite.

  • Coverage threshold: starts at 60%. Ratchet decision per phase.

  • Pilot service choice: canopy-eligibility per the issue. canopy-rules or canopy-persons are smaller leaf alternatives — eligibility wins on cross-service contract exercise.

  • OpenAPI generation: out of scope; #352 builds on Phase A’s contracts crates.

  • Mutation testing: out of scope; file separately if desired post-#436.

  • Fuzzing: out of scope; file separately for Pub 1075 §9 evidence if needed.

Edit this page · default