Plan: canopy-api hardening + canopy-mq consumer inbox (Issues #437 #433)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
|
Not started |
2 |
|
Not started |
3 |
|
Not started |
4 |
|
Not started |
5 |
|
Not started |
6 |
|
Not started |
7 |
13 service migrations + janitor task. Migrations: |
Not started |
8 |
7 subscriber call-site updates to new handler signature. Files: |
Not started |
9 |
Admin replay endpoint wired per subscribing service. Each of the 7 subscriber services adds the admin route at boot: |
Not started |
10 |
Dockerfile HEALTHCHECK + xtask + e2e updates: every |
Not started |
11 |
Tests. canopy-api: 4 new unit tests ( |
Not started |
12 |
Plan filed (this file). CHANGELOG entry under |
Not started |
13 |
Precommit Q1-Q8 answered via subagent verification per |
Not started |
Context
Two coupled production-hardening gaps surfaced in the 2026-05-09 external review, both port-backs from CRAIG:
#437 (canopy-api): the current shared bootstrap collapses liveness and readiness into a single /healthz endpoint that checks BOTH the process is up AND deps (DB, RabbitMQ) are reachable. Kubernetes / k8s-shaped orchestrators behave better when these are separated — liveness failures restart the pod, readiness failures pull from service-mesh traffic. The bootstrap also lacks HSTS (no Strict-Transport-Security in the security-headers stack) and has no /admin/* route family for operator surgery actions.
#433 (canopy-mq): canopy has the producer half of the durable-events story (event_outbox per ADR-018) — a domain write + outbox insert commit in one TX, then a drainer publishes to RabbitMQ. The consumer half is missing. When a subscriber’s handler fails after partial domain work, the message either redelivers (and double-applies) or dead-letters and the human work is lost. CRAIG has the matching pattern: per-subscriber event_inbox table, idempotent handler invocation, retry/backoff up to a max, then DLQ, with an admin endpoint to replay DLQ’d events after operator triage.
The two ship together because the admin replay endpoint surface (POST /v1/admin/events/replay) lives in canopy-api’s new admin family (#437) but the replay logic reads from canopy-mq’s inbox (#433). Each is incomplete without the other.
User direction (2026-05-14): no back-compat
Pre-1.0; no shims, aliases, or deprecation periods. /healthz is removed (not aliased). Subscriber::subscribe is replaced wholesale with the new inbox-aware variant. Subscriber::subscribe_with_dlx is removed entirely (DLX auto-derived). All Dockerfile HEALTHCHECK lines update to /livez in this MR. All 7 subscriber call sites update to the new handler signature in this MR.
Code references
-
crates/canopy-api/src/lib.rs:134, 269-329— current/healthzroute + handler. -
crates/canopy-api/src/lib.rs:146-156—SetResponseHeaderLayerstack (HSTS added here). -
crates/canopy-api/src/bootstrap.rs:17-50—BootstrapResultshape; carriesdb: DbPool,mq_connection: ConnectionManager. -
crates/canopy-mq/src/subscriber.rs:68-152— current 4 public subscribe variants. -
crates/canopy-mq/src/subscriber.rs:345-408— current ack/nack loop (rewritten with inbox). -
crates/canopy-mq/src/envelope.rs:18-57—EventEnvelope.id: EventEnvelopeId(UUID v7). -
crates/canopy-mq/src/publisher.rs:118—Publisher::publish(&envelope)for replay re-publish. -
crates/canopy-mq/src/outbox_drainer.rs:59-82—OutboxDrainer::spawnpattern to mirror for the inbox janitor. -
services/canopy-medicaid/src/main.rs:150-166— only existingsubscribe_with_dlxcallsite. -
crates/canopy-auth/src/claims.rs:134, 227, 254—has_role,require_service_caller,actorhelpers. -
services/canopy-medicaid/migrations/20260508000000_create_event_outbox.sql— reference shape forevent_inboxmigration.
Scope
In scope (single MR feat/canopy-api-mq-hardening):
-
All 13 Status-table steps land together. ~2000 LOC code + ~600 LOC tests.
-
Breaking changes:
/healthzremoved; 3 subscribe variants gain inbox semantics;subscribe_with_dlxdeleted. -
13
event_inboxtable migrations (matchesevent_outboxper-service footprint). -
7 subscriber service handler migrations.
-
Per-service
POST /v1/admin/events/replayendpoint, gated by service-class JWT + actor.has_role("admin"). -
Dockerfile HEALTHCHECK updates to
/livez. -
xtask + e2e references to
/healthzupdated.
Out of scope:
-
True exponential backoff via delayed retry queue. Needs RabbitMQ
delayed-messageplugin or per-queue TTL hops — separate infra concern. Max-attempts-then-DLQ is the substitute. -
Auto-replay on schedule. The admin endpoint is operator-initiated. Scheduled replay-on-cooldown is post-UAT.
-
Cross-service admin endpoint that walks every inbox. Per ADR-001, each service owns its own inbox.
-
Removing
MqHealthfrom/readyz. Stays — readiness includes RabbitMQ. -
OpenAPI documentation of
/livez+/readyz. Operational endpoints, not API surface. -
Migrating non-subscribing services to use the new subscriber API. They have no consumers — they get the
event_inboxtable prophylactically (consistency withevent_outbox).
Design
/livez and /readyz
// crates/canopy-api/src/lib.rs
.route("/livez", get(livez_check)) // 200 always while process runs
.route("/readyz", get(readyz_check)) // 503 if DB or MQ degraded
// /healthz REMOVED — no back-compat alias.
async fn livez_check() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
// readyz_check = the existing health_check at :269-329 with status-code
// flipped to 503 on degraded.
HSTS header layer after the existing 3 security headers (:146-156):
.layer(SetResponseHeaderLayer::overriding(
http::header::STRICT_TRANSPORT_SECURITY,
http::HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
))
AdminRoutes builder
// crates/canopy-api/src/admin.rs (new file)
pub struct AdminRoutes;
impl AdminRoutes {
pub fn router() -> AdminRoutesBuilder { AdminRoutesBuilder::default() }
}
#[derive(Default)]
pub struct AdminRoutesBuilder {
replay: Option<(PgPool, ConnectionManager)>,
}
impl AdminRoutesBuilder {
pub fn with_replay(mut self, pool: PgPool, manager: ConnectionManager) -> Self {
self.replay = Some((pool, manager));
self
}
pub fn build(self) -> Router {
let mut r = Router::new();
if let Some((pool, manager)) = self.replay {
r = r.route(
"/v1/admin/events/replay",
post(admin_replay_handler).with_state(AdminReplayState { pool, manager }),
);
}
r
}
}
async fn admin_replay_handler(
Extension(claims): Extension<canopy_auth::Claims>,
State(state): State<AdminReplayState>,
Json(req): Json<ReplayRequest>,
) -> Result<Json<canopy_mq::ReplayReport>, ApiError> {
claims.require_service_caller()?;
let actor = claims.actor().ok_or(ApiError::Forbidden)?;
if !actor.has_role("admin") { return Err(ApiError::Forbidden); }
let report = canopy_mq::replay_messages(
state.pool.clone(), state.manager.clone(), &req.event_ids,
).await.map_err(|e| ApiError::internal("replay failed", e))?;
Ok(Json(report))
}
event_inbox migration (byte-identical across 13 services)
-- services/canopy-<service>/migrations/20260516000000_create_event_inbox.sql
CREATE TABLE event_inbox (
event_id UUID PRIMARY KEY,
routing_key TEXT NOT NULL,
payload JSONB NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX event_inbox_unprocessed_idx ON event_inbox (enqueued_at)
WHERE processed_at IS NULL;
COMMENT ON TABLE event_inbox IS
'Per-service consumer inbox (#433). Subscriber INSERT ON CONFLICT DO NOTHING on each delivery — handler invocation is idempotent across redelivery. Same pattern as event_outbox (ADR-018) but on the consumer side.';
Per-message flow (Step 4 in detail)
For each delivery:
-
Deserialize
EventEnvelope. On error → nack(no-requeue) → DLQ. -
let mut tx = pool.begin().await?. -
let outcome = inbox::try_insert(&mut tx, &envelope).await?:-
Inserted→ proceed to (4) -
AlreadyProcessed→ commit + ack + skip handler -
InFlightRetry(row exists,processed_at IS NULL) → proceed to (4)
-
-
Call
handler(envelope, &mut tx).await. -
On
Ok(()):inbox::mark_processed(&mut tx, id)+tx.commit()+delivery.ack(). -
On
Err(e):tx.rollback()+inbox::bump_attempts(&pool, id, &e.to_string())(non-TX) → ifattempts < max→ nack(requeue=true) else nack(requeue=false → DLQ).
Dockerfile HEALTHCHECK migration
HEALTHCHECK CMD curl -f http://localhost:PORT/healthz || exit 1 → /livez. Liveness is the right probe for Docker; readiness is operator-monitor concern.
Files Touched
| File | Change |
|---|---|
|
Replace |
|
New: |
|
New: inbox helpers (try_insert, mark_processed, bump_attempts, find_by_ids). |
|
Delete |
|
New: |
|
New: janitor task (7-day cleanup). Spawned in |
|
Re-export |
|
New migrations (byte-identical schema). |
|
7 subscriber call-site updates: new handler signature + auto-DLX queue name + admin route merged. |
|
|
|
Grep |
|
Amendment paragraph: consumer-side inbox extension. |
|
Add |
|
Entry under |
Subscribing-service sections updated. |
OpenAPI snapshot regeneration: required (canopy-api gains /v1/admin/events/replay). Run cargo xtask api-docs --update.
Verification
-
cargo nextest run -p canopy-mq -p canopy-api --lib— new + existing tests pass. -
cargo nextest run --workspace— all subscriber service integration tests pass with new handler signature. -
cargo fmt --check --all+cargo clippy --all-targets — -D warnings— zero warnings. -
cargo xtask api-docs --update— new admin endpoint reflected in snapshots; verify diff. -
cargo xtask validate— full battery green. -
Manual smoke:
cargo xtask dev refresh.curl localhost:8000/livez→ 200.curl /readyz→ 200. Stop RabbitMQ;/readyz→ 503;/livez→ still 200. -
Manual smoke (replay): publish an event the consumer rejects; after max retries it lands in DLQ;
POST /v1/admin/events/replay {event_ids:[…]}with service-class+admin JWT — confirmReplayReport.replayedincludes the id. -
Adversarial smoke: replay without admin role → 403; without service-class → 401/403.
Documentation Updates
-
Plan filed at
docs/modules/ROOT/pages/plans/canopy-api-mq-hardening.adoc. -
CHANGELOG.adoc — entry with explicit breaking-change callouts.
-
docs/modules/ROOT/pages/adrs/adr-018-persistent-outbox.adoc— amendment. -
13 service data-model pages updated.
-
Service Catalog — subscribing-service updates.
-
Plan moves to
plans/archive/post-merge per ADR-013.
Why this approach (vs alternatives)
-
Don’t keep
/healthzas back-compat alias. User explicitly chose no shims pre-1.0. -
Don’t add the new subscriber API alongside the old. Half-migration leaves long-tail debt.
-
Don’t make DLX configurable per-subscriber. Auto-derive removes boilerplate; canopy-medicaid (the only DLX user today) already follows the convention.
-
Don’t add cross-service admin endpoint. ADR-001 — each service owns its own inbox.
-
Don’t implement true exponential backoff in this MR. Needs RabbitMQ delayed-message plugin; separate infra concern.
-
Don’t make handler signature take
&Poolinstead of&mut Transaction. Atomic inbox-insert + domain-write requires TX.
Risk + Rollback
-
Risk: external monitoring or k8s manifests probing
/healthzwill break. Mitigation: CHANGELOG entry calls out the breaking change explicitly. -
Risk: 7 subscriber handler updates may have TX-semantic regressions. Mitigation: per-handler integration tests must pass; reviewer scrutinises each diff.
-
Risk: inbox INSERT-ON-CONFLICT adds DB load proportional to message volume. Mitigation: indexed PK; partial index on
WHERE processed_at IS NULL. -
Risk: replay re-publishes raw envelope payload — if consumer logic has changed since original publish, replay produces different results. Mitigation: intended semantic (replay-after-bugfix); operator decision.
-
Rollback: revert the MR.
/healthzreturns. Subscribe API reverts.event_inboxmigrations stay (ADR-016 forward-only) but unused. No data loss.
Branch + label hygiene
-
Branch:
feat/canopy-api-mq-hardening -
Type:
type::feature(matches both issues) -
Priority:
priority::medium(matches both) -
Service:
service::shared-crates -
Program:
program::infrastructure -
Compliance:
compliance::pub-1075(production-hardening ATO posture) -
Workflow:
workflow::ready→workflow::in-progressat branch →workflow::in-reviewat MR open