Status:
draft · Version v0.1 · Filed 2026-04-30spec_id: SPEC-058 version: v0.1 status: draft authored_by: Donna date: 2026-04-30
SPEC-058 — Signal Delivery Single-Source-of-Truth
Status
Draft — for Frank’s review BEFORE any code changes. Author Donna.Problem
Today’s fan-out test #3 (Donna → Porsche/Lafonda/Desiree/Texi, post-restart) exposed a duplicate-delivery defect: every Acknowledgment appeared twice inprism_signals_pending results — once from the in-process strategy buffer, once from the backend HTTP drain. Texi independently observed observed_pending_duplicates: 2 from the Codex side. Frank’s reaction is the right one: this is the architecture telling us a structural invariant has been lost. We should not patch around it.
Why this is structural, not cosmetic
The SPEC-054 Phase 3 Python→Node port (PR #21, merged 2026-04-29) translated the Pythonmcp/ tree to TypeScript at mcp-node/. Today’s signal-pipeline session shipped five fixes for SPEC-054 port misses — wires that existed in the Python source but weren’t reproduced in the TypeScript port:
The duplicate Frank observed today is port miss #6 — the dedup at the merge boundary (SPEC-037 §3). All six belong to the same class: load-bearing behavior in Python comments / cross-cutting decorators that the port flattened into per-file translations.
Pre-port working contract (the thing we lost)
The Pythonmcp/server.py (deleted in PR #21) had ONE cross-cutting decorator wrapping every non-lifecycle verb response:
- Universal coverage. Every non-lifecycle verb response carried any pending signals — agents saw new arrivals in the next conversational turn without explicit polling.
- Two-source robustness. Push (LAN-cluster Redis subscriber) and pull (HTTP backend drain) both fed into the same merge point — either failing alone never lost a signal.
- Single-delivery invariant. Deduplication by
signal_idensured one logical signal was reported exactly once, no matter how many transport paths happened to carry it.
Current Node implementation (the gap)
The port split the original decorator into two unrelated code paths and dropped the dedup in both:
The structural error: the original was a cross-cutting concern that lived in one decorator. The port translated it as if it were two unrelated functions, and divergence followed.
Backend-side amplifier
backend/app/services/signal_service.py:mark_delivered_via_ws is supposed to stamp delivered_at + delivery_method='channels_push' when the WebSocket frame clears. Live signal_queue inspection shows only 1 of the last 24h’s 23 targeted signals carries delivery_method='channels_push' — the rest are 'piggyback'. So either:
- The function is racing with
drain_for_caller(which stamps'piggyback'on everyprism_signals_pendingcall), or - The function is silently no-op’ing (no log lines in either backend or session-manager containers indicate either success or failure).
/signal/poll until piggyback drain claims it. The dedup at the merge point is what makes that race tolerable; without dedup, the race is a duplicate.
Goals
- Single-delivery invariant. A signal_id reaches the agent’s
pending_signals[]field at most once, regardless of how many transport paths carried it. - Universal coverage. Every non-lifecycle verb response carries any pending signals — same as Python had.
- Two-source robustness. Push (WS) and pull (HTTP drain) both feed the merge point; either path failing alone never loses a signal.
- Honest delivery accounting.
signal_queue.delivered_atanddelivery_methodreflect what actually happened, not whichever drain raced first. - Port-miss prevention. A single test catches the entire family of SPEC-054-class regressions on any future cross-language port.
Non-goals
- Changing the wire envelope (SPEC-045 §4.2). Same shape on the WebSocket.
- Changing the categories taxonomy (SPEC-052 §3). INFO/TASK/ASK/BLOCKER unchanged.
- Changing the doorbell semantics (SPEC-044 §3.3). One coalesced notification per drain cycle, unchanged.
- Reworking SPEC-056 routing or schema. The agent_id channel + four-level hierarchy are unaffected.
- Adding any new transport. WS push and HTTP poll are the two paths; this spec just insists on a clean merge over them.
Architecture
One merge function, called from two sites
Restore the Python decorator’s contract as a single TypeScript helper:Call sites
server.ts every-verb hook — replace the strategy-only drain at lines 118-143 with mergeAndDedupPending(client, pid, identity). Apply on every non-lifecycle verb response (existing NO_PIGGYBACK_VERBS exclusion list unchanged). The pid argument is read from the verb’s bootstrap state (whichever PID this verb is operating on); identity is agentIdentity().
coordination.ts prism_signals_pending handler — replace the local+remote concat at line 67 with the same mergeAndDedupPending(client, pid, identity). Result is the deduped pending_signals[] field.
Backend stamping race resolution
mark_delivered_via_ws and drain_for_caller race on the same row. Today, drain_for_caller wins ~96% of the time (1 channels_push out of 23 in the last 24h). Two changes:
- Priority order via UPDATE conditional:
mark_delivered_via_wsalready hasWHERE delivered_at IS NULL;drain_for_callerdoes too. Order is whoever commits first. Acceptable. - Add log instrumentation:
mark_delivered_via_wsshould log at INFO (not DEBUG) on every fire — both successful (rowcount=1) and silent-loss (rowcount=0, meaning piggyback won the race). Today’s container logs show neither, which is the diagnostic gap that hid this for 24h. INFO logs would have surfaced “channels_push attempted, raced lost — piggyback claimed” frequency immediately. - Retain the priority preference in display. When both stamps exist on a row across its lifetime (impossible due to WHERE clause but if it ever happened), the row would be
channels_pushsince whichever wrote first wins. No additional code needed.
Files changed
No backend schema changes. No migrations.
Test plan — port-miss prevention
A single end-to-end test catches the ENTIRE SPEC-054-class regression family. Add to CI on every PR that touchesmcp-node/src/ OR backend/app/services/signal_service.py:
Acceptance criteria
mcp-node/src/signalMerge.tsexists and is the only placepending_signals[]is constructed.server.tsandcoordination.tsboth callmergeAndDedupPending— no inline concat or strategy-only drain remains.- The fan-out test from this morning (Donna → 4 agents, 4 acks back) returns each ack EXACTLY ONCE in
prism_signals_pending. - After 10 signals between two connected agents, ≥80% of
signal_queuerows showdelivery_method='channels_push'(validates WS stamping is firing more often than piggyback). - The end-to-end test in §test plan passes on a fresh backend with two real Node MCP processes.
- The cross-language port-miss family — points 1-5b above — each have a dedicated assertion in the E2E test.
Phased rollout
Single phase. The mcp-node side is two function changes in two files plus one new helper file. The backend side is INFO-level logging upgrades (no behavior change). All ships in one PR. Frank Cmd+Q + reopen all 5 agent tabs after merge to pick up the new mcp-nodedist/.
Out of scope
- Federation, cross-tenant signals (SPEC-056 covers).
- Replacing the WebSocket transport with anything else.
- Coalescing tuning. The current SPEC-044 §3.3 boolean flag is correct.
- Cleanup of the legacy session channel — that drops naturally when SPEC-056 cutover completes.
References
- Specs: SPEC-034 (signal delivery), SPEC-037 (backend piggyback + dedup contract), SPEC-044 (channel push), SPEC-045 (WS data plane), SPEC-048 (codex), SPEC-052 (signal cache), SPEC-054 (Node MCP shim — the port).
- Memories (port miss family):
project_spec_054_port_miss_project_id,project_spec_054_port_miss_coalescing_reset,feedback_document_port_misses. - ADRs: ADR #34 (agent_id channel), ADR-25 (lifecycle ≠ messaging multiplex).
- Live evidence (this session): signal_queue distribution showing 22 piggyback / 1 channels_push for last 24h; fan-out test #3 returning duplicate acks via
prism_signals_pending.

