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 cargo xtask dev start --profile flag

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 programs_pending with basis "program service not configured" when its URL is absent)

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_URL env vars in canopy-eligibility orchestrator; skip calls to absent optional services

  • Graceful degradation: required-to-optional service calls return None instead of circuit breaker failure when URL is not configured

  • Wire EventPublisher::publish() calls in canopy-snap events.rs for: determination.completed, abawd.warning_month_1, abawd.warning_month_2, abawd.time_limit_reached

  • Wire EventPublisher::publish() calls in canopy-eligibility events.rs for: determination.completed (combined result)

  • Wire EventPublisher::publish() calls in canopy-enrollment events.rs for: 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 — EventPublisher and EventSubscriber exist)

  • 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

snap-only

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

tanf-only

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

medicaid-chip

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

full

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

docker-compose.yml

Add profiles: key to all service definitions

xtask/src/cmd/dev.rs

Add --profile argument, pass to docker compose

services/canopy-eligibility/src/orchestrator.rs

Wrap optional service calls behind URL capability flags

services/canopy-eligibility/src/config.rs

Add optional URL fields for each program service

services/canopy-snap/src/events.rs

Replace skeleton with actual event publishing calls

services/canopy-eligibility/src/events.rs

Replace skeleton with actual event publishing calls

services/canopy-enrollment/src/events.rs

Wire event publishing for issuance and expungement

services/canopy-notices/src/events.rs

Wire event subscriber handlers

services/canopy-notices/src/main.rs

Spawn subscriber background task

services/canopy-enrollment/src/main.rs

Spawn subscriber background task for determination.completed

Verification

  1. docker compose --profile snap-only config --services — lists only SNAP-relevant services (not canopy-tanf, canopy-medicaid, etc.)

  2. cargo xtask dev start --profile snap-only — starts and all SNAP services reach healthy state

  3. cargo xtask dev start --profile full — starts all services (backwards-compatible)

  4. Orchestrator with CANOPY_TANF_URL unset: TANF determination request returns not_configured, no error

  5. canopy-snap determination produces determination.completed.snap event in RabbitMQ (verify via management UI or test consumer)

  6. canopy-notices receives determination.completed.snap and creates a notice record

  7. All event payloads: grep for income, ssn, amount, address in published JSON — zero matches

  8. cargo nextest run --workspace — all existing + new tests pass

  9. 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 — document cargo xtask dev start --profile snap-only

  • .claude/docs/architecture.md — update deployment profiles section with profile list

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default