/** * Tier 5 — DATA INTEGRITY: Phase 6 derived-artifact storage layer. * * Covers (§10 Data-integrity obligations): * - Provenance round-trips intact and co-located with its artifact * - No orphan after note delete (P6-g) * - Crypto-shred: privacy_max ciphertext is unreadable after key destruction * - Aggregate insight stale-flag + re-enrichment preserves source_event_id history * - Re-enrichment never destroys the prior artifact on a failed gate */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createDerivedArtifactWriter, WRITER_REASONS, } from '../lib/companion-artifact-writer.mjs'; import { createClientEncryptor, } from '../lib/companion-client-encryptor.mjs'; import { buildConvenienceProvenance, PROVENANCE_SCHEMA_VERSION, validateProvenance, } from '../lib/companion-provenance-validator.mjs'; import { TERMINAL_STATES } from '../lib/companion-tier-resolver.mjs'; // ── Helpers ─────────────────────────────────────────────────────────────────── function buildIntegrityStores() { const noteStore = new Map(); const vectorStore = new Map(); // notePath → point const insightStore = []; const maintenanceLog = []; const writeNoteFn = (_vp, notePath, opts) => { const prev = noteStore.get(notePath) ?? {}; noteStore.set(notePath, { ...prev, ...opts.frontmatter }); }; const vs = { upsert: async (points) => { for (const p of points) { vectorStore.set(p.path, p); } }, deleteByPath: async (notePath) => { vectorStore.delete(notePath); }, }; const mm = { store: (type, data) => { if (type === 'insight') insightStore.push(data); if (type === 'maintenance') maintenanceLog.push(data); return { id: `mem_${Math.random().toString(36).slice(2)}`, ts: new Date().toISOString() }; }, }; return { noteStore, vectorStore, insightStore, maintenanceLog, writeNoteFn, vs, mm }; } function selfCtx() { return { lane: 'local', containsPrivateData: false, isDelegate: false, delegatedManagedAllowed: false, enrichesDelegatedPartition: false, delegatedEnrichmentAllowed: false, }; } // ── Provenance round-trip ───────────────────────────────────────────────────── describe('data-integrity — provenance round-trips intact (D6.2.4)', () => { it('ai_summary provenance sidecar contains all required fields after write', async () => { const { noteStore, writeNoteFn } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); const originalProv = buildConvenienceProvenance({ generatedBy: 'integrity-user', source: 'companion', model: 'integrity-model', modelVersion: '2.0', runtimeVersion: '1.5.0', lane: 'local', artifactType: 'ai_summary', sourceNotePath: 'integrity/note.md', sourceEventId: 'mem_int_001', }); await writer.write({ summary: 'Integrity check summary.' }, originalProv, selfCtx()); const fm = noteStore.get('integrity/note.md'); const stored = fm.ai_summary_provenance; // All D6.2.1 required fields must survive the round-trip assert.equal(stored.generated_by, 'integrity-user'); assert.equal(stored.source, 'companion'); assert.equal(stored.model, 'integrity-model'); assert.equal(stored.model_version, '2.0'); assert.equal(stored.runtime_version, '1.5.0'); assert.equal(stored.lane, 'local'); assert.equal(stored.privacy_tier, 'convenience'); assert.equal(stored.source_note_path, 'integrity/note.md'); assert.equal(stored.source_event_id, 'mem_int_001'); assert.ok(stored.created_at); assert.equal(stored.artifact_type, 'ai_summary'); assert.equal(stored.schema_version, PROVENANCE_SCHEMA_VERSION); }); it('insight provenance round-trips with array source_event_id', async () => { const { insightStore, writeNoteFn, mm } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault', mm }); const prov = buildConvenienceProvenance({ generatedBy: 'discover-pass', source: 'companion', model: 'insight-model', modelVersion: '1.0', lane: 'local', artifactType: 'insight', sourceNotePath: null, sourceEventId: ['mem_c001', 'mem_c002', 'mem_c003'], }); await writer.write( { connections: ['A→B'], contradictions: [], open_questions: [], topic_count: 3 }, prov, selfCtx(), ); const stored = insightStore[0]; assert.ok(stored.provenance, 'Provenance must be co-located with the insight artifact'); assert.deepEqual(stored.provenance.source_event_id, ['mem_c001', 'mem_c002', 'mem_c003']); assert.equal(stored.provenance.artifact_type, 'insight'); }); }); // ── No orphan after note delete (P6-g) ──────────────────────────────────────── describe('data-integrity — no orphan artifacts after delete (P6-g, D6.5.1)', () => { it('note delete nulls ai_summary and purges vector entry', async () => { const { noteStore, vectorStore, writeNoteFn, vs, mm } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault', vectorStore: vs, mm, }); // Write summary const sumprov = buildConvenienceProvenance({ generatedBy: 'u', source: 'companion', model: 'm', modelVersion: '1', lane: 'local', artifactType: 'ai_summary', sourceNotePath: 'orphan/note.md', sourceEventId: 'mem_o001', }); await writer.write({ summary: 'Orphan check' }, sumprov, selfCtx()); // Write embedding const embprov = buildConvenienceProvenance({ generatedBy: 'u', source: 'companion', model: 'm', modelVersion: '1', lane: 'local', artifactType: 'embedding', sourceNotePath: 'orphan/note.md', sourceEventId: 'mem_o002', }); await writer.write({ vector: [1, 2, 3], payload: {} }, embprov, selfCtx()); // Verify pre-delete state assert.ok(noteStore.has('orphan/note.md')); assert.ok(vectorStore.has('orphan/note.md')); // Delete const delResult = await writer.deleteArtifacts({ notePath: 'orphan/note.md' }); assert.equal(delResult.ok, true); // ai_summary nulled — no orphan const fm = noteStore.get('orphan/note.md'); assert.equal(fm.ai_summary, null); assert.equal(fm.ai_summary_provenance, null); // Vector purged — no orphan assert.equal(vectorStore.has('orphan/note.md'), false, 'Vector entry must be purged'); }); it('multiple sequential deletes are idempotent (second delete does not error)', async () => { const { writeNoteFn, vs, mm } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault', vectorStore: vs, mm, }); const r1 = await writer.deleteArtifacts({ notePath: 'nonexistent/note.md' }); // First delete: writeNoteFn and mm.store succeed (null writes are ok); vs.deleteByPath is fine // The key assertion: it must not throw assert.ok(r1.ok === true || r1.ok === false, 'Must return a result object'); }); }); // ── Crypto-shred: ciphertext is unreadable after key destruction ────────────── describe('data-integrity — crypto-shred for privacy_max (D6.5.3)', () => { it('stored ciphertext is not the plaintext (key destruction simulation)', async () => { const { noteStore, writeNoteFn } = buildIntegrityStores(); // Simulate a user-held key that we then "destroy" let keyAvailable = true; const encryptor = createClientEncryptor({ isAvailable: () => keyAvailable, encrypt: (bytes, _opts) => { // XOR with 0xAA as a trivial "encryption" const ct = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i++) ct[i] = bytes[i] ^ 0xaa; return { ciphertext: ct, wrappedDekRef: 'dek-to-destroy', alg: 'STUB-XOR' }; }, }); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault', vaultRegistryAvailable: true, encryptor, }); const prov = buildConvenienceProvenance({ generatedBy: 'priv', source: 'companion', model: 'm', modelVersion: '1', lane: 'local', artifactType: 'ai_summary', sourceNotePath: 'private/shred.md', sourceEventId: 'mem_sh001', }); const privProv = { ...prov, privacy_tier: 'privacy_max' }; const plaintext = 'My very private content that must never be host-readable.'; await writer.write({ summary: plaintext }, privProv, { lane: 'local', containsPrivateData: true, isDelegate: false, delegatedManagedAllowed: false, }); const fm = noteStore.get('private/shred.md'); assert.ok(fm.ai_summary_ciphertext, 'Ciphertext must be stored'); // Simulate key destruction: wrappedDekRef recorded, key no longer accessible keyAvailable = false; assert.equal(encryptor.isAvailable('privacy_max', 'vault'), false); // The stored ciphertext is not the plaintext const storedBase64 = fm.ai_summary_ciphertext; const decoded = Buffer.from(storedBase64, 'base64').toString('utf8'); assert.notEqual(decoded, plaintext, 'Stored bytes must not be plaintext'); // wrappedDekRef is recorded for targeted key destruction assert.equal(fm.ai_summary_provenance.wrapped_dek_ref, 'dek-to-destroy'); }); }); // ── Stale-flag + re-enrichment preserves source_event_id history ────────────── describe('data-integrity — stale-flag preserves source_event_id (D6.5.2, D6.7)', () => { it('deleteArtifacts stores maintenance event with deleted_note_path', async () => { const { maintenanceLog, writeNoteFn, mm } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault', mm, }); await writer.deleteArtifacts({ notePath: 'agg/source.md' }); const maint = maintenanceLog.find((m) => m.deleted_note_path === 'agg/source.md'); assert.ok(maint, 'Maintenance stale-flag event must be recorded'); assert.equal(maint.action, 'artifact_delete_requested'); // source_event_id in the aggregate insight is preserved — the insight itself is NOT deleted }); }); // ── Re-enrichment does not destroy prior artifact on failed gate ────────────── describe('data-integrity — re-enrichment failure preserves prior artifact (D6.7 §fail-closed)', () => { it('failed write on re-enrichment leaves original artifact unchanged', async () => { const { noteStore, writeNoteFn } = buildIntegrityStores(); // Step 1: write original artifact const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); const origProv = buildConvenienceProvenance({ generatedBy: 'u', source: 'companion', model: 'm', modelVersion: 'v1', lane: 'local', artifactType: 'ai_summary', sourceNotePath: 'reenrich/note.md', sourceEventId: 'mem_r001', }); await writer.write({ summary: 'Original summary v1' }, origProv, selfCtx()); const origFm = { ...noteStore.get('reenrich/note.md') }; assert.equal(origFm.ai_summary, 'Original summary v1'); // Step 2: attempt re-enrichment with a broken writer (provenance missing) const badProv = { ...origProv, generated_by: '' }; // invalid — will fail validation const result = await writer.write({ summary: 'New summary v2' }, badProv, selfCtx()); assert.equal(result.ok, false); // Original artifact MUST be unchanged (no destructive half-write) const afterFm = noteStore.get('reenrich/note.md'); assert.equal(afterFm.ai_summary, 'Original summary v1', 'Prior artifact must be preserved on failed gate'); }); it('failed re-enrichment at consent gate leaves original unchanged', async () => { const { noteStore, writeNoteFn } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); const origProv = buildConvenienceProvenance({ generatedBy: 'u', source: 'companion', model: 'm', modelVersion: 'v1', lane: 'local', artifactType: 'ai_summary', sourceNotePath: 'reenrich2/note.md', sourceEventId: 'mem_r002', }); await writer.write({ summary: 'Original v1' }, origProv, selfCtx()); const origFm = { ...noteStore.get('reenrich2/note.md') }; // Re-enrichment attempt that fails at consent (delegated without permission) const result = await writer.write( { summary: 'v2 attempt' }, origProv, { lane: 'local', containsPrivateData: false, isDelegate: true, delegatedManagedAllowed: false, enrichesDelegatedPartition: true, delegatedEnrichmentAllowed: false, }, ); assert.equal(result.ok, false); const afterFm = noteStore.get('reenrich2/note.md'); assert.equal(afterFm.ai_summary, 'Original v1', 'Consent-denied re-enrichment must leave original intact'); }); }); // ── Provenance cannot be forged or omitted (P6-e) ──────────────────────────── describe('data-integrity — provenance cannot be omitted (P6-e, D6.2)', () => { it('write with missing provenance always fails', async () => { const { writeNoteFn } = buildIntegrityStores(); const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' }); for (const badProv of [null, undefined, {}, { generated_by: 'u' }]) { const r = await writer.write({ summary: 'test' }, badProv, selfCtx()); assert.equal(r.ok, false, `Provenance=${JSON.stringify(badProv)} must be rejected`); } }); });