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 source-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.
ADR 0035 §7 explicitly requires any later leaf introducing graph events to cite/update ADR 0024's vocabulary and protected-set disposition. This leaf must register the typed GraphLog Task event as operational event vocabulary, not as a graph node or edge, and state that PROTECTED_EDGE_TYPES does not apply; retention remains governed separately by #12329.\n- ADR 0002 §6.1 defines notification eventId as a fresh per-emission transport ULID and resync as a new emission. The durable GraphLog identity must therefore surface separately as sourceEventId; transport retry retains one eventId, while reconnect resync retains sourceEventId and mints a new eventId.
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 sourceEventId (stored as GraphLog event_id) 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 durable sourceEventId in MCP notifications, daemon queue entries, resync output, retry state, and coalescing while retaining ADR 0002's per-emission transport eventId. Coalescing remains batching; it is not the source-event identity.
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 sourceEventId
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 sourceEventId and snapshot
Return stable sourceEventId plus a fresh per-emission eventId and exact historical state
repeated resync preserves source identity; transport remains at-least-once
OpenAPI/JSDoc if shape changes
resync + restart specs
Standalone wake daemon
Same typed sourceEventId and snapshot
Queue/retry/coalesce without reconstructing source identity
generic later MESSAGE update produces no Task event
JSDoc
beyond-window rewrite + two-transition specs
ADR 0024 graph model
ADR 0035 §7 graph-event obligation
Register typed GraphLog Task events as operational wake vocabulary, distinct from nodes, edges, and PROTECTED_EDGE_TYPES
Retention/compaction remains #12329; no new graph ontology class
ADR 0024
ADR text + seam consistency
Decision Record Impact
Aligned with ADR 0035 and mechanically amends ADR 0024's operational event vocabulary plus ADR 0002's typed-source envelope. The ticket makes a source-owned Task transition a durable, citeable fact for the lifecycle frontier without adding a graph node, graph edge, protected edge type, fifth composition authority, or new retention policy.
Decision Record impact: amends ADR 0024 and ADR 0002; remains aligned with ADR 0035.
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 sourceEventId and immutable previousState, newState, taskId, originator, canonical assignee, assignment authority, and canonical ISO 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 sourceEventId for the same GraphLog transition row; each resync notification keeps ADR 0002's fresh per-emission eventId.
Both WakeSubscriptionService and the standalone daemon consume the same shared typed-event evaluator contract.
Existing SENT_TO_ME and PERMISSION_GRANTED behavior is unchanged.
ADR 0024 registers the typed GraphLog Task-transition row as operational wake-event vocabulary, explicitly distinguishes it from graph nodes/edges and PROTECTED_EDGE_TYPES, and leaves retention/compaction to #12329.
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.
tobiu referenced in commit 7a65951 - "fix(memory-core): persist durable Task transition events (#15114) (#15121)" on 4:17 AM
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 source-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
PROTECTED_EDGE_TYPESdoes not apply; retention remains governed separately by #12329.\n- ADR 0002 §6.1 defines notificationeventIdas a fresh per-emission transport ULID and resync as a new emission. The durable GraphLog identity must therefore surface separately assourceEventId; transport retry retains oneeventId, while reconnect resync retainssourceEventIdand mints a neweventId.The Fix
event_id) and immutable snapshot: taskId, previousState, newState, originator, canonical assignee, assignment authority, and lastModifiedAt.Contract Ledger
PROTECTED_EDGE_TYPESDecision Record Impact
Aligned with ADR 0035 and mechanically amends ADR 0024's operational event vocabulary plus ADR 0002's typed-source envelope. The ticket makes a source-owned Task transition a durable, citeable fact for the lifecycle frontier without adding a graph node, graph edge, protected edge type, fifth composition authority, or new retention policy.
Decision Record impact: amends ADR 0024 and ADR 0002; remains aligned with ADR 0035.
Acceptance Criteria
PROTECTED_EDGE_TYPES, and leaves retention/compaction to #12329.Out of Scope
Avoided Traps
Related
Origin Session ID: f95e01ff-ba36-409a-98af-573263fab247
Retrieval Hint: "durable TASK_STATE_CHANGED GraphLog sourceEventId 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.