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:
- Normalize transaction diff capture in
Database.onNodesMutate() / onEdgesMutate() so rollback receives actual removed objects, not remove keys; or
- Change
Database.removeNode() / removeEdge() transaction usage to capture the return value from store.remove() / splice() and push a rollback-safe diff; or
- 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
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")
Context
Operator ran
/opt/homebrew/bin/npm run ai:run-sandmanfrom/Users/Shared/github/neomjs/neoon currentdev(dev == origin/dev,3271fb0281b2cd81fca84f403c0696b76e74d930) on 2026-05-18.The REM run reached graph garbage collection, detected
8408orphaned 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()callsGraphService.getOrphanedNodes(), which returns an array of node ID strings. It then callsGraphService.removeNodes(orphaned).GraphService.removeNodes()wrapsnodeIds.forEach(id => this.db.removeNode(id))inDatabase.transaction().When the backing storage transaction fails,
Database.rollbackTransaction()replaystransactionDiffand 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 publicmutateevent emits:removedItems: toRemoveArray || removedItemsFor remove-by-key calls,
toRemoveArrayis the string ID array.Database.onNodesMutate()stores that event payload intransactionDiff. Rollback then callsstore.add(['CONCEPT:sunset-restart-cycle', ...]), andNeo.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.mjsowns the apoptosis pass and callsGraphService.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 assumemutation.removedItemscontains 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:
Database.onNodesMutate()/onEdgesMutate()so rollback receives actual removed objects, not remove keys; orDatabase.removeNode()/removeEdge()transaction usage to capture the return value fromstore.remove()/splice()and push a rollback-safe diff; orCollection.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
Database.transaction()rollback diffrollbackTransaction()GraphMaintenanceService.runGarbageCollection()apoptosisai:run-sandmanno longer throwsCannot create property ... on stringAcceptance Criteria
Store.add()with strings.GraphService.removeNodes(['CONCEPT:test'])rollback preserves the original node object in memory when storage deletion fails.GraphMaintenanceService.runGarbageCollection()no longer throwsCannot create property 'Symbol(Neo.internalId)' on string ...when a bulk orphan deletion fails.Out of Scope
Avoided Traps
CONCEPT:sunset-restart-cycleas corrupt data — rejected. The stack proves the string type itself is the rollback payload shape.Related
devat3271fb0281b2cd81fca84f403c0696b76e74d930.ai/daemons/services/GraphMaintenanceService.mjs:49-53ai/services/memory-core/GraphService.mjs:887-920ai/graph/Database.mjs:434-482src/collection/Base.mjs:1599-1614Origin Session ID: 8591bc48-0ddc-48bf-aa47-58e53ea81a57 Retrieval Hint:
query_raw_memories("Sandman GraphMaintenanceService apoptosis rollbackTransaction removedItems string CONCEPT:sunset-restart-cycle internalId")