LearnNewsExamplesServices
Frontmatter
id11595
titleGraph apoptosis rollback re-adds node IDs as strings
stateClosed
labels
bugairegressionarchitecture
assigneesneo-opus-ada
createdAtMay 18, 2026, 11:18 PM
updatedAtMay 19, 2026, 3:19 AM
githubUrlhttps://github.com/neomjs/neo/issues/11595
authorneo-gpt
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 19, 2026, 3:19 AM

Graph apoptosis rollback re-adds node IDs as strings

Closed v13.0.0/archive-v13-0-0-chunk-12 bugairegressionarchitecture
neo-gpt
neo-gpt commented on May 18, 2026, 11:18 PM

Context

Operator ran /opt/homebrew/bin/npm run ai:run-sandman from /Users/Shared/github/neomjs/neo on current dev (dev == origin/dev, 3271fb0281b2cd81fca84f403c0696b76e74d930) on 2026-05-18.

The REM run reached graph garbage collection, detected 8408 orphaned nodes, then failed inside rollback:

[INFO] [GraphMaintenanceService] Apoptosis detected 8408 orphaned nodes. Commencing eradication...
[ERROR] [DreamService] Failed to process undigested sessions: TypeError: Cannot create property 'Symbol(Neo.internalId)' on string 'CONCEPT:sunset-restart-cycle'
    at Store.assignInternalId (src/data/Store.mjs:411:30)
    at Store.splice (src/collection/Base.mjs:1530:33)
    at Store.splice (ai/graph/Store.mjs:161:30)
    at Store.add (src/collection/Base.mjs:181:21)
    at Store.add (src/data/Store.mjs:298:35)
    at Database.rollbackTransaction (ai/graph/Database.mjs:451:23)
    at Database.transaction (ai/graph/Database.mjs:482:18)
    at GraphService.removeNodes (ai/services/memory-core/GraphService.mjs:920:17)
    at GraphMaintenanceService.runGarbageCollection (ai/daemons/services/GraphMaintenanceService.mjs:53:26)

The Problem

GraphMaintenanceService.runGarbageCollection() calls GraphService.getOrphanedNodes(), which returns an array of node ID strings. It then calls GraphService.removeNodes(orphaned). GraphService.removeNodes() wraps nodeIds.forEach(id => this.db.removeNode(id)) in Database.transaction().

When the backing storage transaction fails, Database.rollbackTransaction() replays transactionDiff and does:

if (mutation.removedItems?.length > 0) {
    store.add(mutation.removedItems);
}

The mutation buffer is not guaranteed to contain removed node objects. src/collection/Base.mjs#splice() returns actual removed objects, but its public mutate event emits:

removedItems: toRemoveArray || removedItems

For remove-by-key calls, toRemoveArray is the string ID array. Database.onNodesMutate() stores that event payload in transactionDiff. Rollback then calls store.add(['CONCEPT:sunset-restart-cycle', ...]), and Neo.data.Store#assignInternalId() attempts to assign the internal ID symbol onto a string.

The failure is a rollback-path type-shape bug, not a data-specific issue with CONCEPT:sunset-restart-cycle.

The Architectural Reality

  • ai/daemons/services/GraphMaintenanceService.mjs owns the apoptosis pass and calls GraphService.removeNodes(orphaned).
  • ai/services/memory-core/GraphService.mjs#getOrphanedNodes() intentionally returns string IDs.
  • ai/graph/Database.mjs#transaction() assumes rollback diff entries are re-addable item objects.
  • src/collection/Base.mjs#splice() event payload uses remove input keys when remove-by-key was used, so transaction subscribers cannot assume mutation.removedItems contains objects.
  • ai/graph/storage/SQLite.mjs#removeNodes() accepts either objects or strings, but rollback must restore in-memory stores, so it needs the actual removed node objects.

The Fix

Choose the narrowest safe boundary after implementation V-B-A:

  1. Normalize transaction diff capture in Database.onNodesMutate() / onEdgesMutate() so rollback receives actual removed objects, not remove keys; or
  2. Change Database.removeNode() / removeEdge() transaction usage to capture the return value from store.remove() / splice() and push a rollback-safe diff; or
  3. Change Collection.splice() mutate payload contract only if broader consumers expect actual removed objects. This is higher blast radius and should be avoided unless tests prove it is the correct substrate.

The likely safest local fix is inside ai/graph/Database.mjs: transaction rollback is the only consumer that requires object-shaped removed entries.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Database.transaction() rollback diff This ticket + Sandman failure stack Rollback re-adds object-shaped nodes/edges, never primitive remove keys Abort rollback with explicit diagnostic before corrupting Store state JSDoc on rollbackTransaction() Unit test forces storage transaction failure after remove-by-key and verifies rollback completes
GraphMaintenanceService.runGarbageCollection() apoptosis This ticket Removing orphan string IDs either succeeds or fails without secondary rollback TypeError Skip vector purge if graph removal failed Existing Dream Pipeline docs Re-run ai:run-sandman no longer throws Cannot create property ... on string

Acceptance Criteria

  • Unit test reproduces a graph transaction failure after node removal by string ID and asserts rollback does not call Store.add() with strings.
  • GraphService.removeNodes(['CONCEPT:test']) rollback preserves the original node object in memory when storage deletion fails.
  • Edge rollback remains covered; removing edges by string ID must not regress.
  • GraphMaintenanceService.runGarbageCollection() no longer throws Cannot create property 'Symbol(Neo.internalId)' on string ... when a bulk orphan deletion fails.
  • Follow-up Sandman run reaches Golden Path synthesis without this rollback error.

Out of Scope

  • Changing apoptosis selection policy or protected node types.
  • Deleting the 8408 orphan nodes manually.
  • Reworking SQLite transaction semantics outside the rollback shape needed here.

Avoided Traps

  • Treating CONCEPT:sunset-restart-cycle as corrupt data — rejected. The stack proves the string type itself is the rollback payload shape.
  • Changing all collection mutation semantics globally first — rejected until proven necessary; the graph transaction layer is the immediate consumer with the broken assumption.
  • Suppressing graph GC — rejected. Apoptosis is expected maintenance; the rollback path must be safe.

Related

  • Operator Sandman run, 2026-05-18, current dev at 3271fb0281b2cd81fca84f403c0696b76e74d930.
  • ai/daemons/services/GraphMaintenanceService.mjs:49-53
  • ai/services/memory-core/GraphService.mjs:887-920
  • ai/graph/Database.mjs:434-482
  • src/collection/Base.mjs:1599-1614

Origin Session ID: 8591bc48-0ddc-48bf-aa47-58e53ea81a57 Retrieval Hint: query_raw_memories("Sandman GraphMaintenanceService apoptosis rollbackTransaction removedItems string CONCEPT:sunset-restart-cycle internalId")

tobiu referenced in commit 8aa2fed - "fix(collection/graph): emit object-shaped removedItems in mutate event to fix transaction rollback TypeError (#11595) (#11611) on May 19, 2026, 3:19 AM
tobiu closed this issue on May 19, 2026, 3:19 AM