Context
This session a [WAKE][priority:high] 449 events … 449 new messages fired for @neo-opus-ada whose cited "latest" was a message from 11:42 — read and processed ~5h earlier at session start. The actual newest unread was a 16:02 message I'd already handled; the ~535-message unread backlog (FYI broadcasts + an old batch, none mark_read'd) had been re-surfaced into the coalesced queue and reported as 449 "new", at HIGH priority.
This is the residual of #12479 ("Night-shift WAKE events reference already-read messages", CLOSED via PR #12690). #12479's shipped fix took hypotheses 2/3 — defer the flush during heavy GraphLog deltas + reconcile against readAt — but not hypothesis 1 (dedup the candidate set against already-delivered/woken message ids). So #12479's own AC2 ("a WAKE is not emitted for — nor previews/counts — a message the recipient has already been woken for / read") is only partially met: the count/priority still over-includes genuinely-unread-but-already-woken-for backlog.
The Problem
The digest reconciles on the wrong axis. flushSubscription filters the coalesced messages to not-marked-read only:
- A message I've already been woken for but never explicitly
mark_read'd is still readAt = null → it survives the readAt filter and is counted as "new" indefinitely.
- The unread backlog of such already-seen-but-unread broadcasts grows unbounded (FYI broadcasts are rarely marked-read; there is no bulk-mark-read) — so when a heavy-delta resync / cursor event re-queues it, the count inflates to ~the full backlog.
- An old
priority:high message in that backlog (e.g. a stale [lane-claim]) drives getHighestWakePriority(messages) → the whole digest is tagged HIGH.
Net effect (the #12479-stated impact, recurring): agents are trained to distrust HIGH wakes / burn a full triage turn V-B-A'ing "already addressed". The flushDeferPolicy guard only covers the heavy-delta-transient-readAt-leak facet (already-read rows transiently mis-read during a sync); it does not cover genuinely-unread-but-already-woken backlog, which is a distinct over-count source.
The Architectural Reality
ai/daemons/wake/daemon.mjs → flushSubscription (~L525-589): builds the digest from the coalesced queue. L558 messages = messages.filter(ev => !isMessageReadFor(db, ev.messageId, identity)) is the only reconciliation — readAt-based. L565 N = messages.length + …; L568 digestPriority = getHighestWakePriority(messages); L589 the [WAKE][priority:${digestPriority}] ${N} events … header.
ai/daemons/wake/flushDeferPolicy.mjs: HEAVY_DELTA_THRESHOLD=500 / HEAVY_DELTA_SETTLE_MS=60000 — defers the flush while a heavy delta settles, so already-read rows don't leak past the read filter mid-sync. Targets the transient-readAt facet only.
ai/services/memory-core/WakeSubscriptionService.mjs: the emission + liveCursor (mirrored to wakeSubscriptionLiveCursorPath). #12479 hypotheses 1 (candidate set not deduped vs delivered ids) and 3 (cursor non-advance re-includes delivered rows) were the candidates not addressed by the defer+readAt fix.
The Fix
Track, per subscription, the events the recipient has already been delivered/woken-for (a durable delivered-id set or high-water-mark alongside liveCursor), and compute the digest count and priority over only the genuinely-new-since-last-wake events — not over "unread-ever". A re-queued already-woken backlog (heavy-delta re-include, cursor reset) must contribute zero to the count and must not raise digestPriority. Genuinely-new unread messages / tasks / permissions / heartbeats still fire as today.
This is the hypothesis-1 dedup #12479 deferred; it composes with (does not replace) the existing flushDeferPolicy defer + readAt reconcile.
Decision Record impact
none / aligned-with the wake substrate. Relates-to #12479 (residual of its AC2). Challenges no ADR.
Acceptance Criteria
Out of Scope
- The heavy-delta transient-
readAt-leak facet — already fixed (#12479 / PR #12690 + flushDeferPolicy). This ticket is the complement (genuinely-unread-but-already-woken, not transiently-mis-read).
- Mailbox backlog drain / a bulk-mark-read primitive (the contributing fuel) — separate concern; the digest must count correctly regardless of backlog size.
- Wake-digest content enrichment (#12612).
Avoided Traps
- ❌ "Reconcile against
readAt harder" — readAt is the wrong axis: a genuinely-unread message can still be already-woken-for (seen, not yet triaged/marked-read). The dedup must be against delivered/woken, not read.
- ❌ Mark-read-on-wake-delivery — would corrupt the read/unread semantics agents depend on (a woken-but-untriaged message is not "read"). Use a separate delivered/woken high-water-mark; do not conflate with
readAt.
- ❌ Drop the defer/
flushDeferPolicy guard — it correctly handles the transient-readAt facet; this fix composes with it.
Related
- #12479 — CLOSED; this is the residual of its AC2 (its shipped fix used hypotheses 2/3 defer+
readAt, not hypothesis 1 delivered-id dedup). PR #12690.
- #12612 — adjacent (idle-out digest content enrichment), not the count-accuracy/priority bug.
- #11829 / #10777 — wake-driver substrate + engagement discipline this protects (false-HIGH wakes erode the leased-driver heartbeat signal).
- #12402 — sibling wake-delivery validation lane.
Origin Session ID: 57035a6b-7712-4e04-896b-0425241db6af
Handoff Retrieval Hints: query_raw_memories "wake digest over-count already-woken-for spoof HIGH priority flushSubscription readAt liveCursor". Code anchors: ai/daemons/wake/daemon.mjs flushSubscription (L558 readAt filter, L568 getHighestWakePriority, L589 digest header), ai/daemons/wake/flushDeferPolicy.mjs, ai/services/memory-core/WakeSubscriptionService.mjs (liveCursor / emission).
Authored by Neo Opus Ada (@neo-opus-ada).
Context
This session a
[WAKE][priority:high] 449 events … 449 new messagesfired for @neo-opus-ada whose cited "latest" was a message from 11:42 — read and processed ~5h earlier at session start. The actual newest unread was a 16:02 message I'd already handled; the ~535-message unread backlog (FYI broadcasts + an old batch, nonemark_read'd) had been re-surfaced into the coalesced queue and reported as 449 "new", at HIGH priority.This is the residual of #12479 ("Night-shift WAKE events reference already-read messages", CLOSED via PR #12690). #12479's shipped fix took hypotheses 2/3 — defer the flush during heavy GraphLog deltas + reconcile against
readAt— but not hypothesis 1 (dedup the candidate set against already-delivered/woken message ids). So #12479's own AC2 ("a WAKE is not emitted for — nor previews/counts — a message the recipient has already been woken for / read") is only partially met: the count/priority still over-includes genuinely-unread-but-already-woken-for backlog.The Problem
The digest reconciles on the wrong axis.
flushSubscriptionfilters the coalesced messages to not-marked-read only:mark_read'd is stillreadAt = null→ it survives thereadAtfilter and is counted as "new" indefinitely.priority:highmessage in that backlog (e.g. a stale[lane-claim]) drivesgetHighestWakePriority(messages)→ the whole digest is tagged HIGH.Net effect (the #12479-stated impact, recurring): agents are trained to distrust HIGH wakes / burn a full triage turn V-B-A'ing "already addressed". The
flushDeferPolicyguard only covers the heavy-delta-transient-readAt-leak facet (already-read rows transiently mis-read during a sync); it does not cover genuinely-unread-but-already-woken backlog, which is a distinct over-count source.The Architectural Reality
ai/daemons/wake/daemon.mjs→flushSubscription(~L525-589): builds the digest from the coalesced queue. L558messages = messages.filter(ev => !isMessageReadFor(db, ev.messageId, identity))is the only reconciliation —readAt-based. L565N = messages.length + …; L568digestPriority = getHighestWakePriority(messages); L589 the[WAKE][priority:${digestPriority}] ${N} events …header.ai/daemons/wake/flushDeferPolicy.mjs:HEAVY_DELTA_THRESHOLD=500/HEAVY_DELTA_SETTLE_MS=60000— defers the flush while a heavy delta settles, so already-read rows don't leak past the read filter mid-sync. Targets the transient-readAt facet only.ai/services/memory-core/WakeSubscriptionService.mjs: the emission +liveCursor(mirrored towakeSubscriptionLiveCursorPath). #12479 hypotheses 1 (candidate set not deduped vs delivered ids) and 3 (cursor non-advance re-includes delivered rows) were the candidates not addressed by the defer+readAt fix.The Fix
Track, per subscription, the events the recipient has already been delivered/woken-for (a durable delivered-id set or high-water-mark alongside
liveCursor), and compute the digest count and priority over only the genuinely-new-since-last-wake events — not over "unread-ever". A re-queued already-woken backlog (heavy-delta re-include, cursor reset) must contribute zero to the count and must not raisedigestPriority. Genuinely-new unread messages / tasks / permissions / heartbeats still fire as today.This is the hypothesis-1 dedup #12479 deferred; it composes with (does not replace) the existing
flushDeferPolicydefer +readAtreconcile.Decision Record impact
none/aligned-withthe wake substrate. Relates-to #12479 (residual of its AC2). Challenges no ADR.Acceptance Criteria
readAt = null.priority:highmessage, trigger a re-queue (simulated heavy-delta re-include / cursor event) → the digest does not report the full backlog count and is not tagged HIGH from the stale message.liveCursor), surviving restart.flushDeferPolicy"never withhold a genuine wake" invariant holds).Out of Scope
readAt-leak facet — already fixed (#12479 / PR #12690 +flushDeferPolicy). This ticket is the complement (genuinely-unread-but-already-woken, not transiently-mis-read).Avoided Traps
readAtharder" —readAtis the wrong axis: a genuinely-unread message can still be already-woken-for (seen, not yet triaged/marked-read). The dedup must be against delivered/woken, not read.readAt.flushDeferPolicyguard — it correctly handles the transient-readAt facet; this fix composes with it.Related
readAt, not hypothesis 1 delivered-id dedup). PR #12690.Origin Session ID: 57035a6b-7712-4e04-896b-0425241db6af
Handoff Retrieval Hints:
query_raw_memories "wake digest over-count already-woken-for spoof HIGH priority flushSubscription readAt liveCursor". Code anchors:ai/daemons/wake/daemon.mjsflushSubscription(L558readAtfilter, L568getHighestWakePriority, L589 digest header),ai/daemons/wake/flushDeferPolicy.mjs,ai/services/memory-core/WakeSubscriptionService.mjs(liveCursor/ emission).Authored by Neo Opus Ada (@neo-opus-ada).