Plan: canopy-test-lib world-class testing port (closes #436)
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Phase A MR A1 — pilot + cross-cutting primitives
- Step 2-4: Phase A rollout — MR A2 / A3 / A4
- Step 5: Phase B MR B1 — typed-client infrastructure + canopy-eligibility migration + time module + insta scaffolding
- Step 6: Phase B MRs B2–B16
- Step 7: Phase C — fault injection + observability assertions
- Step 8: Phase D — multi-replica fixture + ephemeral schema
- Step 9: Phase E — ephemeral-schema backfill
- Step 10: Plan archival + docs sweep
- Files Touched
- Branch + label hygiene
- CHANGELOG entries (one per phase)
- Verification
- Documentation Updates
- Pre-commit Q1-Q8 expectations (every MR)
- Risk + Rollback
- Open decisions revisited when Phase A lands
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 |
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 |
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 |
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 — |
Done (2026-05-15) — B2-B16 all shipped (!301, !304-!317). Phase B complete. |
7 |
Phase C — fault-injection harness + observability assertions. New |
Done (2026-05-15) — !318. |
8 |
Phase D — multi-replica fixture + per-test DB isolation. New |
Done (2026-05-16) — |
9 |
Phase E — backfill ephemeral-schema isolation to existing tests. Apply |
Done (2026-05-16) — three independent subagent audits converged: only 3 integration test files under |
10 |
Plan archival + docs sweep. Plan moves to |
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:53—TestClientthinreqwestwrapper with bearer-token auth andGET/post_json/put_json/deletetakingserde_json::Value. -
auth.rs:18— Keycloak token acquisition. -
infrastructure.rs—infrastructure_availabledevstack probe. -
mock.rs:69—MockBehaviour::{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/advancefor 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}.rsmodules. -
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 coveragextask command + GitLab CI gate + 60% baseline threshold. -
.claude/docs/testing.mdcomprehensive 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-evilCLI 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 bareUuid. -
Derive symmetry: every Request gets
Serialize+Deserialize; every Response getsSerialize+Deserialize. Current asymmetric service code (Requests areDeserialize-only, Responses areSerialize-only) becomes symmetric. -
Standard derives:
Debug, Clone, Serialize, Deserialize, utoipa::ToSchema. AddPartialEq, Eqonly when a test compares for equality (round-trip tests do compare → add for proptest scope). -
Path constants exposed under
pathsmodule; 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_dlxat 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 greppingqueue_declare\|basic_consumewithin the file. -
metrics.rs:spawn_dlq_depth_metrics(lines ~41-108) — DLQ-depth poller doesqueue_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 toEVENTS_EXCHANGEby 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).
-
Inventory
services/canopy-eligibility/src/orchestrator.rs:401-470— 5 DTOs to move. -
Create
crates/canopy-contracts-eligibility/per the template; lift DTOs; addSerializeto Requests /Deserializeto Responses;paths::DETERMINE = "/v1/eligibility/determine". -
Write
tests/roundtrip.rswith proptest round-trip tests for all 5 DTOs. -
Replace local DTO definitions in
orchestrator.rswithpub use canopy_contracts_eligibility::determine::*;. -
Update
services/canopy-eligibility/src/api/handlers.rs:41axum route to reference path constant. -
Add workspace member + workspace dependency.
-
Implement
xtask/src/cmd/coverage.rs+ register inxtask/src/main.rs. -
Add
coverageGitLab CI job. -
cargo build -p canopy-contracts-eligibility -p canopy-eligibility -p xtaskclean. -
cargo nextest run -p canopy-contracts-eligibilityclean (proptest tests pass). -
cargo xtask coverageruns (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).
-
Add
TestApiError+TestResponse::into_typedtoclient.rs. -
Create
crates/canopy-test-lib/src/clients/module family +EligibilityClient. -
Create
crates/canopy-test-lib/src/time.rswith paused-clock helpers. -
Wire
lib.rsre-exports. -
Migrate the 4 test files. Per call site: typed
EligibilityClient.determine(&req).await?+ typed assertion +insta::assert_yaml_snapshot!(…). -
Add
.insta.tomlconfig at workspace root if not present (redactions for UUIDs/timestamps). -
cargo nextest run -p canopy-eligibilitygreen; review snapshot files underservices/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.
Files Touched
| File group | Change |
|---|---|
|
New crates: DTOs lifted, |
|
|
|
Phase B: migrate to typed clients + add insta snapshots. Phase E: adopt |
|
Add contracts crate as dependency; add |
|
16 per-service typed-client modules. |
|
|
|
Paused-clock helpers. |
|
|
|
|
|
|
|
|
|
|
|
4+4 ignored devstack tests. |
|
+ |
|
|
|
Outbox routing honors |
|
|
|
Command registration. |
|
Coverage CI job. |
|
Reference PDFs for representative templates. |
|
+16 workspace members, +16 workspace deps, + |
|
Plan filed in MR A1; moves to archive after Phase E. |
|
5 |
|
Comprehensive testing-pattern guide. |
|
Insta config for redactions + snapshot review behavior. |
Existing utilities to reuse
-
crates/canopy-test-lib/src/client.rs:53—TestClient(typed clients wrap this). -
crates/canopy-test-lib/src/auth.rs:18—acquire_token/acquire_service_token. -
crates/canopy-test-lib/src/infrastructure.rs—infrastructure_available. -
crates/canopy-test-lib/src/mock.rs:35—MockHandle/spawn_router(evil_proxy mimics the spawn-router pattern). -
crates/canopy-test-lib/src/poll.rs—poll_until/wait_for_event. -
crates/canopy-common/src/id.rs:23—define_id!macro. -
crates/canopy-mq/src/subscriber.rs:88—replica_queue_name(multi-replica fixture sets matching env). -
services/canopy-security/src/store/mod.rs:109—verify_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 |
|
A2 |
|
A3 |
|
A4 |
|
B1 |
|
B2–B16 |
|
C |
|
D |
|
E1–E4 |
|
Final |
|
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
-
cargo build --workspaceclean. -
cargo clippy --all-targets — -D warningsclean. -
cargo nextest run --lib --workspace— no regressions. -
cargo fmt --check --allclean. -
cargo xtask docs plan-lintclean. -
cargo xtask coverage— coverage at or above baseline. -
Pre-push:
cargo xtask validate --skip-docker+cargo xtask seed+cargo xtask e2e --no-refreshpasses (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=== Changedentry 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,EvilLayervsMockBehaviourdecision tree. -
.insta.toml— workspace insta config (created in MR B1). -
xtask/README.md(if present) — documentcargo 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, zerounsafe, 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.mdupdates. -
Q8 — zero new TODO/FIXME tokens.
Risk + Rollback
-
Risk: Phase B’s insta snapshots churn excessively as DTO shapes settle. Mitigation:
.insta.tomlredactions for nondeterministic fields; reviewers gate on snapshot diffs the same as code diffs. -
Risk: Phase A’s
Serialize/Deserializesymmetry 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-servicecargo checkis the gate; if a trait bound breaks, fix at the service side. -
Risk: Per-test
EphemeralSchemais 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_PREFIXsource 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.rsis a moving target. Mitigation:clients/mod.rsextends incrementally per Phase B MR. Contracts crates land BEFORE their typed clients. Per-MR mechanical merge conflicts onclients/mod.rsare expected (every B-MR adds onepub 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:
EvilLayeraxum 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-typstcrate; 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_PREFIXsource change: small but real source change. If higher cost than expected, fall back tonextest --test-threads=1for 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.