Plan: Operational Infrastructure Remediation
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: Add CI test and lint jobs
- Step 2: Implement column-level encryption
- Step 3: Backup and disaster recovery tooling
- Step 4: Migration rollback strategy
- Step 5: Secret management integration
- Step 6: API versioning and deprecation
- Step 7: Retry with exponential backoff
- Step 8: Database connection pool tuning
- Step 9: SSE for real-time portal updates
- Step 10: Automated accessibility testing
- Step 11: Data export API
- Step 12: Distributed idempotency and cache store
- Step 13: Documentation testing
- Step 14: Full validation
- Files Touched
- Execution Priority
- Verification
- Documentation Updates
Status
| Step | Description | Status |
|---|---|---|
1 |
Add test, lint, and build jobs to GitLab CI pipeline |
Done (2026-04-05) |
2 |
Implement column-level encryption for sensitive PII fields |
Done (2026-04-28, scoped) — AES-256-GCM SSN field-level encryption shipped in |
3 |
Create backup/disaster recovery tooling and runbooks |
Done — |
4 |
Establish migration rollback strategy with pre-migration snapshots |
Done (2026-04-30, scoped to devstack tooling) — |
5 |
Integrate secret management (Vault or sealed-secrets) |
Done (2026-04-30, phase 1 shipped) — |
6 |
Add API versioning and deprecation headers |
Done (2026-04-28) — |
7 |
Implement retry with exponential backoff and jitter for external calls |
Done (partial, scoped to RabbitMQ) — |
8 |
Tune database connection pool per service and export pool metrics |
Done (2026-04-29) — |
9 |
Add SSE endpoint for real-time portal updates |
Done (2026-04-30) — |
10 |
Integrate automated accessibility testing (axe-core) into E2E suite |
Done — |
11 |
Build data export API for FOIA, audit, and citizen data portability |
Done (2026-04-30) — Three bulk-export endpoints shipped: |
12 |
Replace in-memory idempotency/JWKS cache with PostgreSQL or Redis-backed store |
Done (2026-04-30, scoped to idempotency) — |
13 |
Add documentation testing for API examples and CLI commands |
Done (2026-04-29, scoped to doctests) — |
14 |
Full validation pass |
Done (2026-04-30) — All 9 sub-tasks executed against the 13 prior steps' deliverables: (1) |
Epic: &43
Issues: TBD
Branch: chore/operational-infrastructure
Labels: type::chore, priority::critical, program::infrastructure, service::ci, service::shared-crates
Context
A cross-project audit of Canopy and its sibling project CRAIG identified 13 shared operational infrastructure gaps. Both projects invested heavily in application architecture (service isolation, signing, rules engines, event buses) but underinvested in operational concerns (backups, encryption, CI enforcement, retry resilience, observability under failure).
The most critical findings:
-
Neither project runs tests in CI. Both defer entirely to optional pre-push hooks. A developer pushing with
--no-verifyor from a machine without hooks configured can land broken code inmainwith zero automated test signal. For a system determining SNAP eligibility, this is an unacceptable risk. -
No encryption at rest. SSNs, income data, and FTI sit in PostgreSQL as plaintext. IRS Pub 1075 and HIPAA require encryption at rest. Application-level column encryption provides defense-in-depth beyond filesystem encryption.
-
No backup or disaster recovery tooling. These are systems of record for government benefits. Data loss from ransomware, accidental deletion, or failed migrations has no recovery path today.
-
Forward-only migrations with no rollback. Failed deployments cannot revert schema changes. Combined with no backups, a bad migration could leave the system in an unrecoverable state.
These gaps must be addressed before production deployment. Several items (CI testing, pool tuning, retry logic) are low-effort high-impact fixes that should be prioritized immediately.
Scope
In scope:
-
CI pipeline:
cargo fmt,cargo clippy,cargo nextest runas blocking merge jobs -
JUnit XML artifact consumption and test reporting in CI
-
Docker build validation on feature branches
-
Column-level encryption for SSN, DOB, and income fields using
aes-gcm-siv -
Encryption key management via environment variable (phase 1) with Vault integration (phase 2)
-
pg_basebackupwrapper script with WAL archiving configuration -
Documented RTO/RPO targets and tested restore procedure
-
Pre-migration snapshot tooling in
cargo xtask -
Down migration templates for critical tables
-
HashiCorp Vault integration or sealed-secrets operator support
-
Accept-Versionheader support withSunsetandDeprecationheaders (RFC 8594) -
backoffcrate integration for JWKS refresh, inter-service HTTP, and RabbitMQ reconnect -
Per-service pool sizing with
test_before_acquire, pool metrics exported to Prometheus -
SSE endpoint in canopy-web for real-time case/determination events
-
axe-core integration in Playwright E2E tests
-
/v1/exportendpoints for authorized data extraction -
PostgreSQL-backed idempotency key store (replacing in-memory DashMap)
-
API example validation in CI
Out of scope:
-
Worker portal domain routes (separate plan:
worker-portal-snap) -
Full Redis deployment (PostgreSQL-backed cache is sufficient for phase 1)
-
Mobile/offline client
-
Multi-jurisdiction deployment orchestration (Kubernetes operator)
-
HSM-backed encryption keys (phase 3, post-production)
Design
CI Pipeline Architecture
Add a test stage to .gitlab-ci.yml that runs before promote.
Use the existing rust:1.94-alpine builder image.
Three jobs run in parallel:
cargo-fmt:
stage: test
script: cargo fmt --check --all
cargo-clippy:
stage: test
script: cargo clippy --workspace -- -D warnings
cargo-test:
stage: test
script: cargo nextest run --workspace --profile ci
artifacts:
reports:
junit: test-results/**/*.xml
The docker-promote job gains a needs: [cargo-fmt, cargo-clippy, cargo-test] dependency so broken code cannot be promoted.
Encryption at Rest
encrypt / decrypt plus an EncryptionKeys rotation wrapper in
crates/canopy-common/src/crypto.rs, using aes-gcm (Aes256Gcm) — not the
proposed canopy-crypto crate or aes-gcm-siv. Multi-key rotation support
(decrypt_with_rotation) shipped via the secret-and-config-migration plan
Step 5 to handle SOPS+age key rotation per ADR-017. The original sketch is
preserved below as the design starting point.
Use aes-gcm-siv (AEAD, nonce-misuse resistant) for column-level encryption.
Create a canopy-crypto shared crate:
// crates/canopy-crypto/src/lib.rs
pub struct FieldEncryptor { key: aes_gcm_siv::Aes256GcmSiv }
impl FieldEncryptor {
pub fn from_env(var: &str) -> Result<Self, Error>;
pub fn encrypt(&self, plaintext: &[u8]) -> Vec<u8>; // nonce || ciphertext || tag
pub fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, Error>;
}
Encryption key loaded from CANOPY_FIELD_ENCRYPTION_KEY (base64-encoded 256-bit key).
Phase 2 replaces env var with Vault transit engine.
Retry with Backoff
Add backoff crate to workspace dependencies.
Wrap all external calls (JWKS refresh, rules client, inter-service HTTP) with:
backoff::future::retry(
backoff::ExponentialBackoffBuilder::new()
.with_initial_interval(Duration::from_secs(1))
.with_max_interval(Duration::from_secs(300))
.with_randomization_factor(0.3)
.with_max_elapsed_time(Some(Duration::from_secs(3600)))
.build(),
|| async { /* call */ },
).await
SSE for Real-Time Portal Updates
Add an SSE endpoint to canopy-web that subscribes to relevant RabbitMQ events and pushes them to connected browser sessions:
// services/canopy-web/src/api/sse.rs
async fn event_stream(
session: AuthenticatedWorker,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// Subscribe to events for this worker's caseload
// Map RabbitMQ EventEnvelope → SSE Event
}
htmx natively supports SSE via hx-sse="connect:/sse".
Steps
Step 1: Add CI test and lint jobs
Files: .gitlab-ci.yml, .config/nextest.toml
-
Add
teststage to stages list (beforepromote) -
Add
cargo-fmtjob:cargo fmt --check --all -
Add
cargo-clippyjob:cargo clippy --workspace — -D warnings -
Add
cargo-testjob:cargo nextest run --workspace --profile ci -
Publish
test-results/*/.xmlas JUnit artifacts -
Add
cargo-build-dockerjob on MR branches (build only, no push) -
Gate
docker-promoteon test jobs:needs: [cargo-fmt, cargo-clippy, cargo-test]
Step 2: Implement column-level encryption
Files: New crates/canopy-crypto/, services/canopy-persons/src/store/, Cargo.toml
-
Create
canopy-cryptocrate withFieldEncryptor(encrypt/decrypt using AES-256-GCM-SIV) -
Add
canopy-cryptoto workspace members and dependencies -
Update
canopy-personsstore: encrypt SSN on write, decrypt on read -
Add migration to backfill existing plaintext SSN data (encrypt in place)
-
Add unit tests for encrypt/decrypt roundtrip and tamper detection
-
Document key management in
.claude/docs/security.md
Step 3: Backup and disaster recovery tooling
Files: New tools/canopy-backup/, .claude/docs/local-dev.md, new docs/modules/ROOT/pages/disaster-recovery.adoc
-
Create
tools/canopy-backup/backup.sh: wrapper aroundpg_basebackupfor all program databases -
Configure WAL archiving in devstack PostgreSQL containers
-
Create
tools/canopy-backup/restore.sh: tested point-in-time recovery -
Document RTO (4 hours) and RPO (1 hour) targets
-
Add quarterly restore test procedure to operations documentation
-
Add
cargo xtask backupcommand that invokes the script
Step 4: Migration rollback strategy
Files: xtask/src/cmd/migrate.rs (new), .claude/docs/coding-conventions.md
-
Add
cargo xtask migrate snapshotcommand that takes apg_dumpbefore running pending migrations -
Add
cargo xtask migrate rollbackcommand that restores from the most recent snapshot -
Document the rollback strategy in coding conventions
-
Create down migration templates for critical tables (persons, determinations, enrollments)
-
Add snapshot step to
cargo xtask validatebefore running migrations in integration tests
Step 5: Secret management integration
Files: New crates/canopy-secrets/, crates/canopy-common/src/settings.rs, .env.example
-
Create
canopy-secretscrate with trait-based secret provider:EnvSecretProvider(phase 1),VaultSecretProvider(phase 2) -
Settings loader uses
SecretProviderto resolvedatabase_url,rabbitmq_url,encryption_key -
Phase 1:
EnvSecretProviderreads from env vars (current behavior, wrapped in trait) -
Phase 2:
VaultSecretProviderreads from HashiCorp Vault via HTTP API -
Add secret access audit logging (which service accessed which secret, when)
-
Document secret rotation procedure
Step 6: API versioning and deprecation
Files: crates/canopy-api/src/versioning.rs (new), crates/canopy-api/src/lib.rs
-
Add
Accept-Versionheader extraction middleware -
Default to
v1when header is absent -
Add
SunsetandDeprecationresponse headers (RFC 8594) for deprecated endpoints -
Add
/v1/api-versionsendpoint listing available versions with sunset dates -
Document versioning strategy in developer guide
Step 7: Retry with exponential backoff
Files: Cargo.toml, crates/canopy-auth/src/jwks.rs, crates/canopy-rules-client/src/lib.rs, crates/canopy-mq/src/subscriber.rs
-
Add
backoff = "0.4"to workspace dependencies -
Replace infinite loop in JWKS refresh with exponential backoff (1s → 300s max, 30% jitter)
-
Wrap
RulesClient::evaluate()with retry (3 attempts, 1s → 4s) -
Add retry on RabbitMQ reconnect in subscriber (already has DLQ, add connection retry)
-
Add tests for retry behavior (mock failing endpoint, verify retry count and timing)
Step 8: Database connection pool tuning
Files: crates/canopy-db/src/lib.rs, crates/canopy-common/src/settings.rs
-
Add per-service pool configuration:
db_max_connections,db_min_connections,db_acquire_timeout_secs,db_idle_timeout_secs -
Enable
test_before_acquire(true)for connection health checks -
Export pool metrics to Prometheus:
db_pool_active,db_pool_idle,db_pool_waiting,db_pool_acquire_duration_seconds -
Set production-appropriate defaults: max 25 connections, min 5, 10s acquire timeout
-
Add pool exhaustion alert threshold in canopy-security
Step 9: SSE for real-time portal updates
Files: services/canopy-web/src/api/sse.rs (new), services/canopy-web/src/api/mod.rs, services/canopy-web/templates/base.html
-
Add SSE route:
GET /ssereturningSse<impl Stream> -
Subscribe to RabbitMQ events filtered by worker’s assigned caseload
-
Map
EventEnvelopeto SSEEventwith JSON data -
Add
hx-sse="connect:/sse"to base template for auto-reconnect -
Add SSE event handlers for: determination_complete, appeal_filed, new_assignment, notice_generated
-
Add connection keepalive (30s heartbeat)
Step 10: Automated accessibility testing
Files: tests/e2e/package.json, tests/e2e/fixtures/a11y.ts (new), tests/e2e/specs/*.spec.ts
-
Add
@axe-core/playwrightto E2E dev dependencies -
Create shared fixture that runs
checkA11y()after each page load -
Assert zero WCAG 2.1 AA violations on every page render
-
Add color contrast validation for theme tokens (light and dark mode)
-
Run a11y tests as part of
cargo xtask e2e
Step 11: Data export API
Files: New endpoint in each service’s api/mod.rs
-
Add
GET /v1/export/determinationsto canopy-snap (CSV and JSON formats) -
Add
GET /v1/export/personsto canopy-persons (with PII redaction for FOIA) -
Add
GET /v1/export/audit-eventsto canopy-security (admin role required) -
Add
Accept: text/csvcontent negotiation -
Require
adminorquality_controlrole for all export endpoints -
Add audit log entry for every export request
Step 12: Distributed idempotency and cache store
Files: crates/canopy-api/src/idempotency.rs, crates/canopy-api/src/lib.rs, all 18 service main.rs callers (mechanical .await thread-through).
-
Create
idempotency_keystable in each service’s database:(cache_key TEXT PRIMARY KEY, response_status INT, response_body BYTEA, response_content_type TEXT, created_at TIMESTAMPTZ). Implemented asCREATE TABLE IF NOT EXISTSrun at boot insideIdempotencyCache::with_poolrather than as a sqlx migration — the migration approach the original plan proposed would require coordinated migration steps across 14 services with no functional benefit (each service’s own DB owns its own table per ADR-001). Boot-time DDL is idempotent and the table schema is owned bycrates/canopy-api, not by any one service. The original plan also proposedresponse_headers JSONB; the implementation persists onlyresponse_content_typebecause that is the only response header the in-memory backend ever stored, and persisting arbitrary header maps would cross authentication-token boundaries (a replayedset-cookiecould leak session state). -
Replace
DashMapin idempotency middleware with PostgreSQL queries — done via aBackend::{Memory, Postgres}enum so the in-memory backend stays available for unit tests (the test pool requires devstack and breakscargo nextest runhost-side). -
Add TTL cleanup:
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours'runs on a 1-hourtokio::time::intervalwithMissedTickBehavior::Skip. The single-statementDELETEis unbounded — bounding-by-LIMIT is tracked as #341. -
JWKS cache stays in-memory (per-instance, refreshes hourly). No code change for JWKS in this scope.
-
Cross-replica restart-survival integration test deferred to #340 — the project does not yet have a multi-replica devstack harness;
Backend::Memorypaths are unit-tested, andBackend::Postgrespaths are exercised via every service that boots against devstack but lack a focused regression test. The fallback-to-in-memory behaviour on initial DDL failure (logged at WARN) is not tested either.
Cross-replica race safety: INSERT … ON CONFLICT (cache_key) DO NOTHING — first writer wins, replay output is identical for callers either way. Prometheus metrics for hit/miss/replay/persist-error rates tracked as #342.
Step 13: Documentation testing
Files: .gitlab-ci.yml, new tests/doc-validation/
-
Extract API request/response examples from AsciiDoc into testable fixtures
-
Add CI job that validates fixture requests against running devstack
-
Add
cargo test --docto CI for Rust doc examples -
Validate CLI examples in developer guide produce expected output
-
Add
cargo xtask check-docs --examplescommand
Step 14: Full validation
-
cargo fmt --check --all -
cargo clippy --workspace — -D warnings -
cargo nextest run --workspace --profile ci— all tests pass (existing + new) -
cargo xtask validate— full pre-push validation -
CI pipeline successfully runs all new jobs on feature branch
-
Verify encryption roundtrip: encrypt SSN, decrypt, compare
-
Verify backup/restore: take backup, corrupt data, restore, verify integrity
-
Verify retry: mock failing Keycloak, confirm backoff intervals in logs
-
Verify SSE: open browser, trigger determination, confirm real-time update
Files Touched
| File | Change |
|---|---|
|
Add test stage with fmt, clippy, test, docker-build jobs |
|
Add aes-gcm-siv, backoff, canopy-crypto to workspace |
|
New crate: AES-256-GCM-SIV field encryption |
|
Pool tuning, test_before_acquire, metrics export |
|
Per-service pool config, rate limit, secret provider |
|
Versioning middleware, SSE wiring |
|
Replace DashMap with PostgreSQL-backed store |
|
New: Accept-Version header, Sunset/Deprecation headers |
|
Replace infinite loop with backoff crate |
|
Add retry with backoff on evaluate() |
|
Add connection retry with backoff |
|
New crate: trait-based secret provider |
|
Encrypt/decrypt SSN via canopy-crypto |
|
New: SSE endpoint for real-time updates |
|
Add hx-sse connection |
|
Add export endpoint |
|
New: pg_basebackup wrapper + restore script |
|
New: snapshot and rollback commands |
|
New: axe-core accessibility fixture |
|
New: API example validation tests |
Execution Priority
| Priority | Step | Effort | Reason |
|---|---|---|---|
P0 |
Step 1 (CI tests) |
Small |
Highest-impact single change; blocks broken code from merging |
P0 |
Step 2 (Encryption) |
Medium |
Regulatory requirement (IRS Pub 1075, HIPAA) |
P0 |
Step 3 (Backups) |
Medium |
No recovery path today; existential risk |
P1 |
Step 4 (Migration rollback) |
Small |
Prevents unrecoverable deployment failures |
P1 |
Step 7 (Retry/backoff) |
Small |
Low effort, prevents cascading failures |
P1 |
Step 8 (Pool tuning) |
Small |
Low effort, prevents connection exhaustion under load |
P2 |
Step 5 (Secret management) |
Medium |
Env vars acceptable short-term; Vault needed for production |
P2 |
Step 6 (API versioning) |
Medium |
Not urgent until v2 is needed, but foundation should exist |
P2 |
Step 9 (SSE) |
Medium |
UX improvement; not blocking for UAT |
P2 |
Step 10 (a11y testing) |
Small |
Section 508 requirement; blocked on E2E infrastructure |
P3 |
Step 11 (Data export) |
Medium |
Needed for auditors and FOIA; not blocking for UAT |
P3 |
Step 12 (Distributed cache) |
Medium |
Only matters at multi-pod scale |
P3 |
Step 13 (Doc testing) |
Small |
Quality-of-life; prevents doc drift |
Verification
-
cargo fmt --check --all— no formatting issues -
cargo clippy --workspace — -D warnings— zero warnings -
cargo nextest run --workspace --profile ci— all tests pass -
cargo xtask validate— full pre-push validation passes -
CI pipeline runs fmt + clippy + test jobs and blocks promote on failure
-
cargo xtask backupcreates valid backup;cargo xtask migrate rollbackrestores from snapshot -
Encrypted SSN roundtrip: insert person with SSN, retrieve, verify match
-
Retry test: stop Keycloak, verify JWKS refresh backs off (1s, 2s, 4s… in logs)
-
SSE test: open portal, trigger event via API, verify browser receives update < 2s
-
a11y test:
cargo xtask e2ereports zero WCAG 2.1 AA violations
Documentation Updates
-
.claude/docs/services.md— export endpoint tables -
.claude/docs/security.md— encryption at rest, secret management, key rotation -
.claude/docs/local-dev.md— backup/restore commands, pool tuning -
.claude/docs/coding-conventions.md— migration rollback strategy, retry patterns -
CHANGELOG.adoc— entry under== Unreleased -
Antora pages — disaster-recovery.adoc, configuration-reference updates
Tracked follow-ups (filed 2026-04-30 alongside Step 12 implementation MR):
-
#340 — Cross-replica restart-survival integration test for
IdempotencyCache::with_pool(deferred Step 12.5) -
#341 — Bound the TTL cleanup
DELETEwithLIMIT 1000to avoid long lock windows under sustained traffic -
#342 — Prometheus metrics for idempotency cache hit/miss/replay/persist-error rates (parity with Step 8 pool metrics)
-
#344 — Per-database
--dbflag forcargo xtask migrate rollback(Step 4 follow-up) -
#345 — Down-migration templates for critical tables (Step 4 sub-task 4 deferred — needs-spec)
-
#346 — Vault-backed
SecretProvider(Step 5 phase 2 — when Vault is provisioned) -
#347 — Quality-control role + address-join in person export (Step 11 follow-up)
-
#348 — SSE caseload filtering + missing event publishers + htmx-sse wiring (Step 9 follow-up)