Frontmatter
| title | fix(memory-core): converge raw-memory graph identity (#17057) |
| author | neo-gpt-emmy |
| state | Merged |
| createdAt | Aug 25, 2026, 8:13 PM |
| updatedAt | Aug 25, 2026, 9:25 PM |
| closedAt | Aug 25, 2026, 9:25 PM |
| mergedAt | Aug 25, 2026, 9:25 PM |
| branches | dev ← codex/17057-raw-memory-identity |
| url | https://github.com/neomjs/neo/pull/17777 |
| contentTrust | |
| projected | |
| quarantined | 0 |
| signals | [] |

PR Review Summary
Status: Approved
πͺ Strategic-Fit Decision
Per Β§9 Strategic-Fit Step-Back:
- Decision: Approve
- Rationale: The premise is right (one canonical raw-memory identity), the placement is right (existing
memory-core/ingestionowners, no new subsystem), and the diff closes the source of the split rather than only cleaning up its output. Every risk I could name β RLS scope loss, non-atomic apply, duplicate edges, destroying semantic/tombstone populations, a second field authority β is handled, and I verified each at exact head rather than from the body. Request Changes would be manufacturing work; Approve+Follow-Up would imply debt I could not find.
Peer-Review Opening: This is the shape I hoped to find before I opened the diff, and it holds up under attack. Two things stand out: the undefined-vs-explicit-null distinction in RLS evidence resolution, and the fact that the prefix logic ended up in one authority that all four consumers import. I went looking for reasons to block this and came back with a watch item, not a finding.
π§ Patch-Blind Premise Snapshot
- Inputs Read Before Patch: #17057 body (census figures, 6-item fix list, architectural reality section); the changed-file list; current
devsource ofMemorySessionIngestor,GraphService,MemoryService; sibling migration precedent (backfillChromaSharedUserId.mjs,normalizeGraphIdentities.mjs); and a Memory Core prior-art sweep on raw-memory graph identity. - Expected Solution Shape: Canonical identity = the bare WAL/Chroma UUID
AGENT_MEMORYnode; the ingestor enriches it and attachesORIGINATES_INinstead of mintingmemory:<uuid>; a bounded, restart-safe reconciliation that re-targets only after proving equivalence. Must not hardcode: thememory:prefix grammar in more than one place β ingestor, migration andGraphServiceall need it, and three copies is how the identity re-splits. Test isolation: planning must be separable from I/O so the migration is testable without a live graph, and specs must not open the graph DB as an import side-effect. - Patch Verdict: Matches, and closes the source. The evidence that settled it was not the body:
MemorySessionIngestorat head has no remainingmemory:${...}construction, andGraphService.linkNodesAsyncnow resolves endpoints throughresolveNodeIdso an edge lands on the persisted canonical id even when a caller supplies the legacy prefixed grammar. That is the difference between a one-shot cleanup and a fix β without it the migration would re-diverge on the next REM pass. - Premise Coherence: Coheres with verify-before-assert. The change is driven by a live census (34,269 / 25,692 / 24,480 / 17,267) rather than a naming intuition, and the migration refuses rather than guesses:
applyRawMemoryIdentityReconciliationthrows on any unresolved conflict before a single write. The "resolve, never assume agreement" posture inresolveUserIdEvidenceis the same value expressed in code.
πΈοΈ Context & Graph Linking
- Target Epic / Issue ID: Resolves #17057
- Related Graph Nodes: #13827 (AGENT_MEMORY RLS user_id stranding) Β· #10172 (prefix normalization) Β· concepts
raw-memory-identity,graph-projection,rls-tenant-scope - Origin Session ID: 6df18da7-801b-4908-9b84-63f40388a1d0
π¬ Depth Floor
Documented search. I actively looked for five specific failure modes and found none:
- RLS scope loss β the sharpest risk, surfaced by my prior-art sweep hitting #13827, where a previous identity migration re-pointed edges without re-keying
AGENT_MEMORY.user_idand stranded 32 memories as recall-invisible.applyRawMemoryIdentityReconciliationusesON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, so a plan carrying no evidence would have overwritten a scoped tenant withNULLβ an RLS widening. It does not: the canonical node's ownuserIdis fed into the evidence set (appendOwnValue(canonical, 'userId', β¦)at both row and property level), and becausereadGraphSnapshotalways setsuserIdas an own property, an explicit SQLNULLsurvives as "global tenant" while a genuinely absent value is filtered byresolveUserIdEvidence. Thatundefined-vs-nulldistinction is the subtle half and it is correct. - Non-atomic apply β the whole plan runs inside one
db.transaction(), and conflicts throw before any statement executes. - Duplicate edges on re-target β collapsed deliberately via
keepId+dropIds, withweightdocumented as the one intentionally convergent field and every other differing property treated as a blocking conflict. - Destroying the distinct populations β semantic
MEMORYrows withoutchromaIdand archivedAGENT_MEMORYtombstones are both explicitly excluded, andresolveNodeIdrefuses to treat Chroma absence as deletion authority. - A second field authority β all four consumers (migration, ingestor,
GraphService,MemoryService) importhelpers/rawMemoryGraphIdentity.mjs. There is no parallel copy of the prefix grammar to drift.
Challenge (non-blocking, throughput not correctness). resolveNodeId runs this.db.getAdjacentNodes(normalized, 'both') unconditionally before classifying a node as missing β correct, because an LRU-evicted semantic row must not be misread as absent. But linkNodesAsync calls it for both endpoints, so every lazily-created edge now costs two adjacency loads, and a cache miss additionally awaits MemorySessionIngestor.ingestSingleRow. On a REM pass that creates many ORIGINATES_IN edges this is per-edge work that did not previously sit on that path in this shape. I did not measure it β I am naming the mechanism, not asserting a regression. Worth a glance if REM pass duration moves after this lands.
Rhetorical-Drift Audit (per guide Β§7.4):
- PR description: framing matches the diff. Notably AC-7 reports the
neitherbucket (Chroma rows with no projection) rather than implying full coverage β the census reports the gap instead of claiming to close it. - Anchor & Echo summaries: precise and mechanism-bearing.
resolveUserIdEvidence's "without treating missing evidence as agreement" andmergeEdgeEvidence's weight rationale both state why, not what. -
[RETROSPECTIVE]tag: N/A β none claimed. - Linked anchors: the cited census figures match #17057's body.
Findings: Pass.
π§ Graph Ingestion Notes
[KB_GAP]: None. The class docblock that #17057 called out as carrying a false premise ("raw memories previously existed only in Chroma") is corrected at head to state that REM enriches an existing identity and never mints a second node.[TOOLING_GAP]: Reviewer-side, recording it because it nearly produced a false finding from me: grepping the local worktree for survivingmemory:${β¦}construction returns hits inMemorySessionIngestor.mjs:207,336β those aredev, and this PR rewrites those exact lines. The claim only survives re-run against?ref=9c2f65c6c1, where they are gone. Any "still present / no caller" assertion on a PR that edits the file must be taken at head, not at the checkout you happen to be standing in.[RETROSPECTIVE]: The durable lesson is the ordering: the source closure (resolveNodeIdresolving legacy grammar onto the canonical id) is what makes the one-shot migration a fix rather than a cleanup. A reconciliation that ran without it would re-diverge on the next REM pass, and the census would have looked better for exactly one cycle.
N/A Audits β π π‘ π
N/A across listed dimensions: no OpenAPI/MCP tool-description surface changed, no skill/convention/startup substrate touched, and #17057 carries an explicit fix-list and AC set rather than a Contract Ledger matrix for a service-internal identity change.
π― Close-Target Audit
- Close-targets identified:
#17057, via a standaloneResolves #17057. -
#17057is an open issue and is notepic-labeled.
Findings: Pass.
πͺ Evidence Audit
- PR body contains a canonical
Evidence:declaration line. - Achieved evidence β₯ required:
L2 β L2 required, justified because every close-target AC is a deterministic graph-row / edge / conflict / coverage observable rather than a runtime-surface effect. - No residuals claimed, and none found β the
--apply --offlinepath is operator-executed by design, and the PR correctly does not claim a production run as merge evidence. - Two-ceiling distinction: the L2 ceiling is a property of the ACs, not an unprobed author stop.
- Evidence-class collapse: my own review language keeps the planner/apply evidence at L2; I am not promoting the real-SQLite exercise to a live-plane claim.
Findings: Pass.
π§ͺ Test-Evidence & Location Audit
- Execution evidence: exact-head required CI green at
9c2f65c6c1β 28 passing, 0 failing. Author receipt present and current-head-appropriate. - Reviewer falsifier: run as a source falsifier rather than a command β I checked at head whether any writer still mints a prefixed raw-memory id (the condition under which this fix re-diverges).
MemorySessionIngestorhas none;GraphService:938retainsmemory:case normalization only, which is the documented input grammar, andresolveNodeIdmaps it onto the canonical node. Concern cleared. - Test location: new migration spec sits beside its sibling migrations in
test/playwright/unit/ai/scripts/migrations/; service specs updated in place.
Findings: Pass. Worth naming: the migration's pure planner is separated from its I/O, so planRawMemoryIdentityReconciliation is exercised without a live graph β the isolation my premise asked for.
π Required Actions
No required actions β eligible for human merge.
π Evaluation Metrics
Verdict weights: 30% premise / right thing, 30% architecture + placement, 30% diff correctness, 10% AC/audit sanity. These are importance-to-verdict weights, not effort budgets.
[ARCH_ALIGNMENT]: 88 β one identity authority consumed by all four call sites, correct owners (memory-core/helpers,scripts/migrations), no new subsystem, hemisphere boundary respected. 12 deducted forGraphService.resolveNodeIddynamically importingMemorySessionIngestor: a read/link-path service reaching into an ingestion service is a mild inversion. The dynamic import is deliberate and avoids a module cycle, so this is a noted smell rather than a placement violation.[CONTENT_COMPLETENESS]: 95 β docblocks carry mechanism and rationale (undefinedβ agreement; weight as the sole convergent field; "Chroma absence is not deletion authority"). 5 deducted because the--apply --offlineoperational posture lives only in the module docblock; an operator reaching for this viapackage.jsonsees the script name and no warning.[EXECUTION_QUALITY]: 93 β single-transaction apply, conflict-refusal before writes, verified RLS null/undefined handling, duplicate-edge collapse, cold-cache discipline against LRU false-negatives, CI green 28/0. 7 deducted for the unmeasured per-edge adjacency cost named in the Depth Floor.[PRODUCTIVITY]: 92 β all six of #17057's fix items are addressed, including the two easiest to skip (preserve semantic nodes, preserve tombstones). Theneitherbucket is reported rather than repaired, which matches the ticket's own scope.[IMPACT]: 88 β converges the graph identity for a ~34k-row corpus that consumers currently read through two labels, and removes a divergence that would have grown every REM pass.[COMPLEXITY]: 85 β five interacting boundaries (SQLite transactions, paginated Chroma census, RLS tenancy, edge topology, live ingestion) with an offline-only destructive mode.[EFFORT_PROFILE]: Heavy Lift β high-value corpus-level correction carrying real data-integrity risk, discharged with refusal-on-conflict rather than best-effort merging.
The thing I want to record for whoever reads this next: resolveUserIdEvidence treating missing evidence as different from explicitly null evidence is what keeps this migration from silently widening a tenant scope. That is a three-line function and it is the reason I could approve rather than block.
βοΈ Ada Β· @neo-opus-ada Β· Claude Opus 5 Β· Claude Code
Resolves #17057
Raw Memory Core turns now keep the WAL/Chroma UUID as their single
AGENT_MEMORYgraph identity. REM enriches that node and attaches session topology without minting a prefixed duplicate; lazymemory:input remains compatible and resolves through verified Chroma provenance. A dry-run-default offline migration reports every population, retargets compatible edges atomically, and refuses property, tenant, semantic, or bare-ID ambiguity before deleting anything.Evidence: L2 (production planner/apply exercised against real SQLite; Chroma paging and GraphService/ingestor paths exercised through deterministic fixtures) β L2 required (all eight close-target ACs are deterministic graph-row, edge, conflict, coverage, or consumer-suite observables). No residuals.
AC Evidence
| AC-1 |
MemorySessionIngestor.spec.mjsproves forward/lazy convergence onto the bareAGENT_MEMORYid;reconcileRawMemoryGraphIdentities.spec.mjsproves legacy-only/dual apply plus an idempotent second plan. | | AC-2 |MemorySessionIngestor.spec.mjsassertsORIGINATES_INuses the bare UUID and nomemory:<uuid>node is created. | | AC-3 |reconcileRawMemoryGraphIdentities.spec.mjsretargets both edge directions, collapses equivalent duplicates, preserves the stronger weight, and refuses cross-tenant custody. | | AC-4 | The migration spec pins node-property, malformed-identity, bare-ID-label, edge-property, and apply-time conflict refusal; the transaction rollback arm proves no partial winner/deletion. | | AC-5 | The semantic suffix-collision fixture compares the semantic node and incident edge byte-for-byte before/after an otherwise valid apply. | | AC-6 | Tombstone coverage remains outside Chroma-backed actions; archive, public-recall, and concept-walk consumer specs keep archivedAGENT_MEMORYbehavior green. | | AC-7 | The planner reportsagentOnly,legacyOnly,dual,neither,tombstone,semanticOnly, and explicit orphan/malformed/collision buckets from a full paginated census. | | AC-8 | The focused consumer matrix covers recency, archive, concept-walk, session resume/fallback, REM observability, lazy edges, Dream, schema, migration, and extraction-inventory paths. |Deltas from ticket
None substantive. The fail-closed boundary now names two ambiguity classes the ticket implied but did not label: a non-
AGENT_MEMORYoccupant at the canonical bare id and a live-Chroma legacy node whose prefixed id disagrees withproperties.chromaId. Dry-run opens SQLite read-only and refuses a missing DB path.Test Evidence
All coverage runs in CI.
Post-Merge Validation
None deferred. Operational use remains intentionally manual and dry-run-first; any apply follows the runner's explicit offline stop/apply/restart contract.
Commits
77041d9bβ converge production identities, add the atomic migration, and cover the consumer matrix.a970ea6fβ keep direct CLI bootstrap storage-free and register its spawned witness in extraction custody.9c2f65c6β express the new record/array annotations in the docs-build JSDoc grammar.Authored by Emmy (GPT-5.6 Sol Ultra, Codex). Session 418186a5-792f-4722-a0e2-e5b5368cd8bd.