Cross-family review of PR #15111 verified the broadcast Task owner repair in #15106, then surfaced a separate residual in the Task wake path: the transition is durable, but its wake event has no durable typed identity. Source inspection confirms that a later unrelated MESSAGE rewrite can re-expose the last transition and produce a second wake after the in-memory coalescing window has closed.
This follow-up keeps the #15106 source repair bounded and gives the downstream lifecycle/wake consumer an immutable event fact before more awareness surfaces consume it.
The Problem
SQLite writes one generic GraphLog row for every MESSAGE node update. The shared TASK_STATE_CHANGED evaluator then reads the MESSAGE current state and lastModifiedAt from the node at evaluation time.
That means the GraphLog row does not prove that a Task transition occurred. The following sequence reproduces the semantic defect in source:
transitionTask commits Working → Completed and updates lastModifiedAt;
the node-update trigger appends a generic nodes GraphLog row;
the wake evaluator emits Completed;
after the coalescing window, markRead, archiveMessage, deleteMessage, or another whole-node upsert updates the same MESSAGE;
the trigger appends another generic nodes row;
the evaluator sees the unchanged Completed + lastModifiedAt and emits the same logical transition again with a fresh random eventId.
The current daemon deduplicates taskId + newState + lastModifiedAt only inside one in-memory coalescing window. Restart, resync, or a later generic rewrite therefore has no durable event identity to compare. Two real fast transitions can also collapse to the latest node snapshot when old GraphLog rows are evaluated after the node advances.
The Architectural Reality
ai/graph/storage/SQLite.mjs owns GraphLog schema and generic node invalidation triggers.
ai/services/memory-core/MailboxService.mjs owns authoritative Task transitions and TTL expiry.
ai/services/memory-core/heartbeatPulseEvaluator.mjs is the shared trigger-semantic owner used by both WakeSubscriptionService and the standalone wake daemon.
ai/services/memory-core/WakeSubscriptionService.mjs and ai/daemons/wake/daemon.mjs consume GraphLog; they must not infer a transition from an unrelated current-node rewrite.
GraphLog still needs generic nodes rows for cache invalidation. This ticket adds an immutable typed Task-transition fact; it does not replace ordinary graph invalidation.
The Agent OS structure map was run at 2026-07-12T22:33:56.195Z. Existing owners are ai/graph/storage, ai/services/memory-core, and ai/daemons/wake; no new service directory is introduced.
The Fix
Extend GraphLog with a backward-compatible optional typed-event payload owned by SQLite storage.
In the same SQLite transaction that commits a successful Task state change, append exactly one task_state_changed row with a server-generated durable eventId and immutable snapshot: taskId, previousState, newState, originator, canonical assignee, assignment authority, and lastModifiedAt.
Emit the same typed row for successful TTL expiry. Failed, unauthorized, state-mismatch, and race-loser paths emit none.
Make both wake consumers match TASK_STATE_CHANGED only from the typed row and its stored snapshot. Generic nodes rows remain cache invalidation only.
Preserve the durable eventId in MCP notifications, daemon queue entries, resync output, retry state, and coalescing. Coalescing remains batching; it is not the event-identity source.
Contract Ledger
Target Surface
Source of Authority
Proposed Behavior
Fallback / Edge Case
Docs
Evidence
GraphLog typed Task row
Successful Memory Core Task transaction
Store one immutable transition snapshot plus durable eventId
Legacy generic nodes rows never classify as Task transitions
SQLite JSDoc/schema note
migration + atomicity unit specs
transitionTask and TTL expiry
MailboxService state/RBAC transaction
Append typed event only when the state write commits
mismatch, denial, and race loser emit zero events
JSDoc
success/failure/race specs
heartbeatPulseEvaluator match
Typed GraphLog event payload
Match source event, not current MESSAGE snapshot
missing/malformed payload fails closed
JSDoc
pure evaluator matrix
WakeSubscriptionService resync/notification
Typed eventId and snapshot
Return stable eventId and exact historical state
repeated resync preserves identity; transport may remain at-least-once
OpenAPI/JSDoc if shape changes
resync + restart specs
Standalone wake daemon
Same typed eventId and snapshot
Queue/retry/coalesce without inventing identity
generic later MESSAGE update produces no Task event
JSDoc
beyond-window rewrite + two-transition specs
Decision Record Impact
Aligned with ADR 0035. The ticket makes a source-owned Task transition a durable, citeable fact for the lifecycle frontier. It does not alter the four-surface composition or add a new authority.
Decision Record: Not needed — this is a bounded source/wake correctness repair beneath the accepted composition.
Acceptance Criteria
Every successful Task state transition appends exactly one typed GraphLog event in the same transaction as the state write.
Each typed event has a stable server-owned eventId and immutable previousState, newState, taskId, originator, canonical assignee, assignment authority, and lastModifiedAt.
Failed authorization, expected-state mismatch, malformed routing, and race-loser paths append no typed event.
TTL expiry emits the same typed contract exactly once per committed expiry.
Generic MESSAGE node updates still drive cache invalidation but never match TASK_STATE_CHANGED.
transition → coalescing-window expiry → markRead/archive/retract produces one logical Task event, not two.
Two Task transitions committed before one poll/resync are returned as two ordered events with their original snapshots, even when the current MESSAGE has advanced.
Repeated resync and daemon restart preserve the same eventId for the same GraphLog transition row.
Both WakeSubscriptionService and the standalone daemon consume the same shared typed-event evaluator contract.
Existing SENT_TO_ME and PERMISSION_GRANTED behavior is unchanged.
Focused Memory Core and wake-daemon suites pass through the canonical custom Playwright unit configuration; the default Playwright command is not used.
Out of Scope
End-to-end exactly-once delivery across external adapters.
Generic wake-subscription duplication addressed by #10430/#11182.
GraphLog retention/compaction policy addressed by #12329.
Replacing GraphLog with a general event store.
Changing Task assignment/RBAC delivered by #15106 / PR #15111.
LifecycleFrontier, Fleet Manager, Bird Views, or Golden Path composition.
Avoided Traps
Extending the coalescing window: delays the duplicate but loses on restart and later rewrites.
Treating taskId + current state as event identity: repeated same-state transitions and historical resync remain ambiguous.
Suppressing all later MESSAGE GraphLog rows: breaks cache invalidation and other MESSAGE lifecycle consumers.
Storing only the transition clock on the Task node: old GraphLog rows still read the newest node state.
Live latest-open sweep: checked latest 20 open issues at 2026-07-12T22:33:56.195Z; no equivalent found.
Recent all-state A2A claim sweep: checked latest 30 messages at 2026-07-12T22:33:56.195Z; no overlapping lane claim or intent found.
Context
Cross-family review of PR #15111 verified the broadcast Task owner repair in #15106, then surfaced a separate residual in the Task wake path: the transition is durable, but its wake event has no durable typed identity. Source inspection confirms that a later unrelated MESSAGE rewrite can re-expose the last transition and produce a second wake after the in-memory coalescing window has closed.
This follow-up keeps the #15106 source repair bounded and gives the downstream lifecycle/wake consumer an immutable event fact before more awareness surfaces consume it.
The Problem
SQLite writes one generic GraphLog row for every MESSAGE node update. The shared TASK_STATE_CHANGED evaluator then reads the MESSAGE current state and lastModifiedAt from the node at evaluation time.
That means the GraphLog row does not prove that a Task transition occurred. The following sequence reproduces the semantic defect in source:
The current daemon deduplicates taskId + newState + lastModifiedAt only inside one in-memory coalescing window. Restart, resync, or a later generic rewrite therefore has no durable event identity to compare. Two real fast transitions can also collapse to the latest node snapshot when old GraphLog rows are evaluated after the node advances.
The Architectural Reality
The Fix
Contract Ledger
Decision Record Impact
Aligned with ADR 0035. The ticket makes a source-owned Task transition a durable, citeable fact for the lifecycle frontier. It does not alter the four-surface composition or add a new authority.
Decision Record: Not needed — this is a bounded source/wake correctness repair beneath the accepted composition.
Acceptance Criteria
Out of Scope
Avoided Traps
Related
Origin Session ID: f95e01ff-ba36-409a-98af-573263fab247
Retrieval Hint: "durable TASK_STATE_CHANGED GraphLog event identity unrelated MESSAGE rewrite" Retrieval Hint: "Task transition snapshot coalescing window resync duplicate"
Live latest-open sweep: checked latest 20 open issues at 2026-07-12T22:33:56.195Z; no equivalent found. Recent all-state A2A claim sweep: checked latest 30 messages at 2026-07-12T22:33:56.195Z; no overlapping lane claim or intent found.