Plan: Deployment Profiles and Event Bus Wiring
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add Docker Compose deployment profiles per ADR-005 |
Done (2026-04-19) |
2 |
Implement |
Done (2026-04-19) |
3 |
Implement capability flags for optional services in canopy-eligibility |
Done (2026-04-19) — (verified by adr-005-graceful-degradation-verification: 7 capability-flag tests prove every program lands in |
4 |
Wire event publishers in canopy-snap and canopy-eligibility |
Done (2026-04-19) |
5 |
Wire event subscribers in canopy-notices, canopy-enrollment, canopy-security |
Done (2026-04-19) |
6 |
Integration tests for profile-aware startup and event flow |
Done (2026-04-19) |
Epic: &35
Branch: feature/deployment-profiles-event-wiring
Labels: type::feature, priority::high, program::infrastructure, service::devstack, service::xtask, workflow::ready
Context
Two foundational architectural features remain unimplemented despite being specified in ADRs and referenced by every domain plan:
Deployment profiles (ADR-005): The ADR specifies Docker Compose profiles (snap-only, tanf-only, snap-tanf, medicaid-chip, caps-only, wic-only, full) so that any jurisdiction can deploy only the program services it needs.
Currently, only the isolated-db profile exists.
A SNAP-only UAT deployment currently requires starting all 19 services — there is no way to start only the SNAP-relevant subset.
The cargo xtask dev start --profile snap-only command specified in ADR-005 does not exist; xtask only supports --shared-db.
Event bus wiring: Every domain plan specifies events that services publish and subscribe to (e.g., determination.completed, enrollment.snap_issued, abawd.warning_month_1).
The RabbitMQ infrastructure (canopy-mq crate, canopy.events topic exchange) is implemented and working.
However, the events.rs files in canopy-snap and canopy-eligibility are still skeleton stubs: "Skeleton — add events as routes are implemented."
No events are actually published, meaning the event-driven architecture described in all plans is not operational.
canopy-notices, canopy-enrollment, and canopy-security cannot react to domain events until publishers are wired.
These two gaps are blocking: 1. UAT environment setup (cannot deploy snap-only without profiles) 2. Cross-service integration (no service reacts to another’s state changes without events)
Scope
In scope:
-
Docker Compose
profiles:keys on all services per ADR-005 Section 2 -
cargo xtask dev start --profile <name>flag implementation -
Capability flags:
CANOPY_TANF_URL,CANOPY_MEDICAID_URL,CANOPY_EXCHANGE_URLenv vars in canopy-eligibility orchestrator; skip calls to absent optional services -
Graceful degradation: required-to-optional service calls return
Noneinstead of circuit breaker failure when URL is not configured -
Wire
EventPublisher::publish()calls in canopy-snapevents.rsfor:determination.completed,abawd.warning_month_1,abawd.warning_month_2,abawd.time_limit_reached -
Wire
EventPublisher::publish()calls in canopy-eligibilityevents.rsfor:determination.completed(combined result) -
Wire
EventPublisher::publish()calls in canopy-enrollmentevents.rsfor:enrollment.snap_issued,enrollment.expungement_pending,enrollment.benefits_expunged -
Wire event subscribers in canopy-notices for:
determination.completed,abawd.warning_month_1,abawd.warning_month_2,enrollment.expungement_pending -
Wire event subscribers in canopy-enrollment for:
determination.completed(approved → create enrollment) -
Verify: all event payloads contain only IDs, status codes, timestamps — no PII, FTI, income, or SSN per ADR-004
Out of scope:
-
New service implementations (all services already exist as stubs or implementations)
-
TANF/Medicaid/CAPS/WIC event flows (their services are stubs; events will be wired when implemented)
-
canopy-portal deployment profile (post-UAT)
-
Event schema versioning (future concern; current events are v1)
Dependencies
-
canopy-mq crate (implemented —
EventPublisherandEventSubscriberexist) -
ADR-005 (accepted — defines the profile taxonomy)
-
ADR-004 (accepted — defines event payload restrictions)
Design
Docker Compose profiles
Per ADR-005 Section 2, each service declares which profiles it belongs to. A profile represents a deployable subset of services.
| Profile | Services included |
|---|---|
|
postgres, postgres-snap, rabbitmq, keycloak, garage, canopy-rules, canopy-persons, canopy-applications, canopy-eligibility, canopy-verification, canopy-enrollment, canopy-renewals, canopy-notices, canopy-appeals, canopy-reporting, canopy-security, canopy-snap, canopy-web |
|
postgres, postgres-tanf, rabbitmq, keycloak, garage, canopy-rules, canopy-persons, canopy-applications, canopy-eligibility, canopy-verification, canopy-notices, canopy-appeals, canopy-security, canopy-tanf, canopy-web |
|
postgres, postgres-medicaid, rabbitmq, keycloak, garage, canopy-rules, canopy-persons, canopy-applications, canopy-eligibility, canopy-verification, canopy-notices, canopy-appeals, canopy-security, canopy-medicaid, canopy-exchange, canopy-web |
|
All services |
Implementation: add profiles: [snap-only, full] (etc.) to each service’s docker-compose.yml entry.
Services that appear in all profiles (postgres, rabbitmq, keycloak, garage, canopy-rules, canopy-persons, canopy-applications, canopy-eligibility, canopy-security, canopy-web) are tagged with all profiles.
xtask --profile flag
// SPDX-License-Identifier: AGPL-3.0-or-later
// In xtask/src/cmd/dev.rs, extend Args:
#[derive(Debug, clap::Parser)]
pub struct Args {
#[command(subcommand)]
pub action: Action,
/// Use a single shared PostgreSQL instance instead of per-program isolated databases.
#[arg(long)]
pub shared_db: bool,
/// Docker Compose profile to activate (snap-only, tanf-only, medicaid-chip, full).
/// Defaults to "full" if not specified.
#[arg(long, default_value = "full")]
pub profile: String,
}
The start action passes the profile to docker compose --profile {profile} up -d.
Validate that profile is one of the known values; error with a helpful message if not.
Capability flags in canopy-eligibility
The orchestrator in services/canopy-eligibility/src/orchestrator.rs currently calls all program services unconditionally.
Wrap optional service calls behind URL checks:
// SPDX-License-Identifier: AGPL-3.0-or-later
// In the orchestrator's dispatch loop:
for program in &request.programs {
match program {
Program::Snap => {
// Required service — always call
let det = snap_client.determine(&input).await?;
results.push(det);
}
Program::Tanf => {
if let Some(url) = &config.tanf_url {
let det = tanf_client.determine(&input).await?;
results.push(det);
} else {
tracing::info!(program = "tanf", "Service not configured; skipping");
results.push(DeterminationResult::not_configured(Program::Tanf));
}
}
// ... same pattern for Medicaid, Caps, Wic
}
}
Environment variables:
* CANOPY_SNAP_URL — required (no default; fail if missing when SNAP requested)
* CANOPY_TANF_URL — optional (skip if not set)
* CANOPY_MEDICAID_URL — optional
* CANOPY_CAPS_URL — optional
* CANOPY_WIC_URL — optional
* CANOPY_EXCHANGE_URL — optional
Event publisher wiring
Replace skeleton events.rs files with actual EventPublisher::publish() calls.
All event payloads must comply with ADR-004: IDs, status codes, and timestamps only.
canopy-snap events:
// determination.completed (routing key: determination.completed.snap)
{
"determination_id": "uuid",
"household_id": "uuid",
"application_id": "uuid",
"program": "snap",
"status": "approved",
"determined_at": "2026-07-15T14:30:00Z"
}
// abawd.warning_month_1 (routing key: abawd.warning_month_1)
{
"person_id": "uuid",
"household_id": "uuid",
"months_used": 1,
"program": "snap"
}
// abawd.warning_month_2
{ "person_id": "uuid", "household_id": "uuid", "months_used": 2, "program": "snap" }
// abawd.time_limit_reached
{ "person_id": "uuid", "household_id": "uuid", "months_used": 3, "program": "snap" }
canopy-enrollment events:
// enrollment.snap_issued
{ "enrollment_id": "uuid", "household_id": "uuid", "benefit_month": "2026-07-01" }
// enrollment.expungement_pending
{ "enrollment_id": "uuid", "household_id": "uuid", "issuance_id": "uuid", "expiry_date": "2027-07-15" }
// enrollment.benefits_expunged
{ "enrollment_id": "uuid", "household_id": "uuid", "issuance_id": "uuid", "benefit_month": "2026-07-01" }
Verification: No income, benefit_amount, ssn, address, or any PII field appears in any event payload.
Event subscriber wiring
canopy-notices: Subscribe to determination.completed.snap, abawd.warning_month_1, abawd.warning_month_2, enrollment.expungement_pending.
On receipt, call the appropriate NoticeGenerator method to create and store the notice.
canopy-enrollment: Subscribe to determination.completed.snap where status = "approved".
On receipt, create enrollment record and trigger initial benefit issuance.
canopy-security: Already subscribes to # (wildcard). Verify it persists the new event types to audit_events.
Steps
Step 1: Docker Compose deployment profiles
Files: docker-compose.yml
Add profiles: key to every service definition.
Tag each service with its membership per the Design table.
Infrastructure services (postgres, rabbitmq, keycloak, garage) belong to all profiles.
Test: docker compose --profile snap-only config outputs only SNAP-relevant services.
Step 2: xtask --profile flag
Files: xtask/src/cmd/dev.rs
Add --profile argument to the Args struct.
Pass to docker compose --profile {profile} in the start and stop commands.
Add profile validation (reject unknown profiles with a helpful error listing valid options).
Test: cargo xtask dev start --profile snap-only starts only SNAP services.
Step 3: Capability flags in canopy-eligibility
Files: services/canopy-eligibility/src/orchestrator.rs, services/canopy-eligibility/src/config.rs (or main.rs)
Read CANOPY_TANF_URL, CANOPY_MEDICAID_URL, etc. from environment.
Wrap optional service calls in if let Some(url) guards.
Add DeterminationResult::not_configured(program) variant for skipped programs.
Test: start with only CANOPY_SNAP_URL set; request determination for SNAP+TANF; verify SNAP result returned, TANF result is not_configured.
Step 4: Wire event publishers
Files: services/canopy-snap/src/events.rs, services/canopy-eligibility/src/events.rs, services/canopy-enrollment/src/events.rs
Replace skeleton comments with actual publisher.publish(routing_key, &payload) calls.
Wire publishers into the domain handlers that produce state changes:
- canopy-snap/src/api/determine_handler.rs → publish determination.completed.snap after successful determination
- canopy-snap/src/abawd.rs → publish abawd.warning_month_* and abawd.time_limit_reached
- canopy-enrollment/src/issuance.rs → publish enrollment.snap_issued after benefit issuance
- canopy-enrollment/src/expungement.rs → publish enrollment.expungement_pending and enrollment.benefits_expunged
Step 5: Wire event subscribers
Files: services/canopy-notices/src/events.rs, services/canopy-notices/src/main.rs, services/canopy-enrollment/src/events.rs, services/canopy-enrollment/src/main.rs
Create queue bindings and message handlers:
- canopy-notices: bind to determination.completed.snap, abawd.warning_month_1, abawd.warning_month_2, enrollment.expungement_pending
- canopy-enrollment: bind to determination.completed.snap (status=approved only)
Wire subscribers as background Tokio tasks in each service’s main.rs.
On handler error: log at ERROR, NACK with requeue.
Step 6: Integration tests
Files: services/canopy-eligibility/tests/profile_test.rs (new), services/canopy-snap/tests/event_test.rs (new)
Test scenarios:
1. Orchestrator with only SNAP URL configured: SNAP determination succeeds, TANF returns not_configured
2. Orchestrator with SNAP + TANF URLs: both determinations attempted
3. canopy-snap determination → determination.completed.snap event published to RabbitMQ (verify with test consumer)
4. ABAWD month 2 → abawd.warning_month_2 event published
5. Event payload verification: confirm no PII/FTI/income fields in any published event
6. docker compose --profile snap-only config includes only expected services (shell test in xtask)
Files Touched
| File | Change |
|---|---|
|
Add |
|
Add |
|
Wrap optional service calls behind URL capability flags |
|
Add optional URL fields for each program service |
|
Replace skeleton with actual event publishing calls |
|
Replace skeleton with actual event publishing calls |
|
Wire event publishing for issuance and expungement |
|
Wire event subscriber handlers |
|
Spawn subscriber background task |
|
Spawn subscriber background task for determination.completed |
Verification
-
docker compose --profile snap-only config --services— lists only SNAP-relevant services (not canopy-tanf, canopy-medicaid, etc.) -
cargo xtask dev start --profile snap-only— starts and all SNAP services reach healthy state -
cargo xtask dev start --profile full— starts all services (backwards-compatible) -
Orchestrator with
CANOPY_TANF_URLunset: TANF determination request returnsnot_configured, no error -
canopy-snap determination produces
determination.completed.snapevent in RabbitMQ (verify via management UI or test consumer) -
canopy-notices receives
determination.completed.snapand creates a notice record -
All event payloads: grep for
income,ssn,amount,addressin published JSON — zero matches -
cargo nextest run --workspace— all existing + new tests pass -
cargo clippy --workspace — -D warnings— zero warnings
Documentation Updates
-
.claude/CLAUDE.md— note deployment profiles implemented; update event wiring status -
.claude/docs/services.md— add event routing keys per service -
.claude/docs/local-dev.md— documentcargo xtask dev start --profile snap-only -
.claude/docs/architecture.md— update deployment profiles section with profile list -
CHANGELOG.adoc— entry under== Unreleased