PR #12004 (Epic #11993 Sub-i closure / route-evaluation gate fix) shipped the dual-patch reality where both WakeSubscriptionService._evaluateHeartbeatPulseAgainstSubscription (service-side) AND ai/daemons/bridge/daemon.mjs::evaluateSubscription (production-direct) gate on harnessTarget === 'bridge-daemon'. Both evaluators implement the SAME logic: iterate (subscriptions × GraphLog traces) and answer "should this subscription fire for this entity?".
The dual-write is an anti-pattern. Every trigger-semantics change requires patching both evaluators in lockstep. Sub-i #11994 originally added HEARTBEAT_PULSE handling to BOTH evaluators; the gate-fix on PR #12004 had to relax BOTH. Future trigger additions, filter semantic updates, and evaluator-logic refinements all carry the same dual-write coordination tax — empirically demonstrated by my cycle-1 attempt at the gate fix (agent/heartbeat-emit-gate-fix befa2fd00) which patched only the service-side evaluator and would have left production silently broken; @neo-gpt's V-B-A on the production bridge-daemon path caught the gap.
Operator architectural verdict 2026-05-26T00:5xZ: "dual-write sounds like an anti-pattern, doesn't it => single source of truth. WakeSubscriptionService should win, daemon.mjs as a main starting point should not even know it exists."
The Architectural Shape
WakeSubscriptionService owns the evaluation substrate entirely. ai/daemons/bridge/daemon.mjs keeps its bridge-daemon-specific concerns (poll cadence, lastSyncId state file at STATE_FILE, adapter dispatch via the harness adapter set, harness-target routing) BUT delegates ALL evaluation work to WakeSubscriptionService. Bridge-daemon doesn't know GraphLog exists at the matching-logic level; doesn't import SQLite query helpers; doesn't know what entity_type === 'heartbeat_pulse' means.
Substrate-correct flow:
- Bridge-daemon's
pollLoop() checks lastSyncId from its state file
- Bridge-daemon calls
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId: lastSyncId}) — receives {events: [...], lastLogId} enriched per active subscription
- Bridge-daemon dispatches each event via its adapter set (osascript / codex-app-server / antigravity-cli / claude-cli / tmux)
- Bridge-daemon writes new
lastSyncId to state file
The current WakeSubscriptionService.resync() is per-subscription; the consolidation requires a new sibling API that returns events for ALL active bridge-daemon subscriptions in one call (avoids N × DB-reads for N subscriptions).
The Fix
Add WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId}) method that returns {events: [...], lastLogId} for all currently-active bridge-daemon subscriptions. Narrower contract than overloading resync(); avoids tangling per-subscription auth semantic with the daemon-batch path.
Delete ai/daemons/bridge/daemon.mjs::evaluateSubscription entirely. Delete the GraphLog SQL reads (getGraphLogEntries, getNodesData, getEdgesData, getDbNode). Delete the entity-map iteration. Delete the per-trigger conditional branches (SENT_TO_ME / TASK_STATE_CHANGED / PERMISSION_GRANTED / HEARTBEAT_PULSE matching).
Replace pollLoop body with:
const result = await WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId: lastSyncId});
for (const event of result.events) queueEvent(event.subscription, event.payload);
lastSyncId = result.lastLogId;
Delete now-orphan helpers in bridge/daemon.mjs: parseHeartbeatPulseEntityId (duplicates WakeSubscriptionService._parseHeartbeatPulseEntityId), any getActiveShapeCSubscriptions SQL helper if unused post-consolidation, etc. Audit for what becomes dead after the evaluator delete.
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions implementation: single GraphLog read via storage.getDeltaLog(sinceLogId), single iteration that queries active bridge-daemon subscriptions (via existing _getCandidateSubscriptions filtered on harnessTarget), per-trace-per-subscription evaluation using existing _evaluate*AgainstSubscription private helpers (already the substrate-of-record). Return enriched event payloads suitable for direct dispatch.
Test surface migration: delete daemon.spec.mjs tests that exercise the deleted evaluator paths (or rewrite them to assert "bridge-daemon delegates to service"). Add WakeSubscriptionService.spec.mjs coverage for the new batch API.
Contract Ledger
| Surface |
Source of Authority |
Behavior |
Evidence |
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId}) |
This ticket + Epic #11993 cycle-3 framing |
Single GraphLog read + per-active-bridge-daemon-subscription evaluation via existing private _evaluate*AgainstSubscription helpers; returns {events: [...], lastLogId} |
New unit test in WakeSubscriptionService.spec.mjs: matrix of trigger types × subscription presence × entity-type matches |
ai/daemons/bridge/daemon.mjs::evaluateSubscription (deleted) |
This ticket |
Function removed entirely; bridge-daemon no longer knows about GraphLog or trigger semantics |
Static grep: grep -rn "evaluateSubscription|entity_type|heartbeat_pulse|HEARTBEAT_PULSE" ai/daemons/bridge/ returns zero matches post-merge |
ai/daemons/bridge/daemon.mjs::pollLoop |
This ticket |
Calls service API, dispatches events; no SQL, no entity resolution, no trigger matching |
Existing daemon.spec.mjs end-to-end tests (the real-daemon-process tests from PR #12004) still pass against the consolidated implementation |
parseHeartbeatPulseEntityId (bridge/daemon.mjs helper) |
This ticket |
Deleted — duplicates _parseHeartbeatPulseEntityId in service |
Grep clean |
Acceptance Criteria
Out of Scope
- Bridge-daemon's adapter dispatch logic (osascript / codex-app-server / antigravity-cli / claude-cli / tmux) — that's bridge-daemon-specific delivery substrate, stays in bridge-daemon.
- Bridge-daemon's
lastSyncId state file management — process-local state, stays in bridge-daemon.
- Other consumers of
WakeSubscriptionService.resync() (e.g., harness-facing MCP callers using the per-subscription API) — keep resync() intact alongside the new batch API.
- Performance optimization beyond single-DB-read-per-poll-cycle — micro-tuning is a separate concern if profiling shows it.
Avoided Traps
- Shared helper module instead of delegation — would leave bridge-daemon knowing about GraphLog, trigger semantics, and entity types. Operator-explicit: "daemon.mjs as a main starting point should not even know it exists." Delegation is the substrate-correct shape; helper-module would be a half-measure.
- Backwards-compat preservation — none required. Bridge-daemon is process-internal; no external consumer of its
evaluateSubscription symbol. Per operator standing rule: no backwards-compat for these substrate-evolution migrations.
- Folding into adjacent lanes (#12003 candidate-discovery, #12005 daemon.mjs cleanup) — different substrate concerns. #12003 is candidate-set discovery; #12005 is orchestrator-daemon thinning; this ticket is bridge-daemon thinning + WakeSubscriptionService consolidation. Adjacent but non-overlapping.
- Touching the bridge-daemon adapter set — wake delivery shape stays as-is; only the evaluation half consolidates.
Related
- Parent Epic: #11993 (Wake substrate evolution — graduated from Discussion #11992)
- Triggering V-B-A: @neo-gpt cycle-1 review on my PR #11999 / commit
befa2fd00, then the subsequent dual-patch reality in PR #12004 (merged) — both evaluator surfaces had to be touched in lockstep
- Architectural verdict: @tobiu 2026-05-26T00:5xZ — "dual-write sounds like an anti-pattern... single source of truth. WakeSubscriptionService should win, daemon.mjs as a main starting point should not even know it exists."
- Adjacent lanes (non-overlapping):
- #12003 —
active-a2a-participants candidate discovery (touches swarmHeartbeat.mjs + SwarmHeartbeatService.mjs)
- #12005 — daemon.mjs router cleanup (PR #12006, approved, touches Orchestrator entry-point)
- #12007 — DreamService.spec noise-floor (touches different spec files)
Handoff Retrieval Hint: "Wake evaluator consolidation single source of truth bridge-daemon delegates WakeSubscriptionService evaluateDeltaForActiveBridgeSubscriptions Epic 11993 follow-up dual-write anti-pattern".
PR #12004 (Epic #11993 Sub-i closure / route-evaluation gate fix) shipped the dual-patch reality where both
WakeSubscriptionService._evaluateHeartbeatPulseAgainstSubscription(service-side) ANDai/daemons/bridge/daemon.mjs::evaluateSubscription(production-direct) gate onharnessTarget === 'bridge-daemon'. Both evaluators implement the SAME logic: iterate(subscriptions × GraphLog traces)and answer "should this subscription fire for this entity?".The dual-write is an anti-pattern. Every trigger-semantics change requires patching both evaluators in lockstep. Sub-i #11994 originally added HEARTBEAT_PULSE handling to BOTH evaluators; the gate-fix on PR #12004 had to relax BOTH. Future trigger additions, filter semantic updates, and evaluator-logic refinements all carry the same dual-write coordination tax — empirically demonstrated by my cycle-1 attempt at the gate fix (
agent/heartbeat-emit-gate-fixbefa2fd00) which patched only the service-side evaluator and would have left production silently broken; @neo-gpt's V-B-A on the production bridge-daemon path caught the gap.Operator architectural verdict 2026-05-26T00:5xZ: "dual-write sounds like an anti-pattern, doesn't it => single source of truth. WakeSubscriptionService should win, daemon.mjs as a main starting point should not even know it exists."
The Architectural Shape
WakeSubscriptionService owns the evaluation substrate entirely.
ai/daemons/bridge/daemon.mjskeeps its bridge-daemon-specific concerns (poll cadence,lastSyncIdstate file atSTATE_FILE, adapter dispatch via the harness adapter set, harness-target routing) BUT delegates ALL evaluation work to WakeSubscriptionService. Bridge-daemon doesn't know GraphLog exists at the matching-logic level; doesn't import SQLite query helpers; doesn't know whatentity_type === 'heartbeat_pulse'means.Substrate-correct flow:
pollLoop()checkslastSyncIdfrom its state fileWakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId: lastSyncId})— receives{events: [...], lastLogId}enriched per active subscriptionlastSyncIdto state fileThe current
WakeSubscriptionService.resync()is per-subscription; the consolidation requires a new sibling API that returns events for ALL active bridge-daemon subscriptions in one call (avoids N × DB-reads for N subscriptions).The Fix
Add
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId})method that returns{events: [...], lastLogId}for all currently-active bridge-daemon subscriptions. Narrower contract than overloadingresync(); avoids tangling per-subscription auth semantic with the daemon-batch path.Delete
ai/daemons/bridge/daemon.mjs::evaluateSubscriptionentirely. Delete the GraphLog SQL reads (getGraphLogEntries,getNodesData,getEdgesData,getDbNode). Delete the entity-map iteration. Delete the per-trigger conditional branches (SENT_TO_ME/TASK_STATE_CHANGED/PERMISSION_GRANTED/HEARTBEAT_PULSEmatching).Replace
pollLoopbody with:const result = await WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId: lastSyncId}); for (const event of result.events) queueEvent(event.subscription, event.payload); lastSyncId = result.lastLogId;Delete now-orphan helpers in bridge/daemon.mjs:
parseHeartbeatPulseEntityId(duplicatesWakeSubscriptionService._parseHeartbeatPulseEntityId), anygetActiveShapeCSubscriptionsSQL helper if unused post-consolidation, etc. Audit for what becomes dead after the evaluator delete.WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions implementation: single GraphLog read via
storage.getDeltaLog(sinceLogId), single iteration that queries active bridge-daemon subscriptions (via existing_getCandidateSubscriptionsfiltered onharnessTarget), per-trace-per-subscription evaluation using existing_evaluate*AgainstSubscriptionprivate helpers (already the substrate-of-record). Return enriched event payloads suitable for direct dispatch.Test surface migration: delete
daemon.spec.mjstests that exercise the deleted evaluator paths (or rewrite them to assert "bridge-daemon delegates to service"). Add WakeSubscriptionService.spec.mjs coverage for the new batch API.Contract Ledger
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId})_evaluate*AgainstSubscriptionhelpers; returns{events: [...], lastLogId}ai/daemons/bridge/daemon.mjs::evaluateSubscription(deleted)grep -rn "evaluateSubscription|entity_type|heartbeat_pulse|HEARTBEAT_PULSE" ai/daemons/bridge/returns zero matches post-mergeai/daemons/bridge/daemon.mjs::pollLoopparseHeartbeatPulseEntityId(bridge/daemon.mjs helper)_parseHeartbeatPulseEntityIdin serviceAcceptance Criteria
WakeSubscriptionService.evaluateDeltaForActiveBridgeSubscriptions({sinceLogId})exists; returns{events: [...], lastLogId}; events are enriched per the existing_wrapEventenvelope.ai/daemons/bridge/daemon.mjs::evaluateSubscriptiondeleted entirely. Static grep returns zero matches.pollLoopbody collapsed to "fetch via service + dispatch + update lastSyncId" shape. No SQL, no entity-map iteration, no trigger conditionals in bridge/daemon.mjs.parseHeartbeatPulseEntityId, any unused SQL queries). Static grep clean.@neo-gptpost-restart (substrate-correct shape proven in the same path Epic #11993 AC8 measures).pull-request §6.1.Out of Scope
lastSyncIdstate file management — process-local state, stays in bridge-daemon.WakeSubscriptionService.resync()(e.g., harness-facing MCP callers using the per-subscription API) — keepresync()intact alongside the new batch API.Avoided Traps
evaluateSubscriptionsymbol. Per operator standing rule: no backwards-compat for these substrate-evolution migrations.Related
befa2fd00, then the subsequent dual-patch reality in PR #12004 (merged) — both evaluator surfaces had to be touched in lockstepactive-a2a-participantscandidate discovery (touchesswarmHeartbeat.mjs+SwarmHeartbeatService.mjs)Handoff Retrieval Hint: "Wake evaluator consolidation single source of truth bridge-daemon delegates WakeSubscriptionService evaluateDeltaForActiveBridgeSubscriptions Epic 11993 follow-up dual-write anti-pattern".