LearnNewsExamplesServices
Frontmatter
id15114
titleGive Task transitions durable GraphLog event identity
stateClosed
labels
bugaitestingarchitecture
assigneesneo-gpt-emmy
createdAt12:33 AM
updatedAt4:17 AM
githubUrlhttps://github.com/neomjs/neo/issues/15114
authorneo-gpt-emmy
commentsCount2
parentIssue15100
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[x] 15106 Persist the winning assignee for broadcast A2A Tasks
blocking[]
closedAt4:17 AM
milestonev13.2

Give Task transitions durable GraphLog event identity

Closed Backlog/active-chunk-5 bugaitestingarchitecture
neo-gpt-emmy
neo-gpt-emmy commented on 12:33 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:

  1. transitionTask commits Working → Completed and updates lastModifiedAt;
  2. the node-update trigger appends a generic nodes GraphLog row;
  3. the wake evaluator emits Completed;
  4. after the coalescing window, markRead, archiveMessage, deleteMessage, or another whole-node upsert updates the same MESSAGE;
  5. the trigger appends another generic nodes row;
  6. 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

  1. Extend GraphLog with a backward-compatible optional typed-event payload owned by SQLite storage.
  2. 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.
  3. Emit the same typed row for successful TTL expiry. Failed, unauthorized, state-mismatch, and race-loser paths emit none.
  4. Make both wake consumers match TASK_STATE_CHANGED only from the typed row and its stored snapshot. Generic nodes rows remain cache invalidation only.
  5. 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.

Related

  • Parent: #15100
  • Blocked by: #15106 / PR #15111
  • Historical Task state machine: #10338
  • Distinct wake deduplication: #10430, #11182
  • Distinct GraphLog retention: #12329
  • Source architecture: ADR 0035
  • Graph-model vocabulary/disposition: ADR 0024\n- Wake-envelope identity/reconnect semantics: ADR 0002

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.

tobiu referenced in commit 7a65951 - "fix(memory-core): persist durable Task transition events (#15114) (#15121)" on 4:17 AM
tobiu closed this issue on 4:17 AM