import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { MindDB } from '../../src/mind/db.js'; import { FrameStore } from '../../src/mind/frames.js'; import { RawArchive, RAW_ARCHIVE_REDACTION_MARKER } from '../../src/mind/raw-archive.js'; import { KnowledgeGraph } from '../../src/mind/knowledge.js'; import { SessionStore } from '../../src/mind/sessions.js'; import { MindErasure, type EraseResult } from '../../src/mind/erasure.js'; import { SuppressionStore } from '../../src/mind/suppression.js'; import { rawTurnHeader, rawTurnConvKey } from '../../src/harvest/raw-turns.js'; import { decisionOfSubjectId } from '../../src/harvest/decision-derivation.js'; // ── Test helpers ─────────────────────────────────────────────────────────── const DIM = 1024; /** A syntactically-valid vec0 float[1024] blob — content irrelevant, we only * ever assert on presence/absence, never on similarity. No embedder needed. */ function fakeVecBlob(): Uint8Array { return new Uint8Array(new Float32Array(DIM).fill(0.1).buffer); } function cnt(db: MindDB, sql: string, ...params: unknown[]): number { return (db.getDatabase().prepare(sql).get(...params) as { c: number }).c; } /** Simulate a chunk-indexed frame WITHOUT an embedder: insert a chunk row + * its chunk-vec row (rowid = chunk id, exactly as HybridSearch does). */ function addChunk(db: MindDB, frameId: number, idx: number, text: string): number { const raw = db.getDatabase(); const res = raw.prepare( 'INSERT INTO memory_frame_chunks (frame_id, chunk_idx, content, char_start, char_end) VALUES (?,?,?,?,?)' ).run(frameId, idx, text, 0, text.length); const chunkId = Number(res.lastInsertRowid); raw.prepare(`INSERT INTO memory_frame_chunks_vec (rowid, embedding) VALUES (${chunkId}, ?)`).run(fakeVecBlob()); return chunkId; } /** Insert a whole-frame vector row (rowid = frame id, as HybridSearch does). */ function addFrameVec(db: MindDB, frameId: number): void { db.getDatabase().prepare(`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${frameId}, ?)`).run(fakeVecBlob()); } const ZERO: EraseResult = { framesDeleted: 0, archiveRedacted: 0, chunkVectorsPurged: 0, entitiesErased: 0, relationsErased: 0, }; // ── FrameStore.delete() chunk-vec leak fix ────────────────────────────────── describe('FrameStore.delete — chunk-vec leak fix', () => { let db: MindDB; let frames: FrameStore; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('g', 'g', 'test'); frames = new FrameStore(db); }); afterEach(() => db.close()); it('purges memory_frame_chunks_vec rows for the frame (the vec0 leak)', () => { const f = frames.createIFrame('g', 'frame body', 'normal', 'import'); const chunkId = addChunk(db, f.id, 0, 'chunk body'); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks_vec WHERE rowid = ?', chunkId)).toBe(1); expect(frames.delete(f.id)).toBe(true); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks_vec WHERE rowid = ?', chunkId)).toBe(0); // vec purged expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks WHERE frame_id = ?', f.id)).toBe(0); // rows cascaded }); }); // ── MindErasure.eraseFrame ────────────────────────────────────────────────── describe('MindErasure.eraseFrame', () => { let db: MindDB; let frames: FrameStore; let archive: RawArchive; let kg: KnowledgeGraph; let erasure: MindErasure; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('harvest', 'harvest', 'test'); frames = new FrameStore(db); archive = new RawArchive(db); kg = new KnowledgeGraph(db); erasure = new MindErasure(db); }); afterEach(() => db.close()); it('deletes the frame from every retrieval store and redacts its provenance', () => { const r = archive.append({ source: 'claude', sourceRef: 'c1', content: 'SENSITIVE PII' }); const archiveId = archive.getByUid(r.archiveUid)!.id; // frozen handle (uid rotates on erase) const f = frames.createIFrame('harvest', 'summary quoting PII', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [r.archiveUid] })); addFrameVec(db, f.id); const chunkId = addChunk(db, f.id, 0, 'chunk quoting PII'); const res = erasure.eraseFrame(f.id, 'dsar#1'); expect(res.framesDeleted).toBe(1); expect(res.archiveRedacted).toBe(1); expect(res.chunkVectorsPurged).toBe(1); // Frame gone from EVERY recall path: expect(frames.getById(f.id)).toBeUndefined(); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_fts WHERE rowid = ?', f.id)).toBe(0); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_vec WHERE rowid = ?', f.id)).toBe(0); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks WHERE frame_id = ?', f.id)).toBe(0); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks_vec WHERE rowid = ?', chunkId)).toBe(0); // Provenance skeleton kept but content redacted (the audit record survives); // the uid rotated on erase, so resolve by the frozen id. const row = archive.getById(archiveId)!; expect(row.content).toBe(RAW_ARCHIVE_REDACTION_MARKER); expect(row.erased_at).not.toBeNull(); expect(row.source_ref).toBe('c1'); // skeleton frozen }); it('hard-deletes an orphaned entity + its relations but preserves a shared entity', () => { const f1 = frames.createIFrame('harvest', 'frame one', 'normal', 'import'); const f2 = frames.createIFrame('harvest', 'frame two', 'normal', 'import'); const entA = kg.createEntity('person', 'Alice Orphan', {}); // linked ONLY to f1 const entB = kg.createEntity('person', 'Bob Shared', {}); // linked to f1 AND f2 kg.linkEntityToFrame(entA.id, f1.id); kg.linkEntityToFrame(entB.id, f1.id); kg.linkEntityToFrame(entB.id, f2.id); kg.createRelation(entA.id, entB.id, 'knows'); // A -> B const res = erasure.eraseFrame(f1.id, 'dsar'); expect(res.entitiesErased).toBe(1); // only the orphan A expect(res.relationsErased).toBe(1); // the A->B relation expect(kg.getEntity(entA.id)).toBeUndefined(); // A physically gone expect(kg.getEntity(entB.id)).toBeDefined(); // B survives (shared) expect(cnt(db, 'SELECT COUNT(*) c FROM kg_entity_frames WHERE entity_id = ?', entB.id)).toBe(1); // still linked to f2 expect(cnt(db, 'SELECT COUNT(*) c FROM knowledge_relations WHERE source_id = ? OR target_id = ?', entA.id, entA.id)).toBe(0); }); it('returns an all-zero result for an unknown frame id (no throw)', () => { expect(erasure.eraseFrame(999_999, 'x')).toEqual(ZERO); }); // #7 review HIGH: harvest created entities with NO frame link, so the orphan // sweep could never reach them. importEntitiesForFrame anchors them so erasure // (and any provenance op) can. This test pins the write-path→erasure chain. it('reaches entities imported via importEntitiesForFrame (harvest write-path linkage)', () => { const f = frames.createIFrame('harvest', 'note about Jane Doe', 'normal', 'import'); const n = kg.importEntitiesForFrame( f.id, [{ name: 'Jane Doe', type: 'person' }], { source: 'claude', importedFrom: 'note.md' }, ); expect(n).toBe(1); const ent = kg.findEntityByName('Jane Doe')!; expect(ent).toBeDefined(); // The entity is LINKED to its frame (the fix — was unlinked before): expect(cnt(db, 'SELECT COUNT(*) c FROM kg_entity_frames WHERE entity_id = ? AND frame_id = ?', ent.id, f.id)).toBe(1); const res = erasure.eraseFrame(f.id, 'dsar'); expect(res.entitiesErased).toBe(1); // erasure now reaches it expect(kg.findEntityByName('Jane Doe')).toBeUndefined(); // name PII physically gone }); }); // ── MindErasure.eraseBySourceRef (subject-level sweep) ─────────────────────── describe('MindErasure.eraseBySourceRef', () => { let db: MindDB; let frames: FrameStore; let archive: RawArchive; let erasure: MindErasure; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('harvest', 'harvest', 'test'); frames = new FrameStore(db); archive = new RawArchive(db); erasure = new MindErasure(db); }); afterEach(() => db.close()); it('sweeps every frame + archive row for a (source, source_ref) subject', () => { // Two archive rows, SAME (source, source_ref), different content → two uids. const a = archive.append({ source: 'claude', sourceRef: 'thread-42', content: 'msg one about the subject' }); const b = archive.append({ source: 'claude', sourceRef: 'thread-42', content: 'msg two about the subject' }); const aId = archive.getByUid(a.archiveUid)!.id; // frozen handles (uids rotate on erase) const bId = archive.getByUid(b.archiveUid)!.id; const f = frames.createIFrame('harvest', 'thread-42 summary', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [a.archiveUid, b.archiveUid] })); const res = erasure.eraseBySourceRef('claude', 'thread-42', 'dsar#7'); expect(res.framesDeleted).toBe(1); expect(res.archiveRedacted).toBe(2); expect(frames.getById(f.id)).toBeUndefined(); expect(archive.getById(aId)!.content).toBe(RAW_ARCHIVE_REDACTION_MARKER); expect(archive.getById(bId)!.content).toBe(RAW_ARCHIVE_REDACTION_MARKER); }); it('redacts an orphan archive row with no linking frame in the subject set', () => { const r = archive.append({ source: 'claude', sourceRef: 'lonely', content: 'orphan pii' }); const rId = archive.getByUid(r.archiveUid)!.id; // frozen handle (uid rotates on erase) const res = erasure.eraseBySourceRef('claude', 'lonely', 'dsar'); expect(res.framesDeleted).toBe(0); expect(res.archiveRedacted).toBe(1); expect(archive.getById(rId)!.content).toBe(RAW_ARCHIVE_REDACTION_MARKER); }); it('is a no-op (all-zero) when no archive rows match the subject', () => { expect(erasure.eraseBySourceRef('claude', 'no-such-ref', 'x')).toEqual(ZERO); }); // Reference-class leak (recovered in the erase-surface review): a subject can // have verbatim [mind-rawturn] frames with NO raw_archive row at all — a // raw_archive.append that failed while the raw-turns still wrote, or a legacy // pre-#7 conversation. eraseBySourceRef must NOT early-return on the empty uid // set; the 2b sweep is conv-prefix-keyed, independent of raw_archive. it('sweeps verbatim raw-turns for a subject with NO raw_archive row (append-failed / legacy)', () => { const convKey = rawTurnConvKey({ source: 'gemini', id: 'no-archive' }); const t1 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 0, 'user')}\nverbatim PII`, 'normal', 'import'); const t2 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 1, 'assistant')}\nmore PII`, 'normal', 'import'); expect(cnt(db, 'SELECT COUNT(*) c FROM raw_archive WHERE source = ? AND source_ref = ?', 'gemini', 'no-archive')).toBe(0); const res = erasure.eraseBySourceRef('gemini', 'no-archive', 'dsar'); expect(frames.getById(t1.id)).toBeUndefined(); expect(frames.getById(t2.id)).toBeUndefined(); expect(res.framesDeleted).toBe(2); expect(res.archiveRedacted).toBe(0); // no provenance rows to redact }); // #7 P1 (S4 residual): subject-mode must ALSO erase the distilled SUMMARY frame // when it has NO archive link (raw_archive.append failed / legacy pre-#7). 2a is // archiveUid-keyed so it misses it; recover symmetric to eraseFrameComplete's // fallback via metadata.sourceId (= sourceRef) + the content platform-prefix. // Frame-mode already handled this; subject-mode (route {source,sourceRef} + MCP // source+source_ref) left the summary recall-able — an Art.17 completeness hole. it('erases an archive-less summary frame for the subject (metadata.sourceId + prefix fallback)', () => { // Harvest summary with NO archiveUids — exactly what harvest.ts writes when // rawArchive.append throws: content platform-prefix + metadata.sourceId. const f = frames.createIFrame('harvest', '[Harvest:gemini] Trip planning\n\nsummary quoting PII', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ sourceId: 'g-trip', status: 'unreviewed' })); // Its verbatim raw-turns (swept by 2b — pinned so we don't regress them). const convKey = rawTurnConvKey({ source: 'gemini', id: 'g-trip' }); const t1 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 0, 'user')}\nverbatim PII`, 'normal', 'import'); // A DIFFERENT subject's summary (same source) MUST survive. const other = frames.createIFrame('harvest', '[Harvest:gemini] Other trip\n\nkeep me', 'normal', 'import'); frames.setMetadata(other.id, JSON.stringify({ sourceId: 'g-other' })); expect(cnt(db, 'SELECT COUNT(*) c FROM raw_archive WHERE source = ? AND source_ref = ?', 'gemini', 'g-trip')).toBe(0); const res = erasure.eraseBySourceRef('gemini', 'g-trip', 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); // archive-less summary erased (the fix) expect(frames.getById(t1.id)).toBeUndefined(); // raw-turn still swept expect(frames.getById(other.id)).toBeDefined(); // other subject untouched expect(res.framesDeleted).toBe(2); // summary + raw-turn }); // #7 review CRITICAL: verbatim [mind-rawturn …] frames carry NO archiveUids, so a // link-only sweep leaves the subject's full dialogue recall-able. They must be // swept by their content-prefix conversation key (= sanitize(source∥sourceRef)). it('purges the conversation raw-turn frames (content-prefix keyed, no archive link)', () => { const r = archive.append({ source: 'claude', sourceRef: 'item-9', content: 'summary source' }); const f = frames.createIFrame('harvest', 'summary of item-9', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [r.archiveUid] })); const convKey = rawTurnConvKey({ source: 'claude', id: 'item-9' }); const t1 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 0, 'user')}\nverbatim PII turn one`, 'normal', 'import'); const t2 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 1, 'assistant')}\nverbatim PII turn two`, 'normal', 'import'); // A DIFFERENT subject's raw-turn (same source) MUST survive. const otherKey = rawTurnConvKey({ source: 'claude', id: 'other-item' }); const o = frames.createIFrame('harvest', `${rawTurnHeader(otherKey, 0, 'user')}\nunrelated dialogue`, 'normal', 'import'); const res = erasure.eraseBySourceRef('claude', 'item-9', 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); // summary expect(frames.getById(t1.id)).toBeUndefined(); // verbatim turn 1 expect(frames.getById(t2.id)).toBeUndefined(); // verbatim turn 2 expect(frames.getById(o.id)).toBeDefined(); // other subject untouched expect(res.framesDeleted).toBe(3); expect(res.archiveRedacted).toBe(1); // FTS purged for a swept verbatim turn (no longer recall-able): expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_fts WHERE rowid = ?', t1.id)).toBe(0); }); it('does NOT over-erase when one subject key is a prefix of another', () => { const r = archive.append({ source: 'claude', sourceRef: 'item', content: 's' }); const f = frames.createIFrame('harvest', 'summary item', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [r.archiveUid] })); // Raw-turn belonging to 'item-9' must NOT be caught by a sweep of 'item'. const longerKey = rawTurnConvKey({ source: 'claude', id: 'item-9' }); const survivor = frames.createIFrame('harvest', `${rawTurnHeader(longerKey, 0, 'user')}\nkeep me`, 'normal', 'import'); erasure.eraseBySourceRef('claude', 'item', 'dsar'); expect(frames.getById(survivor.id)).toBeDefined(); // 'item-9' turn not swept by 'item' }); // #7 review LOW: synthesized B-frames reference the erased frames in content JSON // and carry no archiveUids — sweep them via the reference intersection. it('purges a B-frame that references an erased frame', () => { const r = archive.append({ source: 'claude', sourceRef: 'b1', content: 'src' }); const f = frames.createIFrame('harvest', 'summary', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [r.archiveUid] })); const b = frames.createBFrame('harvest', 'Shared entity: Jane Doe', f.id, [f.id]); const res = erasure.eraseBySourceRef('claude', 'b1', 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); expect(frames.getById(b.id)).toBeUndefined(); // B-frame swept expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_fts WHERE rowid = ?', b.id)).toBe(0); expect(res.framesDeleted).toBe(2); // summary + B-frame }); }); describe('MindErasure.eraseFrameComplete (shared route + MCP primitive)', () => { let db: MindDB; let frames: FrameStore; let archive: RawArchive; let erasure: MindErasure; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('harvest', 'harvest', 'test'); frames = new FrameStore(db); archive = new RawArchive(db); erasure = new MindErasure(db); }); afterEach(() => db.close()); it('sweeps a linked harvested summary + its raw-turns (archive-linked path)', () => { const r = archive.append({ source: 'claude', sourceRef: 'c1', content: 'summary source' }); const f = frames.createIFrame('harvest', 'summary of c1', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ sourceId: 'c1', archiveUids: [r.archiveUid] })); const convKey = rawTurnConvKey({ source: 'claude', id: 'c1' }); const t1 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 0, 'user')}\nPII a`, 'normal', 'import'); const t2 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 1, 'assistant')}\nPII b`, 'normal', 'import'); const res = erasure.eraseFrameComplete(f.id, 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); expect(frames.getById(t1.id)).toBeUndefined(); expect(frames.getById(t2.id)).toBeUndefined(); expect(res.framesDeleted).toBe(3); expect(res.archiveRedacted).toBe(1); }); it('reaches raw-turns via the metadata.sourceId + content-prefix FALLBACK when the summary has no archive link', () => { // No archive row / no archiveUids — content carries the server harvest prefix. const f = frames.createIFrame('harvest', '[Harvest:gemini] Trip\n\nsummary', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ sourceId: 'g1' })); const convKey = rawTurnConvKey({ source: 'gemini', id: 'g1' }); const t1 = frames.createIFrame('harvest', `${rawTurnHeader(convKey, 0, 'user')}\nPII`, 'normal', 'import'); const res = erasure.eraseFrameComplete(f.id, 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); expect(frames.getById(t1.id)).toBeUndefined(); // reached via fallback expect(res.framesDeleted).toBe(2); }); it('returns all-zero for an unknown frame id (no throw)', () => { expect(erasure.eraseFrameComplete(999999, 'x')).toEqual(ZERO); }); // A single-frame memory (connector / ingest_source style: no archiveUids, no // metadata.sourceId, no raw-turns) that a synthesized B-frame references. // eraseFrameComplete's documented intent is to reach "referencing B-frames"; // for a SUBJECT-LESS frame it resolved no subject → never ran the B-frame sweep, // so the B-frame (which can quote the erased frame's PII) survived. Must sweep it. it('sweeps a B-frame referencing the erased frame even when the frame has NO subject link', () => { const f = frames.createIFrame('harvest', '[Harvest:connector:crm] Jane Doe record\n\nverbatim PII', 'normal', 'import'); const b = frames.createBFrame('harvest', 'Synthesized: Jane Doe is a CRM contact', f.id, [f.id]); const res = erasure.eraseFrameComplete(f.id, 'dsar'); expect(frames.getById(f.id)).toBeUndefined(); expect(frames.getById(b.id)).toBeUndefined(); // B-frame swept (no residual synthesized PII) expect(res.framesDeleted).toBe(2); // the frame + its referencing B-frame }); }); // ── FrameStore.compact — no vector/index leak (review MEDIUM #3) ───────────── describe('FrameStore.compact — no orphaned vector/index rows', () => { let db: MindDB; let frames: FrameStore; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('g', 'g', 'test'); frames = new FrameStore(db); }); afterEach(() => db.close()); it('pruning a temporary frame leaves no orphan in _vec / _fts / _chunks_vec', () => { // A temporary frame older than the 30-day prune threshold, fully indexed. const f = frames.createIFrame('g', 'ephemeral note', 'temporary', 'import', '2020-01-01T00:00:00Z'); addFrameVec(db, f.id); const chunkId = addChunk(db, f.id, 0, 'ephemeral chunk'); frames.compact(30, 90); expect(frames.getById(f.id)).toBeUndefined(); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_vec WHERE rowid = ?', f.id)).toBe(0); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frames_fts WHERE rowid = ?', f.id)).toBe(0); expect(cnt(db, 'SELECT COUNT(*) c FROM memory_frame_chunks_vec WHERE rowid = ?', chunkId)).toBe(0); }); }); // ── #7 P2: claude-code `decision-of` derived subject ───────────────────────── // claude-code harvest's extractDecisions emits a SEPARATE import item keyed on // stableHarvestId('claude-code','decision-of',parentId) that quotes the parent's // decision lines. It lands as its OWN (source, source_ref) subject — a different // archiveUid/raw-turn key than the parent, and it is not a B-frame — so a sweep of // the PARENT never reaches it: the derived frame survives erasure AND (its key being // un-suppressed) re-materializes on the next re-import. The persisted frame drops // item.metadata.extractedFrom (harvest.ts stamps only kind/confidence/status/ // sourceId/archiveUids), so the derived subject is reached by RECOMPUTING its key. describe('MindErasure.eraseBySourceRef — claude-code decision-of derived subject (#7 P2)', () => { let db: MindDB; let frames: FrameStore; let archive: RawArchive; let erasure: MindErasure; let suppression: SuppressionStore; beforeEach(() => { db = new MindDB(':memory:'); new SessionStore(db).ensure('harvest', 'harvest', 'test'); frames = new FrameStore(db); archive = new RawArchive(db); erasure = new MindErasure(db); suppression = new SuppressionStore(db); }); afterEach(() => db.close()); /** Seed a harvested claude-code subject exactly as harvest.ts writes it: * a raw_archive row + a summary frame linked via archiveUids + metadata.sourceId * + the '[Harvest:claude-code] …' content prefix. Returns the frame id. */ function seedSubject(sourceRef: string, title: string, content: string): number { const r = archive.append({ source: 'claude-code', sourceRef, content }); const f = frames.createIFrame('harvest', `[Harvest:claude-code] ${title}\n\n${content}`, 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ sourceId: sourceRef, status: 'unreviewed', archiveUids: [r.archiveUid] })); return f.id; } const PARENT_REF = 'projects/foo/.mind/decisions-2026.md'; const DERIVED_REF = decisionOfSubjectId(PARENT_REF); it('erases the derived decision-of frame when the parent subject is erased', () => { const parent = seedSubject(PARENT_REF, 'Decisions 2026', 'we DECIDED to ship X'); const derived = seedSubject(DERIVED_REF, 'Decisions from: Decisions 2026', 'we DECIDED to ship X'); const res = erasure.eraseBySourceRef('claude-code', PARENT_REF, 'dsar#42'); expect(frames.getById(parent)).toBeUndefined(); // parent (baseline) expect(frames.getById(derived)).toBeUndefined(); // derived reached (the fix) expect(res.framesDeleted).toBe(2); // parent summary + derived summary }); it('suppresses the derived decision-of key so it cannot re-materialize on re-import', () => { seedSubject(PARENT_REF, 'Decisions 2026', 'we DECIDED to ship X'); seedSubject(DERIVED_REF, 'Decisions from: Decisions 2026', 'we DECIDED to ship X'); erasure.eraseBySourceRef('claude-code', PARENT_REF, 'dsar#42'); expect(suppression.isSuppressed('claude-code', PARENT_REF)).toBe(true); // parent (baseline) expect(suppression.isSuppressed('claude-code', DERIVED_REF)).toBe(true); // derived key (the fix) }); it('does NOT record a phantom derived suppression when the parent had no decision-of frame', () => { // A claude-code note with no decision derivation → no derived frame exists. const plainRef = 'projects/foo/.mind/plain.md'; const plainDerived = decisionOfSubjectId(plainRef); seedSubject(plainRef, 'Plain note', 'nothing notable here'); erasure.eraseBySourceRef('claude-code', plainRef, 'dsar'); expect(suppression.isSuppressed('claude-code', plainRef)).toBe(true); // explicit subject recorded expect(suppression.isSuppressed('claude-code', plainDerived)).toBe(false); // no phantom derived row expect(suppression.list()).toHaveLength(1); // exactly one entry }); it('does not attempt a decision-of cascade for a non-claude-code source', () => { const r = archive.append({ source: 'claude', sourceRef: 'thread-1', content: 'x' }); const f = frames.createIFrame('harvest', 'summary', 'normal', 'import'); frames.setMetadata(f.id, JSON.stringify({ archiveUids: [r.archiveUid] })); erasure.eraseBySourceRef('claude', 'thread-1', 'dsar'); // Only the explicit subject is suppressed — no derived 'decision-of' row for // an adapter that never derives decisions. expect(suppression.list().map(s => s.sourceRef)).toEqual(['thread-1']); }); // Atomicity invariant (erasure.ts preamble: "Every multi-table erasure runs in // ONE better-sqlite3 transaction — a partial erasure is a compliance failure"). // The P2 refactor extracted the sweep into a non-transactional eraseSubjectFrames // called TWICE (primary + derived) inside eraseBySourceRef's single transaction. // Pin that the whole cascade is one atomic unit: a failure on the LAST write (the // derived suppression.record, after the primary erase + primary record already ran // in the txn) must roll back EVERYTHING — no half-erased subject, no orphan // suppression row. (A released better-sqlite3 savepoint is NOT durable; the outer // rollback discards it — so this also holds when eraseFrameComplete wraps this.) it('rolls the whole primary+derived cascade back atomically if a later write throws', () => { const parent = seedSubject(PARENT_REF, 'Decisions 2026', 'we DECIDED to ship X'); const derived = seedSubject(DERIVED_REF, 'Decisions from: Decisions 2026', 'we DECIDED to ship X'); // Inject a failure on the DERIVED suppression.record — the final write in the // cascade, after the primary subject has already been erased + recorded inside // the same transaction. Delegate the primary record to the real implementation. const realRecord = SuppressionStore.prototype.record; const spy = vi.spyOn(SuppressionStore.prototype, 'record').mockImplementation(function ( this: SuppressionStore, source: string, sourceRef: string, reason?: string, ): void { if (sourceRef === DERIVED_REF) throw new Error('injected mid-transaction failure on derived record'); realRecord.call(this, source, sourceRef, reason); }); expect(() => erasure.eraseBySourceRef('claude-code', PARENT_REF, 'dsar')).toThrow('injected mid-transaction failure'); spy.mockRestore(); // FULL rollback — the transaction guarantee held: expect(frames.getById(parent)).toBeDefined(); // primary erase rolled back expect(frames.getById(derived)).toBeDefined(); // derived erase rolled back expect(suppression.list()).toHaveLength(0); // primary record rolled back too — no orphan }); });