companion-artifact-writer.mjs
503 lines 19.0 KB
Raw
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor ⚠ breaking 4 hours ago
1 /**
2 * Phase 6 — DerivedArtifactWriter: single write + delete path (D6.6).
3 *
4 * This is the ONLY module that may persist or delete a derived artifact in any store:
5 * - Note frontmatter (ai_summary + provenance sidecar) via writeNoteFn
6 * - Vector store (embeddings) via vectorStore.upsert / deleteByPath
7 * - Memory insight events via MemoryManager.store('insight', ...)
8 *
9 * AUTHORITY GROUP ONLY (D6.4.5, D6.6.3):
10 * This module MUST NEVER be imported by:
11 * - lib/companion-runtime-manager.mjs (runtime group)
12 * - lib/companion-spawn-adapter.mjs / companion-download-adapter.mjs (runtime adapters)
13 * - Any other module in the runtime/inference group
14 * An architecture test in the security test tier enforces this.
15 *
16 * WRITE PIPELINE (D6.6.1 — all steps must succeed, or no write occurs):
17 * 1. Provenance validation (D6.2 — validateProvenance)
18 * 2. Tier resolution (D6.1 — resolveTier)
19 * 3. Consent gate (D6.3 — enforceConsentPolicy, Phase 1 reuse)
20 * 4. Encryption routing (D6.4 — ClientEncryptor.isAvailable + encrypt)
21 * 5. Terminal-state store (D6.1.2 — dispatch to the correct physical store)
22 *
23 * DELETE PIPELINE (D6.5.4):
24 * Single call removes from ALL stores for a note/scope. Partial removal is surfaced
25 * as an error, not silently treated as complete.
26 *
27 * RE-ENRICHMENT (D6.7):
28 * checkReEnrichmentEligibility is advisory only — never forces recompute,
29 * never routes to the proposal pipeline (D6.7.4).
30 */
31
32 import { validateProvenance } from './companion-provenance-validator.mjs';
33 import { resolveTier, TERMINAL_STATES } from './companion-tier-resolver.mjs';
34 import { enforceConsentPolicy } from './model-runtime-lane.mjs';
35 import {
36 ENCRYPTOR_REASONS,
37 ENCRYPTOR_SCOPES,
38 UNAVAILABLE_CLIENT_ENCRYPTOR,
39 } from './companion-client-encryptor.mjs';
40
41 /**
42 * Fixed reason codes for writer failures.
43 * Only these codes are logged/surfaced — never the raw exception message or any secrets.
44 * @readonly
45 */
46 export const WRITER_REASONS = Object.freeze({
47 /** Provenance record failed D6.2 validation. */
48 PROVENANCE_INVALID: 'writer_provenance_invalid',
49 /** Tier resolution failed — unknown tier or privacy_max without registry. */
50 TIER_UNRESOLVABLE: 'writer_tier_unresolvable',
51 /** enforceConsentPolicy returned lane_policy_denied or cloud_consent_required. */
52 CONSENT_DENIED: 'writer_consent_denied',
53 /** privacy_max + ClientEncryptor.isAvailable === false → fail closed (D6.4.3). */
54 ENCRYPTION_UNAVAILABLE: 'writer_encryption_unavailable',
55 /** ClientEncryptor.encrypt threw or returned a malformed result. */
56 ENCRYPTION_FAILED: 'writer_encryption_failed',
57 /** Physical store operation failed. */
58 STORE_FAILED: 'writer_store_failed',
59 /** All delete stores failed. */
60 DELETE_FAILED: 'writer_delete_failed',
61 /** Some delete stores succeeded, some failed — partial removal. */
62 DELETE_PARTIAL: 'writer_delete_partial',
63 /**
64 * Cross-partition (delegated) write requested before the tenancy gate exists (D6.3.6).
65 * Only self-partition writes are enabled until the tenancy identity lands.
66 */
67 SELF_PARTITION_ONLY: 'writer_self_partition_only',
68 });
69
70 /**
71 * @typedef {{
72 * writeNoteFn: (vaultPath: string, notePath: string, opts: object) => void,
73 * vaultPath: string,
74 * vectorStore?: ({ upsert: (points: object[]) => Promise<void>, deleteByPath?: (notePath: string) => Promise<void> }) | null,
75 * mm?: import('./memory.mjs').MemoryManager | null,
76 * encryptor?: import('./companion-client-encryptor.mjs').ClientEncryptorLike,
77 * vaultRegistryAvailable?: boolean,
78 * }} WriterDeps
79 */
80
81 /**
82 * @typedef {{
83 * write: (artifact: object, provenance: object, context: WriteContext) => Promise<WriteResult>,
84 * deleteArtifacts: (target: DeleteTarget) => Promise<DeleteResult>,
85 * checkReEnrichmentEligibility: (storedProvenance: object, currentVersions: object) => EligibilityResult,
86 * }} DerivedArtifactWriter
87 */
88
89 /**
90 * @typedef {{
91 * lane: string,
92 * containsPrivateData: boolean,
93 * consentId?: string,
94 * isDelegate?: boolean,
95 * delegatedManagedAllowed?: boolean,
96 * enrichesDelegatedPartition?: boolean,
97 * delegatedEnrichmentAllowed?: boolean,
98 * }} WriteContext
99 */
100
101 /**
102 * @typedef {{ ok: true; terminalState: string } | { ok: false; reason: string; detail?: string }} WriteResult
103 */
104
105 /**
106 * @typedef {{ notePath?: string; scope?: string }} DeleteTarget
107 */
108
109 /**
110 * @typedef {{ ok: true; stores: string[] } | { ok: false; reason: string; failed: string[] }} DeleteResult
111 */
112
113 /**
114 * @typedef {{ eligible: boolean; reason: string }} EligibilityResult
115 */
116
117 /**
118 * Create a DerivedArtifactWriter capability.
119 *
120 * Must be held ONLY by the authority group. Pass it explicitly to callers that
121 * need to persist derived artifacts (enrichIndexedNotes, runDiscoverPass) — never
122 * import it from within the runtime/inference group.
123 *
124 * @param {WriterDeps} deps
125 * @returns {DerivedArtifactWriter}
126 */
127 export function createDerivedArtifactWriter(deps) {
128 const {
129 writeNoteFn,
130 vaultPath,
131 vectorStore = null,
132 mm = null,
133 encryptor = UNAVAILABLE_CLIENT_ENCRYPTOR,
134 vaultRegistryAvailable = false,
135 } = deps;
136
137 if (typeof writeNoteFn !== 'function') {
138 throw new TypeError('DerivedArtifactWriter: writeNoteFn must be a function.');
139 }
140 if (typeof vaultPath !== 'string' || !vaultPath.trim()) {
141 throw new TypeError('DerivedArtifactWriter: vaultPath must be a non-empty string.');
142 }
143
144 /**
145 * Execute the 5-step write pipeline (D6.6.1).
146 *
147 * @param {object} artifact - The derived artifact payload (e.g. { summary: '...' }).
148 * @param {object} provenance - The canonical provenance record per D6.2.1.
149 * @param {WriteContext} context - Consent/authorization context.
150 * @returns {Promise<WriteResult>}
151 */
152 async function write(artifact, provenance, context) {
153 // D6.3.6 — Cross-partition writes disabled until tenancy gate exists.
154 // enrichesDelegatedPartition=true means actor ≠ partition owner (D6.3.2).
155 if (context.enrichesDelegatedPartition === true) {
156 return { ok: false, reason: WRITER_REASONS.SELF_PARTITION_ONLY };
157 }
158
159 // ── Step 1: Provenance validation (D6.2) ─────────────────────────────────
160 const provenanceCheck = validateProvenance(provenance, artifact);
161 if (!provenanceCheck.ok) {
162 return {
163 ok: false,
164 reason: WRITER_REASONS.PROVENANCE_INVALID,
165 detail: provenanceCheck.reason,
166 };
167 }
168
169 // ── Step 2: Tier resolution (D6.1) ───────────────────────────────────────
170 const tierResult = resolveTier(
171 /** @type {string} */ (provenance.artifact_type),
172 /** @type {string} */ (provenance.privacy_tier),
173 { vaultRegistryAvailable },
174 );
175 if (!tierResult.ok) {
176 return { ok: false, reason: WRITER_REASONS.TIER_UNRESOLVABLE, detail: tierResult.reason };
177 }
178 const { terminalState } = tierResult;
179
180 // ── Step 3: Consent gate (D6.3 — Phase 1 enforceConsentPolicy reuse) ─────
181 const consentDecision = enforceConsentPolicy({
182 lane: context.lane,
183 containsPrivateData: context.containsPrivateData,
184 consentId: context.consentId,
185 isDelegate: context.isDelegate ?? false,
186 delegatedManagedAllowed: context.delegatedManagedAllowed ?? false,
187 enrichesDelegatedPartition: context.enrichesDelegatedPartition ?? false,
188 delegatedEnrichmentAllowed: context.delegatedEnrichmentAllowed ?? false,
189 });
190 if (consentDecision !== 'allow') {
191 return { ok: false, reason: WRITER_REASONS.CONSENT_DENIED, detail: consentDecision };
192 }
193
194 // ── Step 4: Encryption routing (D6.4) ────────────────────────────────────
195 let encryptedPayload = null;
196 if (terminalState === TERMINAL_STATES.CLIENT_ENCRYPTED) {
197 const scope = _scopeForArtifact(/** @type {string} */ (provenance.artifact_type));
198
199 // Require ClientEncryptor with user-held key — fail closed if absent (D6.4.2, D6.4.3)
200 if (!encryptor.isAvailable(/** @type {string} */ (provenance.privacy_tier), scope)) {
201 return { ok: false, reason: WRITER_REASONS.ENCRYPTION_UNAVAILABLE };
202 }
203
204 try {
205 const plaintext = new TextEncoder().encode(JSON.stringify({ artifact, provenance: _safeProvenanceFields(provenance) }));
206 encryptedPayload = encryptor.encrypt(plaintext, { scope });
207 } catch (err) {
208 // Surface a fixed reason code — never the raw exception (may contain key material)
209 const detail =
210 err instanceof Error && err.message === ENCRYPTOR_REASONS.UNAVAILABLE
211 ? ENCRYPTOR_REASONS.UNAVAILABLE
212 : ENCRYPTOR_REASONS.ENCRYPT_FAILED;
213 return { ok: false, reason: WRITER_REASONS.ENCRYPTION_FAILED, detail };
214 }
215 }
216
217 // ── Step 5: Terminal-state store (D6.1.2) ────────────────────────────────
218 try {
219 await _storeArtifact({
220 artifact,
221 provenance,
222 terminalState,
223 encryptedPayload,
224 writeNoteFn,
225 vaultPath,
226 vectorStore,
227 mm,
228 });
229 } catch {
230 // Never re-throw the raw error (may contain path or data details)
231 return { ok: false, reason: WRITER_REASONS.STORE_FAILED };
232 }
233
234 return { ok: true, terminalState };
235 }
236
237 /**
238 * Delete all derived artifacts for a note path from ALL stores (D6.5.4).
239 * A single deletion call removes frontmatter ai_summary, vector entry,
240 * and stale-flags aggregate insights. Partial removal is an error.
241 *
242 * @param {DeleteTarget} target
243 * @returns {Promise<DeleteResult>}
244 */
245 async function deleteArtifacts(target) {
246 const failed = [];
247 const succeeded = [];
248
249 // 1. Remove ai_summary + provenance sidecar from note frontmatter (D6.5.1)
250 if (target.notePath) {
251 try {
252 writeNoteFn(vaultPath, target.notePath, {
253 frontmatter: {
254 ai_summary: null,
255 ai_summary_provenance: null,
256 ai_summary_ciphertext: null,
257 },
258 });
259 succeeded.push('frontmatter');
260 } catch {
261 failed.push('frontmatter');
262 }
263 }
264
265 // 2. Purge vector entry keyed by note path (D6.5.1)
266 if (target.notePath && vectorStore != null && typeof vectorStore.deleteByPath === 'function') {
267 try {
268 await vectorStore.deleteByPath(target.notePath);
269 succeeded.push('vector');
270 } catch {
271 failed.push('vector');
272 }
273 }
274
275 // 3. Stale-flag aggregate insights referencing this note (D6.5.2).
276 // Aggregate insights are NOT deleted when a source note is deleted — instead,
277 // a maintenance event records the deletion so runVerifyPass marks them stale
278 // and D6.7 re-enrichment is triggered.
279 if (target.notePath && mm != null) {
280 try {
281 mm.store('maintenance', {
282 deleted_note_path: target.notePath,
283 action: 'artifact_delete_requested',
284 });
285 succeeded.push('memory_stale_flag');
286 } catch {
287 failed.push('memory_stale_flag');
288 }
289 }
290
291 if (failed.length === 0) {
292 return { ok: true, stores: succeeded };
293 }
294
295 return {
296 ok: false,
297 reason: succeeded.length === 0 ? WRITER_REASONS.DELETE_FAILED : WRITER_REASONS.DELETE_PARTIAL,
298 failed,
299 };
300 }
301
302 /**
303 * Advisory re-enrichment eligibility check (D6.7.1).
304 *
305 * Returns eligible=true when the active model/runtime version is newer than the
306 * stored artifact's recorded version. This is a FLAG only — never forces recompute,
307 * never routes the note to the proposal pipeline (D6.7.4).
308 *
309 * Fail-closed (D6.7 §fail-closed): unknown/unparseable version → eligible=true
310 * (safe: recompute rather than trust potentially stale artifact).
311 *
312 * @param {{ model_version?: string | null, runtime_version?: string | null }} storedProvenance
313 * @param {{ model_version?: string | null, runtime_version?: string | null }} currentVersions
314 * @returns {EligibilityResult}
315 */
316 function checkReEnrichmentEligibility(storedProvenance, currentVersions) {
317 if (!storedProvenance || !currentVersions) {
318 return { eligible: true, reason: 're_enrichment_unknown_stored_version' };
319 }
320
321 const storedMV = typeof storedProvenance.model_version === 'string' && storedProvenance.model_version.trim()
322 ? storedProvenance.model_version
323 : null;
324 const storedRV = typeof storedProvenance.runtime_version === 'string' && storedProvenance.runtime_version.trim()
325 ? storedProvenance.runtime_version
326 : null;
327 const currentMV = typeof currentVersions.model_version === 'string' && currentVersions.model_version.trim()
328 ? currentVersions.model_version
329 : null;
330 const currentRV = typeof currentVersions.runtime_version === 'string' && currentVersions.runtime_version.trim()
331 ? currentVersions.runtime_version
332 : null;
333
334 // Both stored versions absent → eligible (stale by default)
335 if (!storedMV && !storedRV) {
336 return { eligible: true, reason: 're_enrichment_stored_version_absent' };
337 }
338
339 // Current version unknown → eligible (safe conservative default)
340 if (!currentMV && !currentRV) {
341 return { eligible: true, reason: 're_enrichment_current_version_unknown' };
342 }
343
344 // model_version mismatch
345 if (storedMV && currentMV && currentMV !== storedMV) {
346 return { eligible: true, reason: 're_enrichment_model_version_newer' };
347 }
348
349 // runtime_version mismatch
350 if (storedRV && currentRV && currentRV !== storedRV) {
351 return { eligible: true, reason: 're_enrichment_runtime_version_newer' };
352 }
353
354 return { eligible: false, reason: 're_enrichment_version_current' };
355 }
356
357 return Object.freeze({ write, deleteArtifacts, checkReEnrichmentEligibility });
358 }
359
360 // ── Internal helpers ──────────────────────────────────────────────────────────
361
362 /**
363 * Map artifact type to the encryptor scope (D6.4.1 / ENCRYPTOR_SCOPES).
364 * ai_summary + embedding live in the vault; insight + discovery_facet in memory.
365 * @param {string} artifactType
366 * @returns {'vault'|'memory'}
367 */
368 function _scopeForArtifact(artifactType) {
369 return artifactType === 'ai_summary' || artifactType === 'embedding'
370 ? ENCRYPTOR_SCOPES.VAULT
371 : ENCRYPTOR_SCOPES.MEMORY;
372 }
373
374 /**
375 * Return only the safe, non-secret provenance fields for storage in frontmatter/sidecar.
376 * Defense-in-depth: provenance validator already rejects secrets, but we whitelist here
377 * to guarantee no novel field with a secret ever appears in a stored artifact.
378 * @param {object} provenance
379 * @returns {object}
380 */
381 function _safeProvenanceFields(provenance) {
382 const SAFE = [
383 'generated_by', 'source', 'model', 'model_version', 'runtime_version',
384 'lane', 'privacy_tier', 'source_note_path', 'source_event_id',
385 'created_at', 'artifact_type', 'schema_version',
386 ];
387 const result = /** @type {Record<string, unknown>} */ ({});
388 for (const k of SAFE) {
389 if (k in provenance) result[k] = /** @type {any} */ (provenance)[k];
390 }
391 return result;
392 }
393
394 /**
395 * Dispatch terminal-state storage to the correct physical store.
396 * Called ONLY by write() after all gates pass.
397 *
398 * @param {{
399 * artifact: object,
400 * provenance: object,
401 * terminalState: string,
402 * encryptedPayload: import('./companion-client-encryptor.mjs').EncryptResult | null,
403 * writeNoteFn: Function,
404 * vaultPath: string,
405 * vectorStore: object | null,
406 * mm: object | null,
407 * }} params
408 * @returns {Promise<void>}
409 */
410 async function _storeArtifact({ artifact, provenance, terminalState, encryptedPayload, writeNoteFn, vaultPath, vectorStore, mm }) {
411 const { artifact_type, source_note_path } = /** @type {any} */ (provenance);
412 const safeProv = _safeProvenanceFields(provenance);
413
414 if (terminalState === TERMINAL_STATES.HOST_READABLE) {
415 switch (artifact_type) {
416 case 'ai_summary': {
417 if (!source_note_path) throw new Error('ai_summary requires source_note_path');
418 writeNoteFn(vaultPath, source_note_path, {
419 frontmatter: {
420 ai_summary: artifact.summary,
421 ai_summary_provenance: safeProv,
422 },
423 });
424 break;
425 }
426 case 'embedding': {
427 if (!vectorStore) throw new Error('embedding requires vectorStore');
428 await vectorStore.upsert([{
429 path: source_note_path,
430 vector: artifact.vector,
431 payload: { provenance: safeProv, ...(artifact.payload ?? {}) },
432 }]);
433 break;
434 }
435 case 'insight':
436 case 'discovery_facet': {
437 if (!mm) throw new Error('insight/discovery_facet requires MemoryManager');
438 mm.store('insight', {
439 connections: artifact.connections ?? [],
440 contradictions: artifact.contradictions ?? [],
441 open_questions: artifact.open_questions ?? [],
442 topic_count: artifact.topic_count ?? 0,
443 provenance: safeProv,
444 });
445 break;
446 }
447 default:
448 throw new Error(`Unknown artifact_type: ${artifact_type}`);
449 }
450
451 } else if (terminalState === TERMINAL_STATES.CLIENT_ENCRYPTED) {
452 // privacy_max: store ciphertext + wrappedDekRef only — host never sees plaintext (D6.4.2 step 3)
453 if (!encryptedPayload) throw new Error('CLIENT_ENCRYPTED requires encryptedPayload');
454 const ciphertextB64 = Buffer.from(encryptedPayload.ciphertext).toString('base64');
455 const provenanceMeta = {
456 wrapped_dek_ref: encryptedPayload.wrappedDekRef,
457 alg: encryptedPayload.alg,
458 schema_version: provenance.schema_version,
459 artifact_type,
460 };
461
462 switch (artifact_type) {
463 case 'ai_summary': {
464 if (!source_note_path) throw new Error('ai_summary requires source_note_path');
465 writeNoteFn(vaultPath, source_note_path, {
466 frontmatter: {
467 ai_summary_ciphertext: ciphertextB64,
468 ai_summary_provenance: provenanceMeta,
469 },
470 });
471 break;
472 }
473 case 'embedding': {
474 if (!vectorStore) throw new Error('embedding requires vectorStore');
475 await vectorStore.upsert([{
476 path: source_note_path,
477 ciphertext: ciphertextB64,
478 wrapped_dek_ref: encryptedPayload.wrappedDekRef,
479 alg: encryptedPayload.alg,
480 provenance_schema_version: provenance.schema_version,
481 }]);
482 break;
483 }
484 case 'insight':
485 case 'discovery_facet': {
486 if (!mm) throw new Error('insight/discovery_facet requires MemoryManager');
487 mm.store('insight', {
488 ciphertext: ciphertextB64,
489 wrapped_dek_ref: encryptedPayload.wrappedDekRef,
490 alg: encryptedPayload.alg,
491 schema_version: provenance.schema_version,
492 });
493 break;
494 }
495 default:
496 throw new Error(`Unknown artifact_type: ${artifact_type}`);
497 }
498
499 }
500 // TERMINAL_STATES.LOCAL_ONLY: nothing persisted to remote stores.
501 // The caller handles local-only caching outside this writer — the writer
502 // simply returns { ok: true, terminalState: 'local_only' } as confirmation.
503 }
File History 1 commit
sha256:0d530f9ef27b8b75547d1db7701a74bc77b77aa8f3d7fa3a8672cf2af36e63bb reconcile: import GitHub-direct RBAC/OAuth/companion and ho… Human minor 4 hours ago