Orchestrata Ultimate › Plans › kernel-dispatch-reply-hardening
Kernel Dispatch-Reply HARDENING — Design Addendum (F4 / F5 / F7+F8)
Source: plans/kernel-dispatch-reply-hardening.md at 8a2cb42ad · last changed 2026-08-19 · first committed 2026-08-05 · 539 lines.
Prose pending.
Plan text
Kernel Dispatch-Reply HARDENING — Design Addendum (F4 / F5 / F7+F8)
Status: DESIGN ONLY (RUL-022 — no implementation in this artifact)
Parent: plans/kernel-dispatch-reply-design.md (7 packets, all shipped in
commit 9914d662 on feat/cxp-direct-transport; app container redeployed on it)
Findings: the SET-2 whole-diff reviewer's named follow-ups, filed in the
session backlog (context.json deferred_work #393–395; report
reports/claude-failure-report-2026-08-05.md "Late addendum 4"):
- F4 — same-thread rounds: refcount memory bind, consume-on-match, bound inbox (absorbs F8 human_inbox growth).
- F5 — zero-terminal-packet paths + background-task shutdown.
- F7 — ReplyRouter eviction + unauthenticated queue minting.
Every as-built claim below was grounded via the AST toolkit
(scripts/serialize_codebase_ast.py {symbols,refs,attr-refs,source}), never by
hand-reading .py files. Specs are authoritative (RUL-013). Rulings consulted:
RUL-002 (no mocks/monkeypatch — injectable seams instead), RUL-003 (no
infra-env literal defaults; tunables MAY default), RUL-009 (observed test
runs), RUL-012 (done = wired + deployed), RUL-013, RUL-020 (async
correctness), RUL-022 (this document), RUL-023 (feature means wired), RUL-024
(pre-existing is a diagnosis, not a disposition).
1. AS-BUILT INVENTORY (post-9914d662, all AST-verified)
1.1 Kernel handler (F4 memory bind, F5 terminal contract)
| Component | Location | Facts |
|---|---|---|
| Ack-only dispatch | agents/kernel_handler.py:382-453 (_KernelPacketHandler.__call__) |
For _CHAT_SUBJECTS = {"intent","response"} (line 57) with a sink set: govern → asyncio.create_task(self._process_in_background(...)) → strong ref into _bg_tasks (init at 68-86) → immediate accepted: True receipt. finally: unregister_memory(thread_id) at 453 (sync path only). |
| Background round | agents/kernel_handler.py:290-336 (_process_in_background) |
_bind_memory → _run_intent → if not produced: return (no packet) → _build_reply → if reply_packet: sink(...) (empty dict → no packet). except Exception → sink(_build_failure_packet(...)). finally: unregister_memory(thread_id) at 336. asyncio.CancelledError is a BaseException — it ESCAPES the except Exception arm: a cancelled round sinks nothing. |
| Memory bind (handler side) | agents/kernel_handler.py:213-225 (_bind_memory) |
register_memory(thread_id, self._mem) — binds the handler's shared engine. |
| Turn persistence | agents/kernel_handler.py:171-181 (_remember) |
Uses self._mem DIRECTLY, never the registry — registry lifetime only matters DURING the volk round. |
| Reply build | agents/kernel_handler.py:246-288 (_build_reply) |
self._runner.get_last_human_message(thread_id=thread_id) — peek, not consume. Stamps reply_to via the central _stamp_reply_to:99-114 (F2 fix, all four producers funnel through it). |
_run_intent |
agents/kernel_handler.py:227-244 |
subject=="response" → respond(...) then unconditional produced=True — even when respond produced no inbox entry. submit_intent returning None → produced=False. |
| Failure packet | agents/kernel_handler.py:338-380 (_build_failure_packet) |
from_agent="KERNEL", subject="intent_failed", PacketType.ERROR, DeliveryStatus.FAILED, machine-flags {"failure": true, "assistant_status": "failed"}. |
| Governance denial | agents/kernel_handler.py:116-169 (_govern) |
Synchronous; returns success=True receipt with reply_subject="intent_denied" + stamped denial reply_packet — routed by send_packet's inline branch. NOT an F5 hole. |
1.2 Pipeline runner (F4 consume/inbox, F5 produced=False)
| Component | Location | Facts |
|---|---|---|
| Inbox | kernel/pipeline.py:140 self.human_inbox: list[CXPPacket] = [] |
Unbounded. Writers (via attr-refs --attr human_inbox): submit_intent:380, respond:444, PipelineSessionMixin._human_handler (kernel/pipeline_session.py:401 — the postman "HUMAN" delivery handler, also the landing zone for terminal qa_complete/compliance_* packets nothing ever removes). Readers: get_last_human_message:548-551 (peek only). Annotation twin at pipeline_session.py:82. |
| Reply selection | kernel/pipeline.py:534-554 (get_last_human_message(thread_id=None)) |
Thread-filtered reverse scan (P-A1). Returns without removing — the same packet can satisfy any number of waits. |
| Pipeline-side memory bind | kernel/pipeline.py:279-302 (_bind_memory_engine) → called from submit_intent (:304-388) |
Registers a fresh MemoryEngine() under the SAME thread_id (overwrite) and never unregisters — today the handler's unconditional unregister_memory cleans it up as an accident of last-writer-wins. |
| Blocked intent | kernel/pipeline.py:349-352 |
may_execute deny → submit_intent returns None → handler sees produced=False → no packet of any kind. |
respond early-out |
kernel/pipeline.py:402-404 |
Unknown thread → prints [Error] Thread not found, returns; _run_intent still reports produced=True. Reply-to-escalation branch (:440-446) also appends nothing to human_inbox. |
| Memory registry | memory/memory_context.py:96-101 (register_memory), :112-120 (unregister_memory), _providers: dict[str, Any] (module level, _lock-guarded) |
Last-writer-wins register; unconditional pop on unregister. Binders (per refs): kernel_handler (:224), kernel pipeline (:296, UNBALANCED), orchestrator pipeline_runner.py:756 (BALANCED — finally: unregister at :407-409, refcounting is a no-op there). |
1.3 Reply router + servicer (F7)
| Component | Location | Facts |
|---|---|---|
| Router | cxp/reply_router.py:44-138 (ReplyRouter: __init__:57, _pick_key:76, queue_for:97, route:114) |
Exactly 5 symbols — no eviction method exists. _pick_key: metadata.reply_to > to_agent > DEFAULT_REPLY_KEY="HUMAN" (:42). route drop-oldest at max_queue=256 (per-queue bound, traced). Per-key asyncio.Queues created on demand, held forever. Single-loop threading contract documented in the class docstring. |
| Drain-side minting | cxp/grpc_server.py:298-315 (receive_packet), :317-337 (subscribe) |
Both call self._reply_router.queue_for(request.agent_id) — any client on the insecure port mints a queue per novel agent_id, no auth, no cap on key count. |
| Router construction | cxp/grpc_server.py:144-159 (CXPServicer.__init__) |
reply_router or ReplyRouter(Tracer.default()) — injectable; the default-construction seam is where env tunables belong. |
| Single publication point | cxp/grpc_server.py:165-176 (enqueue_reply) + send_packet:199-200 inline delegation |
Confirmed: every reply (background sink AND inline receipt branch) funnels through enqueue_reply → route. |
| Consumer id mint | gateway/server.py:382 |
f"HUMAN@{uuid.uuid4().hex[:12]}" — fresh per gateway construction; every restart/test-spawn strands the predecessor's queue. Fan-out chain: server.py:583 → rpc.py:230 → handlers/cxp.py:99 + handlers/orchestrator.py:82 (F1 fix). Stamp sites handlers/cxp.py:250-251, handlers/orchestrator.py:171-172. |
| Drain cadence | gateway/server.py:109-128 (_pump_kernel_replies_once), :802-843 (_consume_kernel_replies), gateway/config.py:173 (kernel_reply_timeout_s, default window 5s) |
Windowed polling: a LIVE consumer's queue sees a queue_for touch at least every window — the fact that makes idle-TTL eviction safe. |
1.4 Shutdown paths (F5)
| Component | Location | Facts |
|---|---|---|
| Runtime boot site | agents/runtime_main.py:59-98 (_serve) |
handler.set_reply_sink(server.servicer.enqueue_reply) (:84); SIGTERM/SIGINT → asyncio.ensure_future(server.stop()) (:86-88); run_serve_lifecycle (cxp/grpc_server.py:503-544) guarantees server.stop() in finally. Nothing anywhere cancels, awaits, or drains handler._bg_tasks — pending rounds die in asyncio.run teardown's cancel-all, and (§1.1) cancellation sinks no terminal packet. |
| Monolith boot site | kernel/cli_main.py:642-662 (boot: handler :655, server :658, sink :662) + _CXPKernelServerLifecycle:323-332 (stop → server.stop(grace=1)) |
Same hole: the lifecycle object stops only the server. |
| Server stops | cxp/grpc_server.py:488-492 (CXPServer.stop), cxp/direct_cxp_adapter.py:170-196 (DirectCXPServer.stop) |
Both stop transport only. DirectCXPServer.stop is the in-repo precedent for a deliberate, deadlock-aware drain discipline. |
1.5 Regression floor (existing tests on these paths — must stay green)
tests/cxp/test_reply_router.py, tests/cxp/test_receive_packets_per_consumer.py,
tests/agents/test_kernel_handler_ack_only.py (10),
tests/kernel/test_get_last_human_message_thread_filter.py,
tests/gateway/test_reply_consumer_addressing.py (3),
tests/gateway/test_cxp_send_receipt_check.py (2),
tests/gateway/test_kernel_reply_consumer.py (3),
tests/gateway/handlers/test_cxp_kernel_dispatch.py,
tests/agents/volks/test_memory_context.py,
tests/cxp/test_direct_transport*.py.
2. SPEC ANCHORS (RUL-013; ids verbatim from checklists)
- SPEC-001 US-002 (
specs/001-kernel/checklists/requirements.md— this story's checklist numbers its reqsREQ-002-NNN): REQ-001-US-002-008 (offline volks/consumers), REQ-001-US-002-019 (delivery acks), REQ-001-US-002-030 (handle queue overflow gracefully), REQ-001-US-002-031..035 (status queued/dispatched/ delivered/acknowledged/failed), REQ-001-US-002-037 (timestamps), REQ-001-US-002-038 (notify sender of status changes), REQ-001-US-002-039 (message tracing across hops). - SPEC-000 US-004: REQ-000-033..041 —
submit_intent/respondcontract; this design changes NEITHER signature's public shape (a keyword with a compatible default onget_last_human_messageonly). - SPEC-002: REQ-002-US-001-026 — notify sender of permanent failures.
- SPEC-014: REQ-014-001/002 — subscribed clients receive CXP packets as they flow.
- SPEC-032 G243: REQ-032-053/054 — never fabricate a conversational
reply; unavailability/failure must be explicit and machine-detectable. Both
new terminal packets in this design follow the shipped
assistant_statusconvention and are sent fromKERNEL, never a volk alias. - SPEC-010 (US-010-002 cross-session memory): REQ-010-017 (session/task scope filters on recall) + REQ-010-021 (memories selected by relevance to current context) — the recall path the F4 memory-bind fix keeps alive mid-round.
3. FINDING F4 — same-thread rounds (memory refcount, consume-on-match, bounded inbox)
3.1 Defect statement (three sub-defects, all unlocked by the epic's ack-only change)
Before 9914d662, one thread had at most one in-flight round (the send RPC blocked). Ack-only dispatch made same-thread concurrency the NORMAL case — a user sends a follow-up the moment the ack renders — and three shared structures were never upgraded for it:
F4a — memory bind is last-writer-wins + first-finisher-unbinds.
_providers is a plain dict. Round R1: handler binds self._mem
(kernel_handler.py:224); submit_intent then OVERWRITES with a fresh
MemoryEngine() (pipeline.py:296) that nothing ever unregisters. Round R2
(same thread T, concurrent): binds again. Whichever round finishes FIRST runs
unregister_memory(T) (kernel_handler.py:336) and pops the mapping while
the other round's volk is mid-LLM — its recall() is best-effort and
silently returns "".
Failure scenario: M1 arrives (R1, measured 11.5–21.1s round). At t=3s the
user sends M2 (R2, response on T). R2 completes at t=8s → unbind. R1's
concierge recall at t=12s gets empty context → answers M1 with no memory of
the conversation, silently — no error anywhere.
F4b — reply matching is peek-not-consume.
get_last_human_message(thread_id=T) (pipeline.py:548-551) returns the
newest matching packet WITHOUT removing it, and the respond path reports
produced=True unconditionally (kernel_handler.py:234-236) even when it
appended nothing (pipeline.py:402-404 unknown-thread early-out; :440-446
reply-to-escalation branch).
Failure scenario: R1 emits reply P1 (inbox append at t=10, sunk at t=10).
R2's round routes its outcome to a volk escalation — no inbox append — yet
produced=True; R2's _build_reply re-matches P1 and re-emits it as the
terminal answer to M2: a stale duplicate presented as a fresh reply. The
symmetric race (R1 slow to build, R2's P2 lands first → both builds match P2,
P2 emitted twice, P1 never) is reachable on the same structures.
F4c (absorbs F8) — human_inbox is unbounded.
Three writers (pipeline.py:380, :444, pipeline_session.py:401 — the
last also receives terminal qa_complete/compliance_* packets), zero
consumers. A long-lived kernel process (the app container runs for weeks)
accretes every human-directed packet forever.
FT-correctness hazard named en passant (RUL-024): appends run on
asyncio.to_thread/sync-router worker threads while _build_reply's
reversed() scan runs on the event loop — mutation during iteration raises
RuntimeError; on 3.14t this is a live race today. The fix below removes it
(snapshot-scan) rather than stepping over it.
3.2 Chosen design
- D-H1 — Refcounted provider registry (pattern: reference-counted
registry).
memory/memory_context.py:_providers: dict[str, tuple[Any, int]]—register_memoryincrements (keeps the NEWEST engine as the provider; the count tracks binders),unregister_memorydecrements and pops only at zero (cache purge stays at zero-time). Module function signatures unchanged → zero caller edits; the orchestrator's balanced pair (pipeline_runner.py:756/:407-409) is naturally a no-op under refcounting. - D-H2 — Balance the unbalanced binder.
submit_intentwrapsroute_syncintry/finally unregister_memory(thread_id)around its_bind_memory_enginebind — round-scoped, exactly like the orchestrator twin. Safe because_rememberusesself._memdirectly (kernel_handler.py:171-181); nothing reads the registry after the round. (D-H2 REQUIRES D-H1 first: without refcounting, submit_intent's unbind would strip a concurrent round's binding — the same defect relocated.) - D-H3 — Consume-on-match (pattern: claim check).
get_last_human_message(thread_id: str | None = None, consume: bool = False): whenconsume=Trueand a match is found, remove it fromhuman_inbox. Scan over a SNAPSHOT (list(self.human_inbox)), thenremove— fixes the FT iteration race for both modes._build_reply:269passesconsume=True. Each inbox entry now satisfies exactly one wait; a round that matched nothing falls through to F5's no-reply terminal (D-H5) instead of re-emitting history. DefaultFalsekeeps every other caller byte-identical (REQ-000-039/040 untouched). - D-H4 — Bounded inbox.
human_inboxbecomescollections.deque[CXPPacket]withmaxlen=human_inbox_max(newPipelineRunner.__init__tunable, literal default 256 — RUL-003 inverse: a tunable, not an infra endpoint; mirrors ReplyRouter's drop-oldest 256). All verified usage forms (append,reversed,[-1],remove) are deque-native. Annotation twin updated atpipeline_session.py:82. Steady-state size stays near zero once D-H3 consumes matched replies; the cap bounds session-mixin residue (qa_completeetc.). Traces REQ-001-US-002-030.
3.3 Alternatives considered
| Option | Verdict |
|---|---|
Per-round correlation ids (in-reply-to message_id threaded through postman) |
REJECTED for this packet set: a SPEC-002/SPEC-004 wire-contract change across route_sync; consume-on-match + thread filter delivers the filed guarantee ("earlier rounds' replies can't satisfy later waits") with a 1-keyword change. Correlation ids remain the eventual answer IF per-round (not per-thread) attribution is ever required — filed as a spec follow-up, not designed here. |
Per-thread asyncio.Lock serializing background rounds |
REJECTED: changes user-visible concurrency semantics (a slow round delays the next round's execution), does nothing for the memory-bind defect (the pipeline binder still overwrites), and holds tasks alive longer at shutdown (worsens F5). |
Refcount inside _KernelPacketHandler (count its own bg tasks per thread) |
REJECTED: the handler is not the only binder — pipeline._bind_memory_engine and the orchestrator path also register; correctness requires the count at the registry. |
| Per-thread inbox dict replacing the shared list | REJECTED: restructures PipelineRunner + PipelineSessionMixin (annotation contract at pipeline_session.py:77-82) for no additional guarantee over filter+consume+bound. |
4. FINDING F5 — zero-terminal-packet paths + background-task shutdown
4.1 Defect statement
The parent design's D3 terminal contract — "one terminal packet per accepted
intent" — has three verified holes; each ends an ACKED (accepted: True)
round with the sender hearing NOTHING, violating REQ-001-US-002-035/038 and
REQ-002-US-001-026:
produced=False—submit_intentreturnsNone(Prologmay_executedeny,pipeline.py:349-352) →_process_in_backgroundreturns without sinking (kernel_handler.py:322). (The parent design consciously kept no-packet semantics here for the queued-for-offline case; the reviewer's F5 supersedes that call: once the ack promises a background round, silence is indistinguishable from loss. This addendum AMENDS D3.)produced=True, no match —_build_replyfinds no inbox packet for the thread →reply_packet={}→if reply_packet:skips the sink (kernel_handler.py:326-328). Reachable today viarespond's unknown-thread early-out (pipeline.py:402-404) and its reply-to-escalation branch (:440-446); D-H3 (consume-on-match) makes this path MORE frequent by design — F5's terminal must land before or with F4's consume packet (sequencing constraint, §7).- Shutdown cancellation — nothing cancels/drains
_bg_tasks(runtime_main.py:86-88stops only the server;cli_main.py:332likewise); pending rounds are cancelled byasyncio.runteardown, andCancelledError(aBaseException) escapes theexcept Exceptionarm (kernel_handler.py:331) — no failure packet, mid-round work silently lost, "Task was destroyed but it is pending" noise. Failure scenario: this is the DEPLOY-TIME NORM, not an edge — everydeploy/orchestrata-test/kernel restart (RUL-012's own acceptance step) cancels in-flight rounds; the UI thread that was acked waits forever across the restart.
4.2 Chosen design
- D-H5 — Total terminal function (pattern: template method over the
round + a shared terminal-packet builder).
_process_in_backgroundguarantees exactly onesink()on every exit path:- genuine reply (unchanged), or
intent_failedon exception (unchanged), or- NEW
intent_no_replyterminal whenproduced=FalseOR no inbox match: built by_build_no_reply_packet, sharing a new private_build_terminal_packet(thread_id, subject, delivery_status, payload, reply_to)core with_build_failure_packet(extraction refactor — one dict shape, two thin wrappers; both keep funneling addressing through_stamp_reply_to). Shape:from_agent="KERNEL",to_agent="HUMAN",subject="intent_no_reply",delivery_status=DeliveryStatus.DELIVERED(the round COMPLETED; there is simply no reply — not a failure), timestamp (REQ-001-US-002-037), payload{"text": "[automated notice] the assistant completed processing without a reply.", "failure": false, "assistant_status": "no_reply"}. G243-honest: machine-detectable, never conversational, never a volk alias (REQ-032-053/054 convention).
- D-H6 — Graceful drain (pattern: explicit lifecycle/disposable —
DirectCXPServer.stop:170-196is the in-repo drain-discipline precedent)._KernelPacketHandler.shutdown(grace: float | None = None):asyncio.wait(self._bg_tasks, timeout=grace); stragglerscancel()ed and awaited. Newexcept asyncio.CancelledErrorarm in_process_in_background: sink_build_failure_packet(thread_id, "kernel shutting down", reply_to)then RE-RAISE (RUL-020: never swallow cancellation). Grace default resolved at call time:os.environ.get("KERNEL_BG_DRAIN_GRACE_S", "30")— a tunable with a permitted literal default (RUL-003 inverse); 30s covers the measured 11.5–21.1s round and sits inside systemd's stop window. - D-H7 — Wiring (RUL-023): drain BEFORE transport stop at BOTH boot
sites, so still-connected consumers can receive the final terminals:
agents/runtime_main.py: signal handler becomesasyncio.ensure_future(_graceful_stop(handler, server))— a module helper that awaitshandler.shutdown()thenserver.stop().run_serve_lifecycle'sfinallyremains the idempotent backstop (unchanged).kernel/cli_main.py:_CXPKernelServerLifecyclecomposes the handler (constructor gains the reference at the:655-662boot site); itsstop()awaitshandler.shutdown()thenserver.stop(grace=1).
4.3 Alternatives considered
| Option | Verdict |
|---|---|
Distinct intent_blocked terminal for the Prolog-deny case |
DEFERRED (user-decision fork §9.3): distinguishing it requires submit_intent to return an outcome — a SPEC-000 US-004 (REQ-000-033..041) signature change. The generic intent_no_reply terminal satisfies the terminal contract now; both cases funnel through one code path. |
| Sink terminals from a task done-callback instead of in-coroutine | REJECTED: a done callback cannot tell whether the coroutine already sank its terminal without extra shared state; the in-coroutine except CancelledError arm is the direct seam and keeps RUL-020's re-raise discipline visible. |
asyncio.TaskGroup replacing _bg_tasks |
REJECTED: TaskGroup wants scoped lifetimes; __call__ is fire-and-forget by design. The shipped set + an explicit shutdown() is the minimal delta. |
Reusing intent_failed with assistant_status="no_reply" |
REJECTED: a non-failure under a failure subject is dishonest signaling (RUL-017's spirit); the vocabulary is additive and cheap. |
5. FINDING F7 — ReplyRouter eviction + unauthenticated queue minting
5.1 Defect statement
ReplyRouter (cxp/reply_router.py:44-138) has exactly five methods and no
eviction anywhere; queue_for creates on demand and entries live forever.
Three exposures:
- Rotating consumers (the normal fleet mode). Every gateway
construction mints a fresh
HUMAN@<12hex>(gateway/server.py:382); every gateway restart and every test-spawned gateway strands its predecessor's queue — bounded per-queue at 256 packets (drop-oldest) but unbounded in KEY COUNT on the long-lived app-container kernel. - Unauthenticated drain-side minting.
receive_packet:307andsubscribe:330callqueue_for(request.agent_id)for ANY caller on the insecure fleet port — one novelagent_idper call mints one dict entry, an arbitrary-rate growth vector requiring no credentials. - Spoofable reply_to / drain-steal. Nothing authenticates a consumer
id;
receive_packet(agent_id=<victim>)drains the victim's queue. Mitigated in practice by unguessability (48-bit random suffix); the legacy"HUMAN"key is predictable BY DESIGN (back-compat). Failure scenario: weeks of suite runs against 10.93.53.13 leave thousands of dead keyed queues (monotonic RSS growth, growing dict); any buggy/hostile fleet client can accelerate it without bound.
5.2 Chosen design (pattern: idle-TTL cache eviction with drain leases; lazily swept — deliberately NO background task, applying F5's own lesson)
- D-H8 —
_QueueEntryvalue object (<<dataclass>>incxp/reply_router.py):queue: asyncio.Queue,last_activity: float(monotonic),active_drains: int. Replaces bare Queue values in_queues. Router gains an injectableclock: Callable[[], float]ctor param (defaulttime.monotonic) — the RUL-002-compliant test seam (no monkeypatching time). - D-H9 — Drain lease (pattern: RAII lease).
open_drain(consumer_id) -> asyncio.Queue/close_drain(consumer_id);receive_packetandsubscribewrap their streaming loops intry/finally.active_drains > 0makes an entry eviction-immune; open/close and everyroute()refreshlast_activity.queue_forremains (route side + back-compat) and also refreshes activity. - D-H10 — Lazy sweep in
route(), at most once persweep_interval_s(tunable, default 60): evict entries withactive_drains == 0and idleness >idle_ttl_s(tunable, default 900) — traced warning per eviction (key + dropped-packet count). Secondary hard capmax_consumers(tunable, default 1024): if still over cap after the TTL sweep, evict oldest-idle INACTIVE entries until at cap (traced). Safety argument: a live gateway polls inkernel_reply_timeout_s-second windows (default 5,gateway/config.py:173), so itslast_activityis always ≤ one window old — TTL cannot bite a live consumer, and under cap pressure a flood's entries are strictly older-idle than any live consumer's. Tunables are ctor params onReplyRouter(pure DI); the default-construction site (CXPServicer.__init__:159) resolves them at call time viaos.environ.get("CXP_REPLY_QUEUE_TTL_S", "900")/..._SWEEP_S,..._MAX_CONSUMERS— tunables with permitted literal defaults (RUL-003 inverse), env-adjustable for the deploy acceptance probe. - Minting guard = boundedness, not authn. Drain-side minting REMAINS
allowed — the shipped contract explicitly promises connect-before-first-
reply (
queue_fordocstring). The guard is that minted-but-never-routed, undrained queues are precisely the first evicted (inactive + idle + empty) and total keys are hard-capped. Full authentication of the kernel gRPC surface (token handshake) is a SPEC-004/deployment-posture decision — user fork §9.2; unguessable ids are the interim capability.
Sanctioned behavior change (explicit): "a reply routed before a consumer
starts draining still arrives once it does" now holds only within
idle_ttl_s; a consumer absent longer loses its queue and contents
(traced). Everything else — precedence, per-queue drop-oldest 256, legacy
"HUMAN" key — is unchanged (§8).
5.3 Alternatives considered
| Option | Verdict |
|---|---|
| Background sweeper task | REJECTED: creates exactly the untended-task lifecycle class F5 exists to fix (another thing to drain at stop); the lazy sweep needs no shutdown story and costs O(keys) at most once per minute. |
| TTL on packets, not queues | REJECTED: bounds content, not KEY growth — empty dict entries still accrete. |
| Evict on stream close (queue lifetime = connection lifetime) | REJECTED: consumers are windowed pollers (one receive_packet call per 5s window, _pump_kernel_replies_once:109-128); a live gateway's queue would be destroyed between every window, breaking the connect-before-reply contract entirely. |
| LRU cap only (no TTL) | REJECTED as sole policy: a below-cap population of dead queues never gets cleaned; TTL is the primary collector, the cap is the flood backstop. |
| Authenticated consumer registration RPC | Genuine option, DEFERRED to user decision (proto addition + client changes on both transports; SPEC-004 territory). |
6. CONTRACT DIAGRAM
plans/contracts/kernel-dispatch-reply-hardening.mmd — changed surfaces only,
consistent with §§3–5. patternRole map for gen-skeleton:
_QueueEntry=dataclass (fully generatable) · open_drain/close_drain/
_sweep=leaf (builder-written policy) · shutdown/_graceful_stop=leaf ·
_build_terminal_packet/_build_no_reply_packet=leaf ·
register_memory/unregister_memory refcount + get_last_human_message
consume + submit_intent balance = edits to existing code (builder tasks,
not generated).
7. BUILD PACKETS (each ≤ one ~5-min builder brief; tests-first inside each)
Dependency spine: P-H1 → P-H2 (refcount before balance — see D-H2) and P-H4 → P-H3 (no-reply terminal before consume-on-match — see §4.1.2). Groups are otherwise independent and individually shippable.
Group H-A — F5 terminal contract (lands first: F4 depends on it)
| # | Packet | Files | Tests (new/extended) | Depends |
|---|---|---|---|---|
| P-H4 | _build_terminal_packet extraction + _build_no_reply_packet + always-sink _process_in_background |
agents/kernel_handler.py (290-336, 338-380) |
tests/agents/test_kernel_handler_ack_only.py: test_no_match_round_emits_no_reply_terminal (respond to unknown thread → intent_no_reply, assistant_status="no_reply", failure is False, same thread_id, reply_to propagated — REQ-001-US-002-033/038); test_no_reply_terminal_is_machine_detectable (from_agent=="KERNEL", non-conversational — REQ-032-053/054); the produced=False deny case shares this exact sink call (same code path; a real-Prolog deny fixture may add it, never a mock). Regression: all 10 existing ack-only tests unchanged. |
— |
| P-H5 | shutdown(grace) + except CancelledError → failure terminal + re-raise |
agents/kernel_handler.py |
same module: test_shutdown_drains_inflight_round (real slow _PluginVolkAdapter volk sleeping 5s, shutdown(grace=10) → genuine reply sunk, no cancel); test_shutdown_cancels_and_sinks_failure (grace=0.1 → intent_failed, error text "shutting down", delivery_status="failed" — REQ-001-US-002-035/038, REQ-002-US-001-026) |
P-H4 |
| P-H6 | Wiring: _graceful_stop helper + signal-handler swap; _CXPKernelServerLifecycle composes handler, stop() drains first |
agents/runtime_main.py (86-88, new helper), kernel/cli_main.py (323-332, 655-662) |
tests/agents/test_runtime_main.py: test_graceful_stop_drains_handler_before_server_stop (real server + slow round → terminal observed on the reply queue before stop returns); tests/kernel/test_cli_main_kernel_boot.py: lifecycle-stop twin |
P-H5 |
Group H-B — F4 same-thread rounds
| # | Packet | Files | Tests | Depends |
|---|---|---|---|---|
| P-H1 | Refcounted registry | memory/memory_context.py (96-120, _providers shape) |
tests/agents/volks/test_memory_context.py: test_double_register_single_unregister_keeps_provider, test_balanced_unregister_drops_and_purges_caches, test_newest_engine_wins_while_counted (REQ-010-021) |
— |
| P-H2 | Balance submit_intent's bind |
kernel/pipeline.py (submit_intent:304-388 try/finally) |
tests/kernel/ new test_pipeline_memory_bind_balance.py: test_registry_empty_after_submit_intent (real runner + registered plugin volk; module registry inspected — state read, not a double); test_concurrent_round_keeps_binding_until_last_unbind (two rounds, one thread — provider survives the first finisher) |
P-H1 |
| P-H3 | Consume-on-match | kernel/pipeline.py (get_last_human_message:534-554 + snapshot-scan), agents/kernel_handler.py (_build_reply:269 passes consume=True) |
tests/kernel/test_get_last_human_message_thread_filter.py: test_consume_removes_matched_packet, test_peek_default_unchanged (REQ-000-039 floor); tests/agents/test_kernel_handler_ack_only.py: test_second_round_never_reemits_first_rounds_reply (two same-thread rounds, second produces nothing → its terminal is intent_no_reply, NOT a duplicate of round 1's reply — REQ-001-US-002-033/039) |
P-H4 |
| P-H9 | Bounded inbox | kernel/pipeline.py:140 (deque + human_inbox_max ctor tunable), kernel/pipeline_session.py:82 (annotation) |
tests/kernel/ (same new module): test_inbox_bounded_drop_oldest (cap+1 appends → oldest gone, newest present — REQ-001-US-002-030); regression: _human_handler append + get_last_human_message forms on deque |
— |
Group H-C — F7 router eviction
| # | Packet | Files | Tests | Depends |
|---|---|---|---|---|
| P-H7 | _QueueEntry + drain leases + lazy TTL sweep + cap (+ injectable clock) |
cxp/reply_router.py |
tests/cxp/test_reply_router.py: test_idle_queue_evicted_after_ttl (injected fake-forward clock — DI, no monkeypatch), test_active_drain_never_evicted, test_route_refreshes_activity, test_cap_evicts_oldest_idle_inactive_first, test_eviction_traced_with_dropped_count (REQ-001-US-002-008/030); floor: all existing router tests byte-unchanged |
— |
| P-H8 | Servicer lease integration + env-tunable router construction | cxp/grpc_server.py (144-159, 298-315, 317-337) |
tests/cxp/test_receive_packets_per_consumer.py: test_queue_survives_between_poll_windows_within_ttl, test_streaming_consumer_immune_to_eviction, test_drain_minted_queue_evicted_after_close_and_ttl (REQ-014-002); parametrized over both transports (grpc + cxpt — shared servicer). Floor: per-consumer + addressing suites unchanged |
P-H7 |
Runner discipline (RUL-009, 5-min cap): scripts/run_tests.py --affected /
targeted modules only, live-observed (observe_pytest attach), kernel
required — never --skip kernel (RUL-010). No mocks/monkeypatch anywhere
(RUL-002): slow/failing volks are REAL registered _PluginVolkAdapter
handlers (kernel/pipeline.py:50-94, SPEC-015 production feature); time is
an injected clock; registries are inspected, never patched. Real infra per
env/test.env: Redis 10.93.53.11, graph 10.93.53.10, kernel container
10.93.53.13.
8. COMPAT CONSTRAINTS (the 9914d662 reply contract keeps working)
| Shipped contract | Status under this design |
|---|---|
metadata.reply_to > to_agent > DEFAULT_REPLY_KEY precedence (_pick_key:76-95) |
UNCHANGED — no edit to _pick_key. |
HUMAN@<12hex> consumer ids (gateway/server.py:382) + set_reply_consumer_id chain (rpc:230 → cxp:99 / orchestrator:82) |
UNCHANGED — no gateway edits in any packet. |
Per-queue drop-oldest 256 (route:114-138) |
UNCHANGED. The ONLY sanctioned lifetime change is D-H10 whole-queue idle eviction: never while a drain is open, never within idle_ttl_s of activity, always traced. |
Legacy unstamped traffic under "HUMAN" |
UNCHANGED routing; the "HUMAN" entry is subject to the same TTL as any key (a legacy consumer idle > TTL re-mints on its next poll; packets routed during its absence beyond TTL are dropped-with-trace — the pre-eviction behavior for such a consumer was unbounded growth, not delivery). |
Ack receipt shape (success/thread_id/reply/reply_payload/reply_subject/reply_packet + accepted) and _CHAT_SUBJECTS |
UNCHANGED. |
Governance denial = success=True + reply_subject="intent_denied" (P-A3 corrected contract) |
UNCHANGED — _govern untouched. |
get_last_human_message() no-arg / thread-filtered peek |
Byte-identical when consume is omitted. |
Reply/failure packet shapes + _stamp_reply_to funnel (F2 fix) |
Preserved; _build_terminal_packet is an extraction, both wrappers keep funneling through _stamp_reply_to. |
| Subject vocabulary | ADDITIVE only: intent_no_reply is new; intent_failed/intent_denied unchanged. UI chat-gadget rendering of the new assistant_status="no_reply" is a separate UI follow-up (non-blocking — unknown statuses pass through cxp.traffic broadcast untouched). |
register_memory/unregister_memory signatures |
UNCHANGED (refcount is internal); orchestrator's balanced pair unaffected. |
9. WIRING & DEPLOYMENT (RUL-012 / RUL-023)
Every new element's production importer, named:
| Element | Production seam |
|---|---|
_build_no_reply_packet / total terminal |
agents/kernel_handler.py _process_in_background (already wired to the sink at both boot sites via set_reply_sink). |
handler.shutdown |
agents/runtime_main.py:86-88 signal path (via _graceful_stop) AND kernel/cli_main.py _CXPKernelServerLifecycle.stop:332 — the only two build_kernel_packet_handler production sites (per refs, unchanged since the parent design §1.3). |
| Refcounted registry | Already imported at agents/kernel_handler.py:36-37, kernel/pipeline.py:40, orchestrator/pipeline_runner.py:751 — internal change, wiring inherited. |
submit_intent balance / consume / deque |
In-place edits inside kernel/pipeline.py — reached by every kernel boot. |
| Drain leases + eviction | cxp/grpc_server.py receive_packet/subscribe — reaches BOTH transports automatically (DirectCXPServer wraps the same CXPServicer, direct_cxp_adapter.py:158). Env tunables resolved at CXPServicer.__init__:159. |
Deployment: the test-fleet kernel + gateway run as systemd services in
the orchestrata-test app container at 10.93.53.13
(deploy/orchestrata-test/) — restart = deploy; each group ends with a
container redeploy. Note: the F5 drain (P-H6) changes what the restart
itself does — the redeploy that ships Group H-A is the first restart that
drains in-flight rounds instead of killing them.
Acceptance probes (post-redeploy, per group):
- H-A (F5): start a chat round against the deployed kernel, restart the
kernel service mid-round → the gateway's reply stream carries an
intent_failed("shutting down") or the genuine reply BEFORE the old process exits; zero "Task was destroyed" lines in the journal. - H-B (F4): paired same-thread probe — send M1, then M2 two seconds later on the same thread → exactly two terminal packets, distinct, no duplicate of M1's reply; kernel journal shows no empty-recall degradation for the slower round.
- H-C (F7): with
CXP_REPLY_QUEUE_TTL_S=30on the service, mint 50 throwaway ids viareceive_packet(agent_id=...), wait TTL+sweep, then confirm via traced eviction log lines that the entries are gone while the live gateway's consumer id survives and its replies still deliver 2/2.
10. NON-GOALS & USER-DECISION FORKS
Non-goals (MVP discipline, unchanged from parent unless noted): ACKNOWLEDGED
lifecycle wiring (REQ-001-US-002-034); UI rendering of intent_failed/
intent_no_reply; multi-kernel fan-in; per-round correlation ids (§3.3);
proto-level reply_to field mapping (metadata stays the carrier).
Forks needing the user (recommendations attached):
- Eviction policy constants — idle-TTL 900s +
max_consumers1024 + sweep 60s (RECOMMENDED, §5.2 safety argument) vs a pure LRU cap vs connection-lifetime queues. All are ctor/env tunables, so the choice is revisable without a code change. - Kernel gRPC consumer authentication — RECOMMEND deferring: ids are 48-bit capabilities on a fleet-internal insecure port; a token-handshake RPC is a SPEC-004 proto change. Revisit if the port ever leaves the fleet subnet.
intent_blockeddistinct terminal for Prolog-deniedsubmit_intent— RECOMMEND deferring (needs a REQ-000-033..041 signature change);intent_no_replycovers the terminal contract today.
11. AMENDMENT (2026-08-05) — F-3 accept-during-drain window
11.1 Defect statement (reviewer finding F-3, filed at the F5 landing)
_KernelPacketHandler.shutdown(grace) snapshots self._bg_tasks and drains
the snapshot — but the accept path (__call__) keeps spawning NEW rounds for
packets arriving during the (default 30s) drain window. Post-snapshot rounds
are never awaited by the drain, and their terminal packets sink into a
transport that _graceful_stop/_CXPKernelComponent.stop closes the moment
the drain returns. Residual of the F5 hole: terminal totality holds per
round, but the terminal has no live consumer.
11.2 Chosen design — D-H11: handler-seam _draining flag (pattern: gate
at the single accept seam; transport-agnostic)
_KernelPacketHandlergains_draining: bool = False.shutdown()sets it True as its FIRST action — before the task snapshot — so accept-reject and drain-snapshot cannot interleave a stray spawn. Both__call__andshutdownexecute on the kernel's event loop, so plain attribute visibility suffices — no lock (document in the docstring).- Accept path: when
_drainingand the packet would enqueue background work, DO NOT spawn. Build and return a SYNCHRONOUS rejection receipt via the existing_build_terminal_packetcore:failure=true,assistant_status="draining", error codeKERNEL_DRAINING, originalthread_id/reply_topreserved so the still-open reply stream delivers it. The caller-visible contract: a client that races a deploy gets one explicit, immediate terminal — retry against the restarted kernel is the client's move. Non-spawning subjects (pure acks/status reads) pass through unchanged. - Ordering note: this AMENDS nothing in F5's drain-then-stop sequence; the
alternative (
server.stop(grace)-first semantics) was REJECTED because it reverses the ratified ordering, kills in-flight streams mid-drain, and needs per-transport half-open behavior (grpc.aio AND DirectCXPServer), where the flag is one mechanism at the one shared seam.
11.3 Build packet
| Packet | Content | Files | Tests | Depends |
|---|---|---|---|---|
| P-H10 | D-H11 _draining flag + synchronous KERNEL_DRAINING receipt |
agents/kernel_handler.py |
tests/agents/test_kernel_handler_ack_only.py: test_accept_during_drain_gets_draining_receipt (start a deliberately slow round; call shutdown() concurrently; hand the handler a fresh intent mid-drain; assert immediate KERNEL_DRAINING terminal, _bg_tasks gained no member, and the slow round's own terminal still lands), test_draining_receipt_preserves_thread_and_reply_to, test_non_spawning_subject_unaffected_while_draining; floor: every existing shutdown/drain test byte-unchanged |
F5 (shipped e949984a) |
Executes AFTER the F4+F7 certify+ship cycle — not folded into it.