ADR 0011: Cross-Process Op-Control Delivery via a NATS Subject
Status: Accepted Date: 2026-06 Ticket: AAASM-3883, upgraded to durable JetStream by AAASM-3885
Update (AAASM-3885). The original design below shipped over core NATS (at-most-once). The transport has since been upgraded to NATS JetStream (durable stream + awaited publish ACK) so a halt
200means persisted and will be delivered to a gateway that (re)subscribes within retention, not merely accepted onto the bus. See the AAASM-3885 update section at the end; it supersedes the “Delivery semantics” consequence.
Context
The operator kill switch for a running agent is delivered end-to-end by three pieces that already exist and are component-tested:
- Emission (AAASM-3881) —
the HTTP operator endpoints
POST /api/v1/ops/{id}/halt-agentandPOST /api/v1/ops/global/haltrecord a halt onAppState.ops_registryand callOpsRegistry::halt_agent/halt_global, which publish an op-control signal under a reserved op-id (agent:{agent_id}for an agent-wide halt,"*"for a fleet-wide halt). - Transport —
OpControlPublisher, a singletokio::sync::broadcastchannel. - Consumption (AAASM-3873) —
the gRPC
PolicyService.op_control_streamRPC subscribes to that broadcast and forwards each matching envelope to the runtime, which records it inOpControlStoreand fast-fails / blocks the agent’s next per-tool check.
The topology gap
OpControlPublisher is an in-process broadcast. In the shipped product the two
halves run in separate processes (verified definitively under AAASM-3883):
| Half | Process | Detail |
|---|---|---|
HTTP halt endpoints (AppState.ops_registry) | aa-api-server | aasm start --mode local launches this |
gRPC PolicyService.op_control_stream | aa-gateway | the only process runtimes subscribe to (aa-runtime/src/op_control.rs) |
op_control_stream reads only from the gateway’s in-process ops_publisher.
There is no shared cross-process bus for op-control today — NATS carries audit
only (assembly.audit.>), and the L1 invalidation channel does not carry
op-control.
A previous attempt (PR #1308,
reverted) injected one in-process OpControlPublisher into both halves. That works
only inside a single process; across the real two-process split, the aa-api publisher
broadcasts to a channel with no subscriber, so the HTTP halt would return 200
while silently dropping the halt — strictly worse than the honest 503 for a kill
switch. The wiring was reverted and the ticket moved back for design.
Decision
Introduce a shared NATS subject that carries op-control signals between the two processes, mirroring the existing NATS audit subsystem rather than inventing a parallel one.
Subject naming
Mirrors the audit convention assembly.audit.<tenant>.<agent>:
- Agent-wide / per-op halt →
assembly.opcontrol.<tenant>.<agent><tenant>= the agent’sorg_id, falling back toteam_id, thendefault.<agent>= the agent id (subject-token-sanitized; non-[A-Za-z0-9_-]→_).
- Fleet-wide halt →
assembly.opcontrol.global.
The gateway subscribes with the wildcard assembly.opcontrol.>, so subject tokens
are for routing/observability only; the gateway filters per subscriber exactly as it
does for the in-process broadcast.
Message schema
A small JSON envelope, reusing the existing op-control reserved-key semantics so producer and consumer can never drift:
{ "org_id": "...", "team_id": "...", "agent_id": "...",
"op_id": "agent:{id}" | "*" | "{trace}:{span}",
"signal": 1, "global": false }
op_idcarries the same reserved key the in-process path uses (aa_runtime::op_control::agent_halt_op_id/GLOBAL_HALT_OP_ID).signalis the wireOpControlSignaldiscriminant (Pause/Resume/Terminate).globalmarks a fleet-wide halt so the gateway forwards it to every subscriber.
Publish side (aa-api)
OpsRegistry gains an optional OpControlNatsPublisher. The halt handlers call
halt_agent_delivery / halt_global_delivery, which:
- publish the envelope to NATS and
flush()(forcing the write to the server) when a NATS publisher is attached, then - fall back to the in-process publisher only when no NATS publisher is configured (single-process / co-located mode).
Consume side (gateway)
The gateway boot (serve_tcp / serve_uds) always constructs the in-process
OpControlPublisher and attaches it to PolicyServiceImpl via with_ops_publisher
— this alone un-inerts op_control_stream (it no longer returns Unavailable). When
NATS is configured it additionally spawns a bridge task that subscribes to
assembly.opcontrol.> and forwards every received envelope into that same in-process
broadcast (publish for per-agent, publish_global_halt for global). The runtime
filtering, reserved-key matching, and sticky-terminate semantics are unchanged.
Configuration
Reuses the existing NATS deployment. Activated by AA_OPCONTROL_NATS_URL
(OpControlNatsConfig::from_env), exactly mirroring the audit consumer’s
AA_AUDIT_NATS_URL env activation. When unset, both processes keep their existing
in-process behavior — no new mandatory config, no behavior change for local mode.
Fail-mode (never a silent-drop 200)
- NATS configured, publish/flush fails → the halt endpoint returns a real
503(HaltDelivery::ChannelError), never a false200. - No op-control channel configured at all →
503(HaltDelivery::NotConfigured) — the pre-existing honest behavior. - This restores the kill switch’s core invariant: an operator is never told an agent was halted when it was not.
Consequences
- Multi-replica. Any aa-api replica can publish; every gateway replica’s bridge
subscribes, so a horizontally-scaled gateway delivers the halt to whichever replica
a given runtime is streamed to. (A runtime is connected to exactly one gateway
replica’s
op_control_stream; the NATS fan-out reaches all replicas.) - Co-located / local mode coexistence. With no
AA_OPCONTROL_NATS_URL, a single-process deployment uses the in-process publisher unchanged. The two paths are mutually exclusive per process (NATS preferred when configured), so a halt is never double-delivered from one publisher. - Delivery semantics. (Superseded by AAASM-3885 — see the update section
below.) Core NATS pub/sub is at-most-once live delivery — the same semantics
as the in-process broadcast it extends (which also drops when no subscriber is
connected).
flush()makes NATS unavailability an honest error, but a halt published while a gateway is momentarily disconnected from NATS is not redelivered. A JetStream-durable op-control stream is a deliberate future enhancement, out of scope for this additive-wiring fix. - No new dependency / no new feature flag.
async-natsis already a non-optional dependency viaaa-runtime(the audit publisher), so the op-control module is always compiled and activated purely by runtime config — matching the always-on runtime audit publisher rather than the postgres-coupled, feature-gated audit consumer. - Per-op cross-process delivery is bounded by registry locality. The op→agent
map populated by
check_actionlives in the gateway process; the robust operator kill path is the agent-wide / global halt, which binds to the server-side agent identity and is what this ADR makes cross-process. Per-oppause/terminateremain same-process for now.
Update — AAASM-3885: Durable JetStream Delivery
Ticket: AAASM-3885 (found in the AAASM-3883 review). This section supersedes the “Delivery semantics” consequence above; everything else (subject naming, envelope schema, fail-mode, multi-replica, per-op locality) is unchanged.
Problem
Core NATS is at-most-once: a successful publish + flush() only confirms bytes
reached the NATS server, not that any gateway bridge consumed the halt or that a
runtime halted. If no gateway is subscribed at that instant (restart / rollout
window, partition), the halt endpoint returns 200 while the halt reaches no
runtime — wrong for a safety kill switch, where 200 should mean “the agent was
provably told to halt.”
Decision
Carry op-control on a durable NATS JetStream stream instead of core pub/sub. The subject scheme, JSON envelope, and reserved keys are unchanged — only the transport guarantee changes.
Stream
- Name:
AA_OPCONTROL. - Subjects:
assembly.opcontrol.>(the same wildcard the bridge consumed before). - Retention:
Limitswith a boundedmax_age= 10 minutes,Filestorage. Halts are tiny and time-sensitive: a bounded max-age covers a gateway restart / rollout window (a halt published in that gap is redelivered to the gateway that resubscribes within it) while keeping the stream small and preventing an indefinitely-replayed stale kill switch. File storage makes the halt survive a NATS server restart as well. - Created idempotently at boot by every process via
create_or_update_stream(ensure_op_control_stream). The NATS server must have JetStream enabled (-js) — a deployment requirement; without it, stream setup / publish ACK fails and the halt endpoint honestly returns503.
Publish (aa-api)
OpControlNatsPublisher now holds a jetstream::Context and awaits the publish
ACK (context.publish(subject, payload).await?.await?). The second await resolves
the JetStream server ACK, which only arrives once the message is persisted in
the stream. The ACK wait is bounded by a timeout so the operator surface never
hangs. The aa-api process does not create the stream — that is the gateway’s
job — so a publish before the stream is ready is an honest failure (below).
Consume (gateway)
The bridge ensures the stream, then reads it via an ephemeral JetStream consumer
with DeliverPolicy::All and explicit ack:
- Ephemeral, not a shared durable consumer. A named durable consumer shared by all gateway replicas would queue-group halts to a single replica, so a runtime streamed from a different replica would miss its kill switch. An ephemeral consumer per replica gives each replica its own copy of every halt — preserving the AAASM-3883 multi-replica fan-out.
DeliverPolicy::Allreplays everything still within retention when the consumer is (re)created. This is what delivers a halt published while this gateway had no consumer attached — the durability property of this ticket. The stream’s retention, not consumer durability, is what makes the halt survive; the consumer just replays from the start of the retained stream on each (re)subscribe.- Re-reading an already-applied halt after a gateway restart is safe because
Terminateis sticky/idempotent in the runtimeOpControlStore(andackremoves it from the consumer’s pending set during steady state).
What a halt 200 now guarantees
The halt was durably persisted to the AA_OPCONTROL JetStream stream (the
server ACK was received). Every gateway whose bridge is subscribed receives it
live, and any gateway that (re)subscribes within the retention window
(10 min) is replayed the persisted halt. An operator is never told an agent was
halted when the signal was dropped onto a bus with no consumer.
Residual caveats
- Not an end-to-end runtime-ack.
200means durably persisted and will be delivered to a (re)subscribing gateway within retention, not a per-runtime acknowledgement that a specific agent process applied the halt. A runtime that is disconnected for longer than the 10-minute retention, or permanently gone, is not tracked. A true end-to-end runtime-level ack would require a return path from the runtime and is out of scope. - Per-op vs agent-wide. Unchanged from the base ADR: per-op
pause/terminateremain bounded by op→agent registry locality. The durable cross-process path is the agent-wide / global halt, which binds to the server-side agent identity. - Deployment requirement. The op-control NATS server must run with JetStream
enabled (
-js). This reuses the existingAA_OPCONTROL_NATS_URLconnection — no new config — but a non-JetStream server now degrades op-control to honest503s rather than the previous best-effort core-NATS delivery.
Fail-mode (unchanged invariant, JetStream-specific triggers)
- JetStream unavailable / stream not ready / publish not ACKed → real
503(HaltDelivery::ChannelError), never a false200. - No op-control channel configured at all →
503(HaltDelivery::NotConfigured).
Update — AAASM-3886: Fail Loud on a Stream-Config Mismatch
Ticket: AAASM-3886 (found in the AAASM-3885 review). Hardens the gateway bridge against an operator misconfiguration; the transport guarantees above are unchanged.
Problem
JetStream stream config is partly immutable (storage type, retention policy;
subjects are mutable). If an operator pre-provisions the AA_OPCONTROL stream
with an incompatible immutable config, create_or_update_stream
(ensure_op_control_stream) can never reconcile it. Before this change the bridge
reconnect loop treated that failure exactly like a transient NATS outage — a quiet
warn! + backoff — so it looped on stream/consumer setup without ever
consuming, while op-control publishes kept ACKing (200) against the existing
stream. Net result: an operator halt is persisted and returns 200, yet no
runtime is ever told to halt — a silent non-delivery of the kill switch.
Decision
The bridge now classifies its setup failures
(OpControlNatsError::is_stream_setup_failure): a Stream / Consumer failure
after a successful connect is a non-transient fail-loud condition (the
canonical trigger is the incompatible pre-provisioned stream; JetStream-disabled
and otherwise-unconsumable streams land here too), whereas a Connect failure
stays the ordinary transient reconnect path.
On the fail-loud condition the bridge:
- emits a prominent, actionable
tracing::error!— it states that op-control delivery is DOWN (halts may be ACKed yet never reach a runtime) and names the likely cause (incompatible immutable stream config / JetStream disabled) and the remedy (reconcile the stream); - records
BridgeHealthState::StreamUnavailableon the new cloneableOpControlBridgeHealthhandle and drives theaa_op_control_bridge_upgauge to0, so the condition is observable (and assertable in tests / wireable to a future readiness probe) rather than buried in a retry loop.
It still retries with backoff so that repairing the stream recovers delivery automatically — but every failed attempt now screams instead of whispering.
Why publish is not made honest-fail in this state
The dangerous case is structurally a cross-process one. The publisher lives in
the aa-api process and, by this ADR’s design, does not own or validate the
stream — that is the gateway’s job. When an incompatible stream exists whose
subjects still cover assembly.opcontrol.>, the publisher’s publish + ACK
succeeds against that present-but-unconsumable stream; the publisher has no way
to know the gateway cannot consume it. (The pre-existing honest-503 paths still
fire when the stream is absent or the publish is genuinely un-ACKed — see the
AAASM-3885 fail-mode above.) Making publish honest-fail here would require the
publisher to validate the gateway’s consume-side stream config, which crosses the
process boundary this ADR deliberately keeps clean. The accepted contract is
therefore: the gateway fails loud / reports unhealthy; the publisher’s 200
in this specific misconfiguration is a known residual, surfaced by the gateway’s
loud error and StreamUnavailable health rather than by the publish call.
Benign per-reconnect full-window replay (note)
DeliverPolicy::All on an ephemeral consumer means the replay catch-up
described in the AAASM-3885 consume section happens on every NATS reconnect,
not only on a process restart: each time bridge_once re-creates its consumer it
replays everything still within the stream’s retention window (≤ 10 min). This is
safe and intended, not a bug:
Terminateis sticky/idempotent in the runtimeOpControlStore, so re-reading an already-applied halt is a no-op;Pause/Resumeconverge by FIFO order — the stream preserves publish order, so replaying the retained window re-applies the last intended state;- the bounded
max_agekeeps the replayed window small and prevents an indefinitely-replayed stale kill switch.
So a flapping NATS connection costs at most a brief, idempotent re-application of the last few minutes of halts — never a missed or contradictory one.
Last updated: 2026-06-28 by Bryant Liu