Skip to main content
Status: draft · Version v0.1 · Filed 2026-04-30

spec_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 in prism_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 Python mcp/ 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 Python mcp/server.py (deleted in PR #21) had ONE cross-cutting decorator wrapping every non-lifecycle verb response:
Three properties this guaranteed:
  1. Universal coverage. Every non-lifecycle verb response carried any pending signals — agents saw new arrivals in the next conversational turn without explicit polling.
  2. 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.
  3. Single-delivery invariant. Deduplication by signal_id ensured 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 every prism_signals_pending call), or
  • The function is silently no-op’ing (no log lines in either backend or session-manager containers indicate either success or failure).
Either way, even if dedup is restored client-side, the backend keeps returning the same row from /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

  1. Single-delivery invariant. A signal_id reaches the agent’s pending_signals[] field at most once, regardless of how many transport paths carried it.
  2. Universal coverage. Every non-lifecycle verb response carries any pending signals — same as Python had.
  3. Two-source robustness. Push (WS) and pull (HTTP drain) both feed the merge point; either path failing alone never loses a signal.
  4. Honest delivery accounting. signal_queue.delivered_at and delivery_method reflect what actually happened, not whichever drain raced first.
  5. 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:
  1. Priority order via UPDATE conditional: mark_delivered_via_ws already has WHERE delivered_at IS NULL; drain_for_caller does too. Order is whoever commits first. Acceptable.
  2. Add log instrumentation: mark_delivered_via_ws should 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.
  3. 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_push since 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 touches mcp-node/src/ OR backend/app/services/signal_service.py:

Acceptance criteria

  1. mcp-node/src/signalMerge.ts exists and is the only place pending_signals[] is constructed.
  2. server.ts and coordination.ts both call mergeAndDedupPending — no inline concat or strategy-only drain remains.
  3. The fan-out test from this morning (Donna → 4 agents, 4 acks back) returns each ack EXACTLY ONCE in prism_signals_pending.
  4. After 10 signals between two connected agents, ≥80% of signal_queue rows show delivery_method='channels_push' (validates WS stamping is firing more often than piggyback).
  5. The end-to-end test in §test plan passes on a fresh backend with two real Node MCP processes.
  6. 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-node dist/.

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.

Authorship

Donna (Claude Code, mini3, session 3f36b796). 2026-04-30. Authored after Frank flagged that the duplicate is a hack symptom and demanded a structural fix with a SPEC for review BEFORE any code touches the tree.
Last modified on May 17, 2026