ADR-003: RabbitMQ Event Topology
On this page
Context
CRAIG services need to communicate asynchronously for domain events (e.g., a case being created triggers placement evaluation, rules evaluation triggers case updates). We needed to design a messaging topology that supports both competing consumers (for DB-mutating handlers) and fan-out (for cache invalidation).
Decision
Use a single topic exchange (craig.events) with service-owned queues and dotted routing keys.
Routing Keys
Events use the format {service}.{event_type}:
-
rules.evaluations_archived(#1129 / ADR-058: rules' fleet-bookkeeping fact — one archive batch ofrule_evaluationsarchived-and-pruned; staged inside the prune transaction. Payload = the batch manifest facts incl. the D9 store identity +archived_by; craig-security validate-then-writes it intoarchive_records(D16 — envelope fields are data, not transport identity; forged/malformed envelopes are refused loudly and land only in the generic audit row). Rules' ownarchive_ledger— not this event — is the authority its orphan GC consults) -
security.archive_completed(REDEFINED by #1129: now marks a REAL archive act — one batch of security’s own audit-class rows archived-and-pruned, payload = manifest facts +archived_by; the pre-#1129 event announced a stale purge_eligible sweep),security.archive_purged(#1129 D15: one expired archive destroyed; carriespurged_by) -
rules.evaluated,rules.cache_invalidated,rules.authz_fallback_admit(rules.evaluatedis a pointer payload since #1130 — rule-set identity +evaluation_id+ context, never the input/output documents; the single durable copy is the producer’srule_evaluationsrow; since #1556 it also carriesrule_set_revision, the content-exact identity joining straight torule_set_snapshots. #786:authz_fallback_admitstaged on every craig-rules bootstrap fallback admit; attribution payload, no PII) -
rules.ruleset_promoted,rules.ruleset_rolled_back(#1556 / D2: the operator lifecycle flips of live rule policy, staged on the flip transaction —rule_set_id, name, version,from_revision→to_revision,user_id(the acting operator, feeding the security actor chain),reason, for promote thepreview_idpointing at the persisted canary evidence, and for rollback therestored_revision(the snapshot the content came FROM —from_revisionis the live state it moved off). Security parses them aspromote/rollbackonrule_setwith therule_set_idresolved — never the raw-split fallback) -
authz.access_denied(ADR-050 §Amendment #1063 — staged by the craig-authz engine on every EXPLICITPolicyDeniedrow-check outcome, volume-gated by the full-dimension per-emitter aggregate: first occurrence per(sub, resource_type, resource_id, action, ruleset_name, denied_reason)per 60 s window emitskind: "deny"(acting worker undersub,user_roles, resource + action + jurisdiction,ruleset_name/ruleset_version, categoricaldenied_reason,suppressed_priorwhen a prior window closed,aggregate_overflowat cap); drained windows emitkind: "suppression-flush"with the suppressed count on the SAME routing key — consumed by craig-security’s wildcard audit subscriber like the sibling authz events) -
authz.cache_miss,authz.cache_refreshed(ADR-050 / #908 — staged by the craig-authz engine inside EVERY hosting service via its injectedOutboxAuditSink;source_servicenames the emitter. Miss payload: ruleset/jurisdiction/resource-type lookup + acting-worker attribution (subpseudonymous UUID,is_service,service_id— no PII), emitted ONLY on source-attested absence, never onPolicyLoadfaults. Refresh payload: triggerrmq_event|ttl_refresh|boot|warmup|lazy_load+ version diff,new_version: null= upstream eviction) -
case.referral_created,case.intake_created,case.created,case.updated,case.closed -
placement.created,placement.activated,placement.ended,placement.requested(placement.activatedis the billing trigger — its payload is the shared typedcraig-placement-contractsDTO both the producer and the craig-financial consumer compile against; #979),placement.home_document_uploaded,placement.home_document_upload_failed(#1251 / ADR-062 §U — staged in the same transaction as the finalize/reap; the failed payload keys onattempt_idand carries the sanitizedfile_nameso reap-path failures name the file, no other PII) -
exchange.sent,exchange.failed,exchange.received,icpc.created,icpc.status_changed -
case.screening_cohort_changed,exchange.screening_run_invalidated(#1467 / ADR-065 D1 rule 2 + ADR-066 — the SSA-screening pair, catalogued + parsed BEFORE any producer exists per D17/H25, the ADR-057 runbook precedent; B6b ships the emitters.screening_cohort_changedis the fork-5 MINIMAL shape —case_id+ the monotoniccohort_revision+ a categoricalchangetoken ∈ {household,person_digest,ssn_cleared,link_promoted}; deliberately NOperson_idand NO SSN digest, person-level tokens are activity-revealing to every queue reader — consumed by craig-exchange’s inbox (watermark GREATEST-upsert → strict<invalidation → sweep) and audited asupdate/screening_cohortso the derived signal never double-counts as a case update.screening_run_invalidatedcarriesrun_id+ the denormalizedcase_id+reason∈ {cohort_changed,operator_cancelled,freshness_expired}, plus present-onlysuperseded_by_run_id(the fork-3 auto-supersede) and present-onlyuser_id= the relayedrequested_bysub onoperator_cancelledONLY (the fork-1 actor-naming obligation; system variants carry no attribution key). NO sweep counts ride either payload — the DB stamps are the accounting (the ADR-066 as-built posture); audited asupdate/ssa_screening_runwithrun_idresolved beforecase_id(the adjustment-family shape)) -
exchange.screening_hold_set,exchange.screening_hold_cleared(#1566 / UD10 — the legal-hold transition pair, staged in the SAME transaction as the run-row flag flip; one event per genuine TRANSITION (a replay of an already-held/already-clear run stages nothing, so the audit trail IS the transition history). Payload:run_id+ the denormalizedcase_id,user_id= the acting admin’s sub (a hold is never a system act — the human-admin-only endpoint gate is the fork-6screening_resumeposture), and the operator-entered boundedreason(the hold’s narrative lives HERE, never on the run row — the flag is a bare boolean, thearchive_recordsprecedent). Audited asupdate/ssa_screening_runwithrun_idresolved via theexchange.screening_family; same same-MR parser-arm deploy-order note asssa.jobs_resumed) -
ssa.jobs_resumed(#1469 / ADR-066 fork 6 — ONE event per operator resume act, staged in the same tx as the fork-6 attempts-reset:user_id= the resuming admin’s sub, the filters as sent (present-onlyreason_filter/run_id), and each re-admitted job with its PRE-reset attempts count. Batch grain — no singular resource id (thepayments_voidedshape); auditedupdate/ssa_screening_job. Categorical only: ids, counts, closed tokens. Deploy-order note: the parser arm ships in the same MR as the producer — safe because the wildcard subscriber’s raw-split fallback records unknown types losslessly; a security instance older than the arm labels rows(jobs_resumed, ssa)until restarted, never drops them) -
case.screening_review_dismissed(#1557 / UD5: ONE event per human dismissal of a screening-review worklist item — the audited decision that new SSA data does NOT warrant re-determination, staged in the dismiss transaction. Payload:item_id,case_id(the audited subject; resolves via the generic selector),user_id(the acting worker), the categoricallatest_reasonthe item carried, and the bounded free-textdismissal_reason(the substance of the decision — no PII beyond what the worker types; the UI labels the field audit-visible). Parsed asupdate/screening_review) -
ssa.stale_cohorts_invalidated(#1489 / ADR-065 fork 4 — ONE summary per executed rotation sweep, staged BEFORE the per-run stamps as the sweep INTENT:user_id= the sweeping admin’s sub,current_version, andrun_ids+count= the CANDIDATE set the operator executed against. Every stamp that follows is a subset of the declared candidates, so a crash mid-sweep can never leave an invalidation without an attributing summary; each stamped run carries its ownexchange.screening_run_invalidatedsystem-variant event (reasoncohort_changed) as the completion evidence, and a post-crash re-run declares a fresh intent over the remainder. Batch grain, categorical only; auditedupdate/ssa_screening_runvia the explicit parser arm (same fallback-safe same-MR deploy-order note asssa.jobs_resumed)) -
financial.payment_created,financial.payment_approved,financial.payment_issued,financial.payment_cleared,financial.payments_voided,financial.claim_submitted,financial.claim_accepted(#1079 — federal acceptance recorded on a claiming record; ids/period/actor only),financial.adjustment_created,financial.adjustment_resolved(#1078 — the payment-correction pair: ids/actor only,resolution=approved|denied; no reason text, no money),financial.subsidy_generation_completed(payments_voidedis one bulk event per set-based void — voided ids + count +cause(placement_ended|subsidy_reconciliation, #1068) with present-only scope keys (placement_id|agreement_id+month), no PII; #953. The three lifecycle events share one payload shape carrying the acting worker’s sub asuser_id; #978 made issue/clear reachable and stopped approval re-emittingpayment_created.payment_created+ the lifecycle events OMITcase_idwhen the payment has none — post-case subsidy rows, #1068.subsidy_generation_completedis one event per generation run: run id, month, initiator kind, and the report counters — no PII; ADR-053) -
financial.subsidy_agreement_imported,financial.subsidy_import_batch_finalized(#1071 / ADR-057 — the conversion import; the PRODUCER landed with the MR-B surface, the parser deploy-first: oneimportedevent per agreement MATERIALIZED at batch finalize (agreement/child/program/head-status/slot-count/batch/cutover — the source-system record reference deliberately does NOT ride events; it is readable via the records ledger / detail API / web provenance block by authorized operators), and one counters-only summary per finalized batch. No PII in either payload) -
reporting.quality_issue_detected,reporting.afcars_transmitted,reporting.ncands_transmitted,reporting.afcars_export_generated,reporting.ncands_export_generated,reporting.afcars_export_failed,reporting.ncands_export_failed(#1252 / ADR-062 §U — the export-attempt pointer flips and reconciler reaps; generated payloads carry the artifact digest + generation, failed payloads key onattempt_idand carry the deterministic filename, no PII) -
security.review_due,security.archive_completed,security.major_change_recorded,security.major_change_reported,security.alert_created,security.alert_acknowledged -
case.report_submitted,case.report_disposition_recorded,case.report_attachment_uploaded,case.report_converted(cases owns the report lifecycle per ADR-017) -
case.plan_approved,case.plan_superseded,case.plan_updated,case.task_created,case.task_updated,case.task_deleted,case.person_created,case.person_updated(cases owns the case-plan / task / person mutation lifecycle; 821 — all carried to theaudit subscriber; person payloads carry no PII),case.person_pii_exported(#1064 / ADR-051 — the federal-export READ-audit: requestor service, person-id list,include_full_ssn, purpose; the repo’s first read-access audit event, still zero PII),case.person_ssn_admin_action(#1064 — SSN verification revoked / SSN cleared, carrying the worker-entered reason toaudit_log; the one person payload with worker-authored text, deliberately kept OUT of tracing logs) -
security.partner.created,security.partner.key_issued,security.partner.key_revoked,security.partner.signer_key_registered,security.partner.signer_key_approved,security.partner.signer_key_revoked(security owns partner identity + signer keys per ADR-018)
Wildcard Audit Subscriber
craig-security binds the queue craig-security.events to craig.events with routing key # — every event published to the exchange is written to the audit_log table by a single subscriber.
This is the mechanism through which CRAIG achieves audit coverage for all domain mutations without per-service wiring.
See Architecture — Cross-cutting Service Contracts for the wider pattern.
Consumer Patterns
(Retitled from "Queue Types" by the #1198 amendment below — "queue type" now unambiguously means the RabbitMQ classic/quorum/stream property, not the consumer pattern.)
-
Competing consumer queues (e.g.,
craig-cases.events): Shared across all instances of a service. Each message is delivered to exactly one consumer. Used for DB-mutating event handlers. -
Exclusive queues (e.g.,
craig-rules.cache.{uuid}): Per-instance, auto-delete. Every instance receives every message. Used for cache invalidation.
Rationale
-
Single exchange: Simplifies topology. Topic routing provides flexible filtering without creating per-service exchanges.
-
Service-owned queues, operator-owned exchanges: Each service declares its own queues and bindings on startup. The exchanges (
craig.events,craig.dlx) are provisioned by the operator (definitions.json/ management API), NOT declared by services — see Amendment #1202. -
Competing vs exclusive: This dual-queue pattern supports horizontal scaling. DB mutations must be processed once (competing); cache invalidation must reach all instances (exclusive).
-
Why not direct exchange: Topic exchange supports wildcard routing (e.g.,
case.*) which may be useful for future audit/logging consumers. -
Why not pub/sub (fanout): Fanout doesn’t support selective subscription. Topic exchange provides both fan-out (exclusive queues with
#binding) and point-to-point (competing queues with specific bindings).
Consequences
-
Services must handle idempotent event processing — messages may be redelivered on connection failure.
-
Event consumers are best-effort — publishers do not wait for consumer acknowledgment.
-
Adding a new event type requires no infrastructure changes — just publish with a new routing key and bind consumers.
Amendment — #1198 (2026-07-29): queue-type & delivery-limit contract (epic &75 C4)
Nothing previously pinned the RabbitMQ queue type (classic vs quorum vs stream). The fleet’s
redelivery semantics — the client 2-strike nack ladder, the #1181 x-death token grammar
(ADR-022), and the planned #1197 parking
tier — all assume classic queues with no broker-autonomous redelivery bound, which was
default-behavior-in-absence-of-config, not an invariant: RabbitMQ 4.x quorum queues default
delivery-limit = 20 (breach → drop, or DLX if configured — the DLQ deliberately has none, so
a quorum DLQ would silently drop forensic records), and a one-dropdown vhost
default_queue_type changes what type-omitting declarations get.
Decision
-
Every CRAIG-declared queue is classic, with an explicit
x-queue-type=classicargument — the production choke-point (SubscriptionSpec::prepare_queueviaqueue_args, all three subscribe variants) and the in-tree test helpers (EventCollector,DlxCollector). The complete argument table per role is unit-pinned by full-table equality, sox-delivery-limit(or any other arg) can never appear silently. -
Delivery limits: a quorum-queue feature classic queues do not implement — type enforcement transitively neutralizes delivery-limit policies. The machine-checked invariant is the queue type; the no-limit posture follows from it. Residually an operator convention (devstack ships no
policies; the deployment guide prohibits delivery-limit or queue-type-affecting policies/operator-policies). It is NOT runtime-enforced — services hold AMQP credentials only, no management API — and this ADR does not claim otherwise. -
Quorum migration is a named future gate: only with broker clustering, and only through a new ADR that revisits together the #1181
x-deathcountgrammar, at-least-once dead-lettering, and the delivery-limit posture.
What the explicit argument actually buys
RabbitMQ 4.x resolves and compares the queue type on every declare even when the client omits
it, so the redeclare-conflict 406 largely pre-exists. Explicit classic adds: (1) immunity to a
vhost/node default_queue_type at fresh declare — the real silent-loss hole; (2)
independence from broker-version-dependent type-injection behavior; (3) auditable intent in the
stored queue arguments.
Enforcement layers
-
A — declare-time: the eager first session means a conflicting pre-existing queue fails the service boot with 406
PRECONDITION_FAILEDnamingx-queue-type. -
A′ — runtime: the reconnect path classifies AMQP 406 into an
error!-level "operator action required" log (generic about queue-vs-exchange — exchanges are declared before queues and 406 is not type-specific) while preserving retry-forever (#1127). Degraded mode is deliberate: the service stays up and loud;readyzreflects the publisher path only. -
B — source lint: the blocking
cargo xtask lints mq-topologygate — a crate-boundary convention gate (raw declaration tokens only insidecrates/craig-mq/src/,crates/craig-test-lib/src/, or test paths). It does not prove declarations carry the type;queue_args+ its unit pins do, inside the boundary. -
C — devstack pins:
rabbitmq.confsetsdefault_queue_type = classic(node) andquorum_queue.property_equivalence.relaxed_checks_on_redeclaration = false(load-bearing: relaxing it makes the broker ignorex-queue-typeon redeclare — a subscriber would silently consume from a quorum queue);definitions.jsonpins the/vhost metadata DQT (canonical exported nested-metadatashape). Definitions import never overwrites existing broker state — the devstack broker is volume-less, so drift is cured by container recreation; the live remedy israbbitmqctl update_vhost_metadata / --default-queue-type classic.
Rollout & residuals
-
4.x-born queues store the injected classic type, so the explicit-classic redeclare is equivalent — no queue wipe, no operator action on sanctioned brokers (live-pinned for both durable arg shapes). Residual: queues first declared on pre-3.13 brokers and upgraded in place may 406 at boot; the deployment guide carries the symptom and the shovel/drain remedy.
-
The exclusive fan-out variant’s explicit type is hygiene, not enforcement (uuid-named, live collision is 405, quorum structurally impossible for exclusive queues).
-
Crash-path residual — RETIRED by #1203 (2026-07-30): a handler panic no longer unwinds the supervisor. Every user-handler path (events, DLQ, inbox) runs inside a per-delivery task boundary that converts UNWIND panics to the normal failure ladder (2-strike nack /
TransientParkcapture / inboxerror_countstamp) — thedlq_handler_panics_total{path}counter is the code-defect tell. Unwind-only:panic = "abort"builds are not contained (the workspace is unwind-everywhere). -
Broker permission separation (per-service credentials) landed as Amendment #1202 (below) — the single administrator credential this amendment inherited is retired there.
-
Live pins:
crates/craig-mq/tests/queue_contract.rs(DQT-vhost, node-default, both conflict tripwires, both rollout shapes, mid-life drift loudness).
Amendment — #1197 (2026-07-29): parking + quarantine companion queues (epic &75 C3)
ADR-059 adds two durable classic queues per
DLQ subscription, declared through the same craig-mq choke-point the #1198 amendment pins
(classic-only argument tables; the mq-topology lint boundary applies): {queue}.parking — a
plain holding queue consumed by the client-driven replayer (prefetch 1; NO TTL or DLX
arguments, so no redeclare hazard ever) — and {queue}.quarantine — terminal, no consumer,
operator-drained per the deployment guide’s manual-ack replay procedure. Both are published
to via the DEFAULT exchange with publisher confirms + mandatory + persistent delivery; no
broker-managed dead-letter hop exists anywhere in the retry path (classic-queue
dead-lettering is best-effort — rejected as a return leg by ADR-059’s review). Production
footprint: craig-security.dlq.parking / craig-security.dlq.quarantine. The deployment
guide’s policy MUST-NOTs are extended by #1197 (message-ttl, max-length,
max-length-bytes, expires — which would DELETE the consumer-less quarantine queue —
and destructive overflow on craig* queues).
Amendment — #1202 (2026-07-30): operator-owned exchanges + per-service credentials (epic &75 addendum)
Two coupled changes retire the shared single-credential posture this ADR shipped with.
Operator-owned exchange topology
Services no longer declare exchanges. craig_mq::declare_dlx and the open_channel
exchange declares are removed (pre-1.0, no shims); craig.events and craig.dlx are
provisioned by the operator (devstack/rabbitmq/definitions.json; production per the
deployment guide). Reason: a service that declares an exchange needs configure on it,
and configure includes DELETE — a compromised service could delete/redeclare
craig.dlx and silently suppress forensic delivery without ever touching the DLQ,
falsifying ADR-022's #1181 bound.
Recovery-semantics change (deliberate): a WIPED broker is no longer silently self-healed. Boot fails loudly; the mid-life session rebuild retries the bind under the #1127 supervisor backoff (promoting that 404-at-rebuild to the operator-action-required error class is #1210). The old silent redeclare masked the data loss a wipe implies.
Per-service least-privilege credentials
Ten accounts replace the one shared craig administrator: craig keeps
administrator as the operator/management-plane identity (no service connects as it),
craig-test is the broad AMQP test identity (no tags), and eight per-service accounts
are each scoped by anchored regex (RabbitMQ patterns are substring matches — anchors
mandatory) to their own queues plus the shared exchanges they use. The exact triples
are devstack/rabbitmq/definitions.json (the executable spec) and the deployment
guide re-renders them with the rabbitmqctl forms + the ordered cutover/rollback
runbook.
Accepted bounds (named, not silently accepted):
-
craig-security’s `amq.defaultwrite is write-anywhere-via-default-exchange for the audit principal — per-queue default-exchange scoping is inexpressible; a dedicated capture exchange is future work. -
Injection residuals: any service may publish any routing key to
craig.events(event forgery) and anydlq.*key tocraig.dlx(forensic-record forgery). Topic permissions are the future hardening; the residual is recorded now. -
Events-plane eavesdropping: any service may bind any routing key on
craig.events. -
Enforcement line:
readoncraig.dlxappears only in `craig-security’s set — "only craig-security reads `dlq.*`"; delete-on-shared-exchanges is denied fleet-wide.
Devstack honesty posture: devstack credentials are a FUNCTIONAL ACL FIXTURE, not a
containment boundary — the port binds the host, passwords equal the account name and are
public, craig/craig-test retain broad rights on the operator/test plane. Production
gets generated secrets, network restriction, and no craig-test analogue. The devstack
broker is rabbitmq:4.2-management-alpine; the 4.3.1+ passive-declare ACL is not yet
enforced, so the grants are forward-compatible, never relied on as current enforcement.
Live pins: crates/craig-mq/tests/broker_permissions.rs (the mgmt-exact fleet pin
against definitions.json + the least-privilege probes, incl. both exchange_delete
403s) and the empty-vhost bind-refusal pin in queue_contract.rs.
Amendment — #1186 (2026-07-31): readiness scope superseded in part (epic &76)
The recurring statement "`readyz` reflects the publisher path only" (Enforcement layers A′
above, echoed in the craig-mq module doc and shared-crates) is superseded in part by
ADR-061, whose epic-&76 implementation
CHANGES /readyz to gate on the publisher connection (the pre-#1186 Extension wiring never
delivered it — readiness has been DB-only) AND on registered critical-worker liveness, so a
subscriber SUPERVISOR’s death will gate readiness once the program’s U2 lands (the ADR-061
plan’s Status table is authoritative for what has shipped). Unchanged either way: the #1127
self-heal semantics inside a live supervisor (reconnect-forever, degraded-and-loud) — a
reconnecting subscriber does NOT flip readiness; only the supervisor task dying does.
Amendment — #1528 (2026-08-20): AMQP establishment carries a liveness bound
"Retry-forever" (Enforcement layer A′) presupposes the retry loop keeps ticking. It did not.
lapin is constructed with ConnectionProperties::default(), and no connect, handshake, or
per-RPC timeout exists anywhere in the client. The AMQP URL is not an escape hatch either:
amq-protocol-uri parses a ?connection_timeout= query parameter, but the async TCP path never
reads it. Nor does the heartbeat cover the gap — lapin arms the heartbeat timer only on
StartHeartbeat, i.e. after Tune, so before the handshake completes no timer exists at all
(afterwards there is a ~4×(heartbeat/2) killswitch, far above any liveness budget). A
broker path that accepts TCP and never answers (a half-open NAT/LB entry, a paused or wedged
node, a saturated proxy hop) therefore parks the awaiting task indefinitely. The
#1141 shield in reconnect() raced only the shutdown token, and a running service never fires
it, so ONE black-holed rebuild pinned the #1127 supervisor permanently: the capped-backoff
ladder never got another tick, subscriber reconnected never logged, and the queue silently
accumulated behind a service that stayed up and — per A′ — stayed "ready".
Every AMQP operation that would otherwise park a long-lived task now runs under the crate-level
AMQP_LIVENESS_TIMEOUT (15 s) via one bounded_amqp helper — one bound, applied at every such
site rather than a fix at the one that was observed. (Every site today; nothing structurally
prevents a future unbounded Connection::connect, so this is review-enforced, not
compiler-enforced.)
| Bounded site | Task it would otherwise pin |
|---|---|
|
the #1127 consume supervisor + both eager boot builds ( |
|
the ADR-059 §D4 DLQ parking replayer |
|
the #1199 DLQ depth sampler (a parked tick loop freezes the gauges at their last value, and a stale depth gauge reads as a healthy queue). The sampler REUSES its connection across ticks, so its realistic black hole is a mid-stream one — the channel open is as exposed as the dial |
|
every service’s boot publisher connect — now a loud failure instead of a startup that hangs with no error and no readiness |
The bound wraps the WHOLE establishment, not just Connection::connect: once the TCP handshake
completes, a black hole that starts mid-stream parks queue_declare / queue_bind /
basic_consume identically, and there is no per-RPC timeout either.
Two contracts hold the amendment together. First, expiry maps to a TimedOut I/O error, which
lapin::Error::is_io_error classifies TRANSIENT — so log_rebuild_failure keeps its warn!
arm and the ladder retries; the operator-action error! arm stays reserved for the 406/404 that
retrying can never clear (layer A′, #1210). That chain is driven end-to-end rather than inspected
— an_expired_establish_lets_the_backoff_ladder_tick_again runs the real reconnect() against a
black-holed listener and asserts from the supervisor’s own narration that the delay ADVANCES past
the expiry and that no operator-action arm was taken. Second, 15 s sits between the crate’s single-RPC
bounds (SETTLE_TIMEOUT 5 s, CONFIRM_TIMEOUT 10 s — unit-pinned as a floor, since a session
build is several RPCs) and the contested suite’s 90 s reconnect-barrier floor
(craig_test_lib::fault::mq_sever::RECONNECT_DEADLINE), at no more than a third of the latter:
one stalled establish costs at most a third of a recovery envelope instead of all of it. That is
an ordering pin and not a budget — it does NOT say how many attempts fit, and the arithmetic that
would is wrong, because it omits the backoff: past the ladder’s 30 s cap, one stalled attempt
plus one cap-length wait is 45 s, so two fill the barrier exactly. Sizing the barrier against a
contested ladder remains the harness’s job (#1509); the bound’s job is that a stall costs one
tick rather than every tick. The ordering is itself pinned
(transport_faults.rs, no broker required) — neither
crate’s own unit tests can hold an invariant that spans both. The second-order effect is
accepted deliberately: an establish that legitimately needs more than 15 s now fails and
restarts the ladder, i.e. a retry inside the same recovery envelope rather than a park outside
every envelope.
Deliberately NOT bounded: the consume loop’s wait for the next delivery (idling on an empty
queue is correct, not a stall). #1531 closed the residual settle/publish gaps: EVERY settle —
the inline ack, all five nacks, the post-capture acks — is bounded by SETTLE_TIMEOUT (a hang
maps to the transient TimedOut class, ends the session as SettlementFailed, and the
supervisor rebuilds instead of the delivery loop parking behind an accept-and-stop-answering
broker path); every basic_publish SEND (both Publisher paths and the capture/replayer
primitive) is bounded by AMQP_LIVENESS_TIMEOUT, with the recorded classification: an expired
send stays RETRYABLE (PublishError::Amqp carrying TimedOut; the capture arm’s Failed) —
the broker may have accepted the frame, and the confirm layer plus the consumer-side inbox
dedup own the resulting duplicate.
The cost: since #1530, zero stranded threads. lapin spawns its lapin-io-loop OS thread
before the handshake completes and exposes no cancel-safe teardown; under the original #1528
timeout-and-drop posture each abandoned attempt stranded one thread on the black-holed socket
until the peer closed (measured: exactly +1 per attempt, reaped ≤3 s after peer close).
connect_owned removed the strand outright: craig-mq dials the TCP socket itself (bounded — no
lapin machinery exists yet), hands the connected stream to Connection::connector through a
single-shot cell, and holds a dup’d fd in a SocketReaper across the handshake — when the bound
expires, or ANY outer bound drops the future mid-handshake, the reaper’s Drop shuts the socket
down and the io-loop reads an error and exits immediately.
owned_connect_strands_no_io_loop_threads is the regression contract on BOTH kill paths (own
expiry; outer drop) — it counts lapin-io-loop threads by name and requires ZERO after each
abandoned-attempt volley while the peer STILL holds the socket. The AMQPS scheme falls back to
lapin’s own dial under the same bound (no deployment terminates TLS at the client today) and
keeps the old strand-until-peer-close cost — recorded, not hidden. The contract reads
/proc/self/task and is therefore cfg(target_os = "linux"): it holds on CI and on Linux
workstations, and is simply absent elsewhere — the cost claim is unverified on a non-Linux dev
box, not asserted there.
Pre-1.0 behavioral change: against an unreachable-but-accepting broker, a service boot now fails after 15 s where it previously hung indefinitely.