derived-artifact-storage-e2e.test.mjs
340 lines 11.9 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 7 hours ago
1 /**
2 * Tier 3 — END-TO-END: Phase 6 derived-artifact storage layer.
3 *
4 * Covers (§10 E2E obligations):
5 * - Self-partition: local inference produces summary → writer persists per owner tier
6 * - Delegated enrichment: denied without delegatedEnrichmentAllowed, allowed with it
7 * - Convenience vs privacy_max branch (encryptor stub)
8 * - Note delete removes summary + vector + stale-flags insight
9 * - runDiscoverPass produces an insight event end-to-end through the writer
10 */
11
12 import { describe, it } from 'node:test';
13 import assert from 'node:assert/strict';
14
15 import {
16 createDerivedArtifactWriter,
17 WRITER_REASONS,
18 } from '../lib/companion-artifact-writer.mjs';
19
20 import {
21 createClientEncryptor,
22 } from '../lib/companion-client-encryptor.mjs';
23
24 import {
25 buildConvenienceProvenance,
26 PROVENANCE_SCHEMA_VERSION,
27 } from '../lib/companion-provenance-validator.mjs';
28
29 import { TERMINAL_STATES } from '../lib/companion-tier-resolver.mjs';
30 import { runDiscoverPass } from '../lib/memory-consolidate.mjs';
31
32 // ── E2E test double infrastructure ───────────────────────────────────────────
33
34 function buildE2EStores() {
35 const noteStore = new Map(); // notePath → frontmatter fields
36 const vectorStore = [];
37 const insightStore = [];
38 const maintenanceLog = [];
39
40 const writeNoteFn = (_vaultPath, notePath, opts) => {
41 const prev = noteStore.get(notePath) ?? {};
42 // Merge, treating null values as explicit nulls (deletion)
43 const merged = { ...prev };
44 for (const [k, v] of Object.entries(opts.frontmatter ?? {})) {
45 merged[k] = v;
46 }
47 noteStore.set(notePath, merged);
48 };
49
50 const vs = {
51 upsert: async (points) => { vectorStore.push(...points); },
52 deleteByPath: async (notePath) => {
53 const idx = vectorStore.findIndex((v) => v.path === notePath);
54 if (idx >= 0) vectorStore.splice(idx, 1);
55 },
56 };
57
58 const mm = {
59 store: (type, data) => {
60 if (type === 'insight') insightStore.push(data);
61 if (type === 'maintenance') maintenanceLog.push(data);
62 return { id: `mem_${Date.now()}`, ts: new Date().toISOString() };
63 },
64 };
65
66 return { noteStore, vectorStore, insightStore, maintenanceLog, writeNoteFn, vs, mm };
67 }
68
69 // ── E2E: self-partition convenience write ─────────────────────────────────────
70
71 describe('E2E — self-partition summary write at convenience tier', () => {
72 it('full flow: local inference → writer → host-readable frontmatter', async () => {
73 const { noteStore, writeNoteFn } = buildE2EStores();
74 const writer = createDerivedArtifactWriter({
75 writeNoteFn,
76 vaultPath: '/vault',
77 vaultRegistryAvailable: false,
78 });
79
80 const prov = buildConvenienceProvenance({
81 generatedBy: 'user-self',
82 source: 'companion',
83 model: 'llama-3',
84 modelVersion: '3.1',
85 runtimeVersion: '0.9.0',
86 lane: 'local',
87 artifactType: 'ai_summary',
88 sourceNotePath: 'research/quantum.md',
89 sourceEventId: 'mem_q001',
90 });
91
92 const result = await writer.write(
93 { summary: 'Quantum entanglement is a physical phenomenon.' },
94 prov,
95 {
96 lane: 'local',
97 containsPrivateData: false,
98 isDelegate: false,
99 delegatedManagedAllowed: false,
100 enrichesDelegatedPartition: false,
101 delegatedEnrichmentAllowed: false,
102 },
103 );
104
105 assert.equal(result.ok, true);
106 assert.equal(result.terminalState, TERMINAL_STATES.HOST_READABLE);
107
108 const fm = noteStore.get('research/quantum.md');
109 assert.ok(fm);
110 assert.equal(fm.ai_summary, 'Quantum entanglement is a physical phenomenon.');
111 assert.ok(fm.ai_summary_provenance);
112 assert.equal(fm.ai_summary_provenance.generated_by, 'user-self');
113 assert.equal(fm.ai_summary_provenance.schema_version, PROVENANCE_SCHEMA_VERSION);
114 });
115 });
116
117 // ── E2E: delegated enrichment default-OFF ────────────────────────────────────
118
119 describe('E2E — delegated enrichment: denied by default (D6.3.3)', () => {
120 it('rejects delegated local-lane write without owner opt-in', async () => {
121 const { writeNoteFn, noteStore } = buildE2EStores();
122 const writer = createDerivedArtifactWriter({ writeNoteFn, vaultPath: '/vault' });
123
124 const prov = buildConvenienceProvenance({
125 generatedBy: 'delegate-user',
126 source: 'companion',
127 model: 'llama-3',
128 modelVersion: '3.1',
129 lane: 'local',
130 artifactType: 'ai_summary',
131 sourceNotePath: 'owner/note.md',
132 sourceEventId: 'mem_d001',
133 });
134
135 // D6.3.6: cross-partition write → self-partition only until tenancy gate
136 const result = await writer.write({ summary: 'delegate summary' }, prov, {
137 lane: 'local',
138 containsPrivateData: false,
139 isDelegate: true,
140 delegatedManagedAllowed: false,
141 enrichesDelegatedPartition: true, // actor ≠ owner
142 delegatedEnrichmentAllowed: false, // owner has NOT opted in
143 });
144
145 assert.equal(result.ok, false);
146 assert.equal(result.reason, WRITER_REASONS.SELF_PARTITION_ONLY);
147 assert.equal(noteStore.size, 0, 'No write should occur');
148 });
149 });
150
151 // ── E2E: privacy_max branch (encryptor stub) ──────────────────────────────────
152
153 describe('E2E — privacy_max branch with stub encryptor', () => {
154 it('stores only ciphertext when encryptor is available', async () => {
155 const { noteStore, writeNoteFn } = buildE2EStores();
156
157 const stubEncryptor = createClientEncryptor({
158 isAvailable: () => true,
159 encrypt: (bytes, _opts) => ({
160 ciphertext: new Uint8Array(bytes.length).fill(0xab),
161 wrappedDekRef: 'wrapped-dek-for-vault',
162 alg: 'STUB-AES-256',
163 }),
164 });
165
166 const writer = createDerivedArtifactWriter({
167 writeNoteFn,
168 vaultPath: '/vault',
169 vaultRegistryAvailable: true,
170 encryptor: stubEncryptor,
171 });
172
173 const prov = buildConvenienceProvenance({
174 generatedBy: 'priv-user',
175 source: 'companion',
176 model: 'llama-3',
177 modelVersion: '3.1',
178 lane: 'local',
179 artifactType: 'ai_summary',
180 sourceNotePath: 'private/note.md',
181 sourceEventId: 'mem_p001',
182 });
183 // Override privacy_tier after building (buildConvenienceProvenance always sets convenience)
184 const privProv = { ...prov, privacy_tier: 'privacy_max' };
185
186 const result = await writer.write(
187 { summary: 'My private content' },
188 privProv,
189 { lane: 'local', containsPrivateData: true, isDelegate: false,
190 delegatedManagedAllowed: false },
191 );
192
193 assert.equal(result.ok, true);
194 assert.equal(result.terminalState, TERMINAL_STATES.CLIENT_ENCRYPTED);
195
196 const fm = noteStore.get('private/note.md');
197 assert.ok(fm.ai_summary_ciphertext, 'Ciphertext must be stored');
198 assert.equal(fm.ai_summary, undefined, 'Plaintext ai_summary must NOT be stored');
199 assert.equal(fm.ai_summary_provenance.wrapped_dek_ref, 'wrapped-dek-for-vault');
200 });
201
202 it('fails closed when encryptor unavailable — nothing written', async () => {
203 const { noteStore, writeNoteFn } = buildE2EStores();
204 const writer = createDerivedArtifactWriter({
205 writeNoteFn,
206 vaultPath: '/vault',
207 vaultRegistryAvailable: true,
208 // Default encryptor (unavailable)
209 });
210
211 const prov = buildConvenienceProvenance({
212 generatedBy: 'priv-user',
213 source: 'companion',
214 model: 'llama-3',
215 modelVersion: '3.1',
216 lane: 'local',
217 artifactType: 'ai_summary',
218 sourceNotePath: 'private/note.md',
219 sourceEventId: 'mem_p002',
220 });
221 const privProv = { ...prov, privacy_tier: 'privacy_max' };
222
223 const result = await writer.write({ summary: 'secret' }, privProv, {
224 lane: 'local', containsPrivateData: true, isDelegate: false,
225 delegatedManagedAllowed: false,
226 });
227
228 assert.equal(result.ok, false);
229 assert.equal(noteStore.size, 0);
230 });
231 });
232
233 // ── E2E: note delete removes all derived artifacts ────────────────────────────
234
235 describe('E2E — note delete removes summary + vector, stale-flags insight (D6.5)', () => {
236 it('full delete flow across all stores', async () => {
237 const { noteStore, vectorStore, maintenanceLog, writeNoteFn, vs, mm } = buildE2EStores();
238
239 // Pre-populate via writer
240 const writer = createDerivedArtifactWriter({
241 writeNoteFn,
242 vaultPath: '/vault',
243 vectorStore: vs,
244 mm,
245 });
246
247 // Write summary
248 const sumprov = buildConvenienceProvenance({
249 generatedBy: 'u', source: 'companion', model: 'm', modelVersion: '1',
250 lane: 'local', artifactType: 'ai_summary',
251 sourceNotePath: 'notes/about-to-delete.md', sourceEventId: 'mem_s001',
252 });
253 await writer.write({ summary: 'Will be deleted.' }, sumprov, {
254 lane: 'local', containsPrivateData: false, isDelegate: false,
255 delegatedManagedAllowed: false,
256 });
257
258 // Write embedding
259 const embprov = buildConvenienceProvenance({
260 generatedBy: 'u', source: 'companion', model: 'm', modelVersion: '1',
261 lane: 'local', artifactType: 'embedding',
262 sourceNotePath: 'notes/about-to-delete.md', sourceEventId: 'mem_e001',
263 });
264 await writer.write({ vector: [0.1, 0.2], payload: {} }, embprov, {
265 lane: 'local', containsPrivateData: false, isDelegate: false,
266 delegatedManagedAllowed: false,
267 });
268
269 assert.ok(noteStore.has('notes/about-to-delete.md'));
270 assert.equal(vectorStore.length, 1);
271
272 // Delete
273 const delResult = await writer.deleteArtifacts({ notePath: 'notes/about-to-delete.md' });
274 assert.equal(delResult.ok, true);
275
276 // Frontmatter nulled
277 const fm = noteStore.get('notes/about-to-delete.md');
278 assert.equal(fm.ai_summary, null);
279 assert.equal(fm.ai_summary_provenance, null);
280
281 // Vector purged
282 assert.equal(vectorStore.filter((v) => v.path === 'notes/about-to-delete.md').length, 0);
283
284 // Maintenance stale-flag recorded for aggregate insight re-enrichment (D6.5.2)
285 assert.ok(maintenanceLog.some((m) => m.deleted_note_path === 'notes/about-to-delete.md'));
286 });
287 });
288
289 // ── E2E: runDiscoverPass end-to-end through writer ────────────────────────────
290
291 describe('E2E — runDiscoverPass end-to-end through writer', () => {
292 it('produces insight event in insightStore via writer', async () => {
293 const { insightStore, mm } = buildE2EStores();
294
295 const mockWriter = {
296 write: async (artifact, provenance, _ctx) => {
297 // Route to insightStore as the writer would
298 if (provenance.artifact_type === 'insight') {
299 insightStore.push({ ...artifact, provenance });
300 }
301 return { ok: true, terminalState: 'host_readable' };
302 },
303 deleteArtifacts: async () => ({ ok: true, stores: [] }),
304 checkReEnrichmentEligibility: () => ({ eligible: false, reason: 'current' }),
305 };
306
307 const config = {
308 vault_path: '/vault',
309 vault_id: 'e2e-vault',
310 llm: { model: 'test-m', model_version: '1.0' },
311 memory: {},
312 daemon: { llm: { max_tokens: 256 } },
313 };
314
315 const consolidations = [
316 { id: 'mem_c1', data: { topic: 'quantum', facts: ['QE is real', 'QE is weird'] } },
317 ];
318
319 const fakeLlm = async () => JSON.stringify({
320 connections: ['quantum connects to cryptography'],
321 contradictions: [],
322 open_questions: ['Is QE deterministic?'],
323 });
324
325 const result = await runDiscoverPass(config, consolidations, {
326 llmFn: fakeLlm,
327 mm,
328 writer: mockWriter,
329 });
330
331 assert.equal(result.dry_run, false);
332 assert.ok(Array.isArray(result.connections));
333 assert.equal(result.connections[0], 'quantum connects to cryptography');
334
335 // Insight must be in the store
336 assert.equal(insightStore.length, 1);
337 assert.ok(insightStore[0].provenance);
338 assert.equal(insightStore[0].provenance.artifact_type, 'insight');
339 });
340 });
File History 1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 7 hours ago