Skip to content

Commit 9235db9

Browse files
feat(memory-core): archiveMemoriesByAgentIdentity tombstone + recall-exclusion (#13384) (#13385)
* feat(memory-core): archiveMemoriesByAgentIdentity tombstone + recall-exclusion (#13384) The Memory-Core primitive the Fleet agent-removal reconciliation consumes via the archiveMemoriesFn seam (split from #13190 per the 1-ticket-1-PR contract). - archiveMemoriesByAgentIdentity({agentIdentity, reason, dryRun}): dual-store tombstone (Chroma metadata + graph-node SQL + in-memory node-cache), idempotent, keyed on the stamped @<github-username> write-identity. - unarchiveMemoriesByAgentIdentity: reversible (Chroma empty-string + graph json_remove + cache). - archivedAt-exclusion across recall paths: queryMemories + listMemories (Chroma), queryRecentTurns (graph SQL), getContextFrontier frontier (GraphService). - Leak-test: 5 cases (exclusion on both Chroma paths, dryRun, idempotency, identity-scoping, fail-closed) + 59 existing memory-core specs green (no regression). Refs #13190. * fix(memory-core): durable tombstone marker survives graph-projection lag (#13384) archiveMemoriesByAgentIdentity stamped archivedAt on the Chroma rows and on ALREADY-projected graph nodes, but a record embedded into Chroma while still graph-pending (graphProjectionVersion:1, no .graph.jsonl marker) had no node to stamp. The deferred drainPendingGraphProjections -> _projectMemoryToGraph later minted a fresh AGENT_MEMORY node from the pre-archive WAL snapshot, silently re-introducing the row un-tombstoned. Chroma embed and graph projection are independent derived states that catch up on different clocks. Fix: a durable, globally-visible RLS-exempt ARCHIVED_AGENT_IDENTITY:<identity> marker (upsertGlobalNode), written by the archive op and removed by unarchive, plus _archivedIdentityState + _withArchiveState helpers. _projectMemoryToGraph now replays the tombstone onto the projected node when the identity is archived, so a lag-window archive survives projection (and a restart). Test: a real (non-stubbed) projection-lag behavior test + durable-marker round-trip exercising the actual _projectMemoryToGraph path; 8/8 green. The existing recall-exclusion suite is unaffected (its db stub gains transaction/removeNode for the marker ops). Remaining before re-request (NOT this commit): the other AGENT_MEMORY mint/overlay sites — the buildMiniSummary re-upsert, mergeMiniSummary, and the _readPendingWalRecencyRows recency overlay — get the same archive-awareness, then the leak test extends across all four sites. * fix(memory-core): archive-aware projection across all four mint/overlay sites (#13384) Extends the durable-marker core to the remaining three AGENT_MEMORY sites the projection-lag analysis mapped, so a tombstone set during projection lag survives every (re)mint and recall surface — not just the drain path: - buildMiniSummary inline re-upsert: wrapped in _withArchiveState (archive-vs-summary race no longer drops archivedAt). - updateMemoryMiniSummary: the addNodes overwrite now replays the tombstone from the durable marker (merge-vs-archive race). - _readPendingWalRecencyRows: identity-level short-circuit excludes an archived identity's graph-PENDING rows from recency recall (the graph-SQL path already excluded archivedAt; this closes the WAL-pending overlay gap). Test: the projection-lag suite extends with a recency-overlay short-circuit assertion (9/9 green). Full memory-core unit suite regression-free (13 failed = pre-existing local-LM-dependent specs on clean dev; 510 passed = clean + the 4 new tests). * test(memory-core): public graph-recall leak coverage for archive — queryRecentTurns + getContextFrontier (#13384) Addresses the cycle-2 CHANGES_REQUESTED: the prior tests proved the projection-lag mechanism via private helpers, but the source issue's ACs name queryRecentTurns + getContextFrontier (the public graph-backed recall surfaces), which now have direct coverage against the real in-memory SQLite graph (UNIT_TEST_MODE ':memory:', mirroring QueryRecentTurns.spec). queryRecentTurns: full end-to-end — addMemory -> archiveMemoriesByAgentIdentity (real op: Chroma sweep + graph-SQL stamp + durable marker) -> the row is excluded from the public recency surface -> unarchive restores it. getContextFrontier: its frontier is agent-identity RLS-scoped (isRlsVisible keys on the acting agentIdentityNodeId, never a tenant userId), so a tenant memory node is never one of its strategic neighbors. The test exercises the public surface's archivedAt exclusion directly with a globally-visible frontier neighbor carrying the same node-cache archivedAt state the archive op's coherence sweep stamps (the op's stamping itself is pinned in the sibling spec). 2/2 green; 22 passed alongside ArchiveByIdentity + QueryRecentTurns (no cross-spec interference). * test(memory-core): point ArchiveByIdentity header at the public-recall spec — gpt cycle-2 RA3 (#13384) The header claimed the graph-SQL (queryRecentTurns) + frontier (getContextFrontier) exclusion was 'covered by a separate integration assertion'. That assertion now exists — MemoryService.ArchiveByIdentity.PublicRecall.spec.mjs — so the comment points at it directly. Completes the third of gpt's cycle-2 Required Actions; RA1 (queryRecentTurns public test) + RA2 (getContextFrontier public test) landed in 51d4561.
1 parent 57c9c67 commit 9235db9

4 files changed

Lines changed: 762 additions & 5 deletions

File tree

ai/services/memory-core/GraphService.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,7 @@ class GraphService extends Base {
869869
let node = this.db.nodes.get(adjacentId);
870870

871871
// Actively filter out CLOSED structural paths plus RLS-invisible nodes/edges.
872-
if (node && isRlsVisible(node, rlsUserId) && isRlsVisible(e, rlsUserId) && node.properties?.state !== 'CLOSED') {
872+
if (node && isRlsVisible(node, rlsUserId) && isRlsVisible(e, rlsUserId) && node.properties?.state !== 'CLOSED' && !node.properties?.archivedAt) {
873873
topology.strategicNeighbors.push({
874874
id : node.id,
875875
type : node.label,

ai/services/memory-core/MemoryService.mjs

Lines changed: 245 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,9 @@ class MemoryService extends Base {
527527
name: `Memory: ${timestamp}`,
528528
description: `Agent thought flow inside session ${sessionId}.`,
529529
semanticVectorId: memoryId,
530-
properties: {...memoryProperties, miniSummary}
530+
// Archive-aware re-upsert: a tombstone set between the initial projection and this
531+
// post-summary re-mint must not be dropped (see _withArchiveState).
532+
properties: this._withArchiveState({...memoryProperties, miniSummary})
531533
});
532534
}
533535
}).catch(() => {});
@@ -621,7 +623,10 @@ class MemoryService extends Base {
621623
name: `Memory: ${timestamp}`,
622624
description: `Agent thought flow inside session ${sessionId}.`,
623625
semanticVectorId: memoryId,
624-
properties: memoryProperties
626+
// Archive-aware: a tombstone set while this record was graph-pending (the projection-lag
627+
// window) is replayed onto the node here, so a deferred projection cannot reintroduce an
628+
// archived row un-tombstoned. See _withArchiveState + the durable ARCHIVED_AGENT_IDENTITY marker.
629+
properties: this._withArchiveState(memoryProperties)
625630
});
626631

627632
// Stamp write-time provenance when the transport resolved a real AgentIdentity.
@@ -806,6 +811,14 @@ class MemoryService extends Base {
806811
*/
807812
async _readPendingWalRecencyRows({identity, userId, before, excludeIds = new Set()} = {}) {
808813
try {
814+
// Identity-level tombstone short-circuit: archiveMemoriesByAgentIdentity sweeps ALL of an
815+
// identity's memories + writes a durable marker, and the graph-SQL recency path already
816+
// excludes archivedAt — so an archived identity's graph-PENDING rows must be excluded here
817+
// too, else they leak into recency recall during projection lag.
818+
if (this._archivedIdentityState(identity)) {
819+
return [];
820+
}
821+
809822
// No raw read-limit here: readPendingWalRecords walks each daily segment in append
810823
// (oldest-first) order, so capping the raw count would drop the NEWEST graph-pending rows
811824
// — exactly the just-written, not-yet-projected turns the read-after-write overlay must
@@ -913,6 +926,9 @@ class MemoryService extends Base {
913926
let records = result.ids.map((id, index) => {
914927
const metadata = result.metadatas[index] || {};
915928

929+
// Tombstone exclusion: archived rows are dropped from recall.
930+
if (metadata.archivedAt) return null;
931+
916932
return {
917933
id,
918934
sessionId: metadata.sessionId,
@@ -927,7 +943,7 @@ class MemoryService extends Base {
927943
toolsUsed: metadata.toolsUsed || null,
928944
_userId : metadata.userId
929945
};
930-
});
946+
}).filter(Boolean); // Tombstone exclusion (archived rows returned null above)
931947

932948
if (userId && policy === 'legacy') {
933949
records = records.filter(r => !r._userId || r._userId === userId || r._userId === SHARED_USER_ID);
@@ -958,6 +974,215 @@ class MemoryService extends Base {
958974
}
959975
}
960976

977+
/**
978+
* @summary Tombstones every memory stamped with `agentIdentity` so it stops surfacing in recall,
979+
* while RETAINING the rows (forensics) and staying operator-reversible — the Memory-Core
980+
* primitive the Fleet agent-removal reconciliation consumes via the `archiveMemoriesFn` seam.
981+
*
982+
* Sets `archivedAt` (+ `archivedReason`) across BOTH stores — the Chroma row metadata (read by
983+
* `queryMemories` / `listMemories`) and the graph `AGENT_MEMORY` node (read by `queryRecentTurns`
984+
* and the topology frontier) — so the recall paths that exclude `archivedAt` stop returning the
985+
* rows. Idempotent: already-tombstoned rows are skipped. Keys on the STAMPED write-identity
986+
* (`@<github-username>` for FM stdio+PAT agents, per the `add_memory` stamping) — NOT a registry id.
987+
*
988+
* @param {Object} options
989+
* @param {String} options.agentIdentity The stamped `metadata.agentIdentity` value to sweep.
990+
* @param {String} [options.reason] Tombstone provenance, stored per row as `archivedReason`.
991+
* @param {Boolean} [options.dryRun=false] Preview `matchedCount` without tombstoning.
992+
* @returns {Promise<Object>} `{agentIdentity, matchedCount, archivedCount, dryRun}`.
993+
*/
994+
async archiveMemoriesByAgentIdentity({agentIdentity, reason, dryRun = false} = {}) {
995+
if (!agentIdentity) {
996+
return {error: 'Bad Request', message: "'agentIdentity' is required.", code: 'MISSING_AGENT_IDENTITY'};
997+
}
998+
999+
try {
1000+
const collection = await StorageRouter.getMemoryCollection(),
1001+
matched = await collection.get({where: {agentIdentity}, include: ['metadatas']}),
1002+
matchedIds = matched.ids || [],
1003+
matchedMeta = matched.metadatas || [],
1004+
live = []; // not-already-tombstoned rows (idempotent: a re-run archives 0 new)
1005+
1006+
for (let i = 0; i < matchedIds.length; i++) {
1007+
if (!(matchedMeta[i] && matchedMeta[i].archivedAt)) {
1008+
live.push({id: matchedIds[i], meta: matchedMeta[i] || {}});
1009+
}
1010+
}
1011+
1012+
if (dryRun) {
1013+
return {agentIdentity, matchedCount: matchedIds.length, archivedCount: 0, dryRun: true};
1014+
}
1015+
1016+
const archivedAt = new Date().toISOString(),
1017+
archivedReason = reason || '';
1018+
1019+
// Chroma side: stamp archivedAt onto the live rows' metadata (full-metadata-preserving).
1020+
if (live.length > 0) {
1021+
await collection.update({
1022+
ids : live.map(r => r.id),
1023+
metadatas: live.map(r => ({...r.meta, archivedAt, archivedReason}))
1024+
});
1025+
}
1026+
1027+
// Graph side: stamp the projected AGENT_MEMORY nodes. The `archivedAt IS NULL` guard keeps
1028+
// it idempotent + scoped to the live rows. Mirrors the MailboxService `archivedAt` model.
1029+
const sqlite = GraphService.db?.storage?.db;
1030+
if (sqlite) {
1031+
sqlite.prepare(`
1032+
UPDATE Nodes
1033+
SET data = json_set(json_set(data, '$.properties.archivedAt', ?), '$.properties.archivedReason', ?)
1034+
WHERE json_extract(data, '$.label') = 'AGENT_MEMORY'
1035+
AND json_extract(data, '$.properties.agentIdentity') = ?
1036+
AND json_extract(data, '$.properties.archivedAt') IS NULL
1037+
`).run(archivedAt, archivedReason, agentIdentity);
1038+
}
1039+
1040+
// In-memory node-cache coherence: getContextFrontier reads GraphService.db.nodes (the
1041+
// cache), which the SQL UPDATE above does NOT touch. The graph node id === the memory id
1042+
// (_projectMemoryToGraph), so mirror archivedAt onto any cached node so the topology
1043+
// frontier excludes it too.
1044+
const nodeCache = GraphService.db?.nodes;
1045+
if (nodeCache) {
1046+
for (const {id} of live) {
1047+
const cached = nodeCache.get(id);
1048+
if (cached && cached.properties) {
1049+
cached.properties.archivedAt = archivedAt;
1050+
cached.properties.archivedReason = archivedReason;
1051+
}
1052+
}
1053+
}
1054+
1055+
// Durable tombstone marker — survives graph-projection lag. The SQL UPDATE + node-cache sweep
1056+
// above only reach ALREADY-projected nodes; a row embedded into Chroma but still graph-pending
1057+
// (graphProjectionVersion:1, no .graph.jsonl marker) has no node to stamp yet. This global,
1058+
// RLS-exempt marker lets the deferred projection drain (_projectMemoryToGraph → _withArchiveState)
1059+
// and the recency overlay tombstone it once projection catches up — even across a restart.
1060+
// Set unconditionally (not gated on live.length) so an identity archived entirely during
1061+
// projection lag is still covered.
1062+
GraphService.upsertGlobalNode({
1063+
id : `ARCHIVED_AGENT_IDENTITY:${agentIdentity}`,
1064+
type : 'ARCHIVED_AGENT_IDENTITY',
1065+
name : `Archived identity: ${agentIdentity}`,
1066+
description: 'Tombstone marker: deferred graph projection + recency overlay exclude this identity.',
1067+
properties : {agentIdentity, archivedAt, archivedReason}
1068+
});
1069+
1070+
logger.info(`[MemoryService] archiveMemoriesByAgentIdentity('${agentIdentity}'): matched ${matchedIds.length}, archived ${live.length}`);
1071+
return {agentIdentity, matchedCount: matchedIds.length, archivedCount: live.length, dryRun: false};
1072+
} catch (error) {
1073+
logger.error('[MemoryService] Error archiving memories by agentIdentity:', error);
1074+
return {error: 'Failed to archive memories', message: error.message, code: 'MEMORY_ARCHIVE_ERROR'};
1075+
}
1076+
}
1077+
1078+
/**
1079+
* @summary Reverses {@link archiveMemoriesByAgentIdentity} — clears `archivedAt` for `agentIdentity`
1080+
* so the rows recall again (operator re-provisioning). A hard purge is a separate
1081+
* explicit op, never the tombstone default.
1082+
*
1083+
* Chroma metadata cannot hold `null`, so the cleared marker is the empty string `''` (falsy → the
1084+
* recall-exclusions re-admit the row); the graph node's marker is removed via `json_remove`.
1085+
*
1086+
* @param {Object} options
1087+
* @param {String} options.agentIdentity The stamped identity to restore.
1088+
* @returns {Promise<Object>} `{agentIdentity, restoredCount}`.
1089+
*/
1090+
async unarchiveMemoriesByAgentIdentity({agentIdentity} = {}) {
1091+
if (!agentIdentity) {
1092+
return {error: 'Bad Request', message: "'agentIdentity' is required.", code: 'MISSING_AGENT_IDENTITY'};
1093+
}
1094+
1095+
try {
1096+
const collection = await StorageRouter.getMemoryCollection(),
1097+
matched = await collection.get({where: {agentIdentity}, include: ['metadatas']}),
1098+
matchedIds = matched.ids || [],
1099+
matchedMeta = matched.metadatas || [],
1100+
restore = [];
1101+
1102+
for (let i = 0; i < matchedIds.length; i++) {
1103+
if (matchedMeta[i] && matchedMeta[i].archivedAt) {
1104+
restore.push({id: matchedIds[i], meta: matchedMeta[i]});
1105+
}
1106+
}
1107+
1108+
if (restore.length > 0) {
1109+
await collection.update({
1110+
ids : restore.map(r => r.id),
1111+
metadatas: restore.map(r => ({...r.meta, archivedAt: '', archivedReason: ''}))
1112+
});
1113+
}
1114+
1115+
const sqlite = GraphService.db?.storage?.db;
1116+
if (sqlite) {
1117+
sqlite.prepare(`
1118+
UPDATE Nodes
1119+
SET data = json_remove(data, '$.properties.archivedAt', '$.properties.archivedReason')
1120+
WHERE json_extract(data, '$.label') = 'AGENT_MEMORY'
1121+
AND json_extract(data, '$.properties.agentIdentity') = ?
1122+
AND json_extract(data, '$.properties.archivedAt') IS NOT NULL
1123+
`).run(agentIdentity);
1124+
}
1125+
1126+
// In-memory node-cache coherence (mirror of the archive sweep): clear the cached marker.
1127+
const nodeCache = GraphService.db?.nodes;
1128+
if (nodeCache) {
1129+
for (const {id} of restore) {
1130+
const cached = nodeCache.get(id);
1131+
if (cached && cached.properties) {
1132+
delete cached.properties.archivedAt;
1133+
delete cached.properties.archivedReason;
1134+
}
1135+
}
1136+
}
1137+
1138+
// Remove the durable tombstone marker so future projections + the recency overlay re-admit
1139+
// the identity. Safe when absent (the identity was never archived) — removeNodes no-ops.
1140+
GraphService.removeNodes([`ARCHIVED_AGENT_IDENTITY:${agentIdentity}`]);
1141+
1142+
logger.info(`[MemoryService] unarchiveMemoriesByAgentIdentity('${agentIdentity}'): restored ${restore.length}`);
1143+
return {agentIdentity, restoredCount: restore.length};
1144+
} catch (error) {
1145+
logger.error('[MemoryService] Error unarchiving memories by agentIdentity:', error);
1146+
return {error: 'Failed to unarchive memories', message: error.message, code: 'MEMORY_UNARCHIVE_ERROR'};
1147+
}
1148+
}
1149+
1150+
/**
1151+
* @summary Looks up the durable tombstone marker for an agent identity.
1152+
*
1153+
* The archive op ({@link MemoryService#archiveMemoriesByAgentIdentity}) writes a global,
1154+
* RLS-exempt `ARCHIVED_AGENT_IDENTITY:<identity>` node; this reads it so the deferred
1155+
* graph-projection drain + the recency overlay can tombstone a record whose graph node did
1156+
* not exist when the archive ran (the projection-lag leak: Chroma embed and graph projection
1157+
* are independent derived states that catch up on different clocks).
1158+
* @param {String} agentIdentity The stamped write-identity.
1159+
* @returns {Object|null} `{archivedAt, archivedReason}` when archived, else `null`.
1160+
* @private
1161+
*/
1162+
_archivedIdentityState(agentIdentity) {
1163+
if (!agentIdentity) {
1164+
return null;
1165+
}
1166+
1167+
const props = GraphService.getNodeRecord({id: `ARCHIVED_AGENT_IDENTITY:${agentIdentity}`})?.properties;
1168+
return props?.archivedAt ? {archivedAt: props.archivedAt, archivedReason: props.archivedReason || ''} : null;
1169+
}
1170+
1171+
/**
1172+
* @summary Returns `memoryProperties` augmented with `archivedAt`/`archivedReason` when the row's
1173+
* `agentIdentity` carries a durable tombstone marker — applied at every `AGENT_MEMORY` mint site so
1174+
* a tombstone set during projection lag survives a later (re)projection.
1175+
* @param {Object} memoryProperties The properties about to be projected onto the graph node.
1176+
* @returns {Object} The same object, or a tombstoned copy when the identity is archived.
1177+
* @private
1178+
*/
1179+
_withArchiveState(memoryProperties) {
1180+
const archived = this._archivedIdentityState(memoryProperties?.agentIdentity);
1181+
return archived
1182+
? {...memoryProperties, archivedAt: archived.archivedAt, archivedReason: archived.archivedReason}
1183+
: memoryProperties;
1184+
}
1185+
9611186
/**
9621187
* @summary Cross-session, reverse-chronological *recency* recall over `AGENT_MEMORY` graph nodes.
9631188
*
@@ -1045,6 +1270,7 @@ class MemoryService extends Base {
10451270
WHERE json_extract(memory.data, '$.label') = 'AGENT_MEMORY'
10461271
AND json_extract(memory.data, '$.properties.agentIdentity') = ?
10471272
AND json_extract(memory.data, '$.properties.userId') = ?
1273+
AND json_extract(memory.data, '$.properties.archivedAt') IS NULL
10481274
${cursorClause}
10491275
ORDER BY json_extract(memory.data, '$.properties.timestamp') DESC, memory.id DESC
10501276
LIMIT ?
@@ -1282,7 +1508,9 @@ class MemoryService extends Base {
12821508
}
12831509

12841510
const existing = JSON.parse(row.data),
1285-
properties = {...(existing.properties || {}), miniSummary},
1511+
// Archive-aware: this addNodes overwrites the node, so a tombstone set after `existing` was
1512+
// read (the merge-vs-archive race) must be replayed from the durable marker (_withArchiveState).
1513+
properties = this._withArchiveState({...(existing.properties || {}), miniSummary}),
12861514
nodeData = {
12871515
id : existing.id || id,
12881516
label : existing.label || 'AGENT_MEMORY',
@@ -1502,6 +1730,19 @@ class MemoryService extends Base {
15021730
let distances = searchResult.distances?.[0] || [];
15031731
let metadatas = searchResult.metadatas?.[0] || [];
15041732

1733+
// Tombstone exclusion: archived rows (metadata.archivedAt set) are dropped from
1734+
// recall. UNCONDITIONAL — the legacy/trust post-filter below only runs for some policies,
1735+
// so the exclusion cannot live there. A dropped archived row reads as a genuine no-match.
1736+
if (metadatas.some(m => m && m.archivedAt)) {
1737+
const live = [];
1738+
for (let i = 0; i < metadatas.length; i++) {
1739+
if (!(metadatas[i] && metadatas[i].archivedAt)) live.push(i);
1740+
}
1741+
ids = live.map(i => ids[i]);
1742+
distances = live.map(i => distances[i]);
1743+
metadatas = live.map(i => metadatas[i]);
1744+
}
1745+
15051746
if ((userId && policy === 'legacy') || minTrustTier) {
15061747
const filteredIndices = [];
15071748
for (let i = 0; i < metadatas.length; i++) {

0 commit comments

Comments
 (0)