import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { addMemoryRecordFromPatch, loadAllRecords, PATCH_APPLY_CONTEXT, updateMemoryRecord } from "./store"; import { listCandidates, updateCandidateStatus } from "./inbox"; import { renderMemoryToDisk } from "./render"; import { ensureMemoryDirs } from "./paths"; import { writeVaultPromotionReport } from "./vaultPromotion"; import { createDeletionTombstone, appendDeletionTombstone, isTombstonedRecord } from "./tombstones"; import { readEvidenceRecords, redactEvidenceForMemory } from "./evidence"; import { collectPrivacyCandidateCorrelations, purgeCorrelatedPrivacyArtifacts, redactPrivacyPatchForExecution, redactPrivacyPatchForPersistence, validatePrivacyArtifactSet } from "./privacy-artifacts"; import { affectedRecordIdsFromPatchOps, postMutationModeFromOps, runPostMutationChecks } from "./post-mutation-checks"; import type { MemoryPatch, PatchOp, PatchSkip, PatchSkipReason } from "./types"; export interface ApplyPatchOptions { selectedOpIds?: string[]; now: string; } function isSelected(op: PatchOp, selected?: string[]): boolean { return selected ? selected.includes(op.op_id) : op.default_selected; } function mergeUnique(existing: string[] | undefined, incoming: string[] | undefined): string[] | undefined { const merged = [...new Set([...(existing ?? []), ...(incoming ?? [])])]; return merged.length ? merged : undefined; } function hasInvalidatedEvidence(root: string, op: PatchOp): boolean { const ids = new Set([...(op.record?.evidence ?? []), ...(op.to_record?.evidence ?? [])].map((ev) => ev.ref)); if (ids.size === 0) return false; return readEvidenceRecords(root).some((ev) => ids.has(ev.id) && (ev.redaction_status === "redacted" || ev.redaction_status === "deleted")); } type ApplyDecision = { ok: true } | { ok: false; reason: PatchSkipReason; detail: string }; function reject(reason: PatchSkipReason, detail: string): ApplyDecision { return { ok: false, reason, detail }; } function applyDecision(root: string, op: PatchOp): ApplyDecision { const records = loadAllRecords(root); const byId = new Map(records.map((record) => [record.id, record])); if (op.op === "add" && !op.record) return reject("malformed_operation", `Add operation ${op.op_id} is missing its record.`); if (op.op === "supersede" && (!op.target_id || !op.to_record)) return reject("malformed_operation", `Supersede operation ${op.op_id} is missing its target or replacement record.`); const updateOps = ["update", "update_stability", "flag_for_review", "decay"]; if (updateOps.includes(op.op) && (!op.target_id || !op.updates)) return reject("malformed_operation", `Update operation ${op.op_id} is missing its target or update fields.`); const targetOnlyOps = ["deprecate", "contest", "uncontest", "add_exception", "delete"]; if (targetOnlyOps.includes(op.op) && !op.target_id) return reject("malformed_operation", `Operation ${op.op_id} is missing its target.`); if (op.op === "add_exception" && !op.updates) return reject("malformed_operation", `Operation ${op.op_id} is missing its exception fields.`); if ((op.record || op.to_record) && hasInvalidatedEvidence(root, op)) { return reject("invalidated_evidence", `Operation ${op.op_id} references redacted or deleted evidence.`); } if (op.op === "add") { const id = op.record!.id; if (isTombstonedRecord(root, id)) return reject("tombstoned", `Record ${id} is tombstoned.`); if (byId.has(id)) return reject("duplicate_id", `Record ${id} already exists in canonical memory.`); return { ok: true }; } if (op.target_id && isTombstonedRecord(root, op.target_id) && !(op.op === "delete" && op.deletion_mode === "privacy_purge")) { return reject("tombstoned", `Target ${op.target_id} is tombstoned.`); } if ([...updateOps, ...targetOnlyOps, "supersede"].includes(op.op)) { const target = op.target_id ? byId.get(op.target_id) : undefined; if (!target) return reject("missing_target", `Target ${op.target_id ?? "(missing)"} does not exist.`); if ((target.status === "deleted" || target.status === "superseded") && !(op.op === "delete" && op.deletion_mode === "privacy_purge")) { return reject("target_terminal", `Target ${target.id} has terminal status ${target.status}.`); } } if (op.op === "supersede") { if (op.target_id === op.to_record!.id) return reject("self_supersession", `A record cannot supersede itself: ${op.target_id}.`); if (byId.has(op.to_record!.id)) return reject("replacement_id_conflict", `Replacement record ${op.to_record!.id} already exists.`); } return { ok: true }; } function markCandidateIfNew(root: string, id: string, status: "patched" | "rejected"): void { const candidate = listCandidates(root).find((item) => item.id === id); if (candidate?.status === "new") updateCandidateStatus(root, id, status); } function applyOp(root: string, patchId: string, op: PatchOp, now: string): void { if (op.op === "add") { if (!op.record) throw new Error(`Patch op ${op.op_id} missing record`); addMemoryRecordFromPatch(root, op.record); if (op.candidate_id) updateCandidateStatus(root, op.candidate_id, "patched"); return; } if (op.op === "decay" || op.op === "update" || op.op === "update_stability") { if (!op.target_id || !op.updates) throw new Error(`Patch op ${op.op_id} missing update fields`); updateMemoryRecord(root, op.target_id, (record) => ({ ...record, ...op.updates }), PATCH_APPLY_CONTEXT); return; } if (op.op === "flag_for_review") { if (!op.target_id || !op.updates) throw new Error(`Patch op ${op.op_id} missing update fields`); updateMemoryRecord(root, op.target_id, (record) => ({ ...record, ...op.updates }), PATCH_APPLY_CONTEXT); return; } if (op.op === "deprecate") { if (!op.target_id) throw new Error(`Patch op ${op.op_id} missing target_id`); updateMemoryRecord(root, op.target_id, (record) => ({ ...record, status: "deprecated", updated_at: now.slice(0, 10) }), PATCH_APPLY_CONTEXT); return; } if (op.op === "contest" || op.op === "uncontest") { if (!op.target_id) throw new Error(`Patch op ${op.op_id} missing target_id`); updateMemoryRecord(root, op.target_id, (record) => ({ ...record, status: op.op === "contest" ? "contested" : "active", evidence: op.reason ? [...record.evidence, { type: "manual", ref: patchId, note: op.reason }] : record.evidence, updated_at: now.slice(0, 10), }), PATCH_APPLY_CONTEXT); return; } if (op.op === "add_exception") { if (!op.target_id || !op.updates) throw new Error(`Patch op ${op.op_id} missing exception fields`); updateMemoryRecord(root, op.target_id, (record) => ({ ...record, applies_when: mergeUnique(record.applies_when, op.updates?.applies_when), does_not_apply_when: mergeUnique(record.does_not_apply_when, op.updates?.does_not_apply_when), known_exceptions: mergeUnique(record.known_exceptions, op.updates?.known_exceptions), updated_at: now.slice(0, 10), }), PATCH_APPLY_CONTEXT); return; } if (op.op === "delete") { if (!op.target_id) throw new Error(`Patch op ${op.op_id} missing target_id`); const mode = op.deletion_mode ?? "audit_preserving"; const reason = op.deletion_reason ?? "other"; let foundContent = ""; updateMemoryRecord(root, op.target_id, (record) => { foundContent = JSON.stringify({ statement: record.statement, evidence: record.evidence, tags: record.tags }); const tombstone = createDeletionTombstone({ resource_id: mode === "privacy_purge" ? undefined : record.resource_id, profile_id: mode === "privacy_purge" ? undefined : record.profile_id, deleted_record_id: record.id, deletion_mode: mode, deletion_reason: reason, content: foundContent, now, }); appendDeletionTombstone(root, tombstone); if (mode === "privacy_purge") { redactEvidenceForMemory(root, record.id); return { id: record.id, layer: record.layer, scope: { type: "global" as const }, statement: "[deleted]", tags: [], evidence: [{ type: "deletion", ref: tombstone.id, note: "Content removed by privacy purge." }], confidence: 0, stability: "low" as const, created_at: record.created_at, updated_at: now.slice(0, 10), review: { ...record.review, change_condition: "[privacy purged]" }, status: "deleted" as const, supersedes: [], superseded_by: [], vault_ref: null, }; } return { ...record, status: "deleted" as const, updated_at: now.slice(0, 10) }; }, PATCH_APPLY_CONTEXT); return; } if (op.op === "supersede") { if (!op.target_id || !op.to_record) throw new Error(`Patch op ${op.op_id} missing supersede fields`); const replacement = { ...op.to_record, supersedes: [...new Set([...(op.to_record.supersedes ?? []), op.target_id])], updated_at: now.slice(0, 10), }; updateMemoryRecord(root, op.target_id, (record) => ({ ...record, status: "superseded", superseded_by: [...new Set([...record.superseded_by, replacement.id])], updated_at: now.slice(0, 10), }), PATCH_APPLY_CONTEXT); addMemoryRecordFromPatch(root, replacement); if (op.candidate_id) markCandidateIfNew(root, op.candidate_id, "patched"); return; } if (op.op === "promote_to_vault_candidate") { writeVaultPromotionReport(root, patchId, op); return; } } function validatePatchId(patchId: string): void { if (!patchId || basename(patchId) !== patchId || patchId === "." || patchId === "..") { throw new Error("Invalid patch ID: path components are not allowed"); } } export function writePatchFile(root: string, patch: MemoryPatch): string { validatePatchId(patch.patch_id); const paths = ensureMemoryDirs(root); const file = join(paths.patches, `${patch.patch_id}.json`); writeFileSync(file, `${JSON.stringify(patch, null, 2)}\n`, "utf-8"); return file; } export function listPatchFiles(root: string): string[] { const paths = ensureMemoryDirs(root); return readdirSync(paths.patches) .filter((name) => name.endsWith(".json")) .map((name) => name.replace(/\.json$/, "")) .sort(); } export function readPatchFile(root: string, patchId: string): MemoryPatch { const paths = ensureMemoryDirs(root); const normalizedId = patchId.endsWith(".json") ? patchId.slice(0, -5) : patchId; validatePatchId(normalizedId); const filename = `${normalizedId}.json`; const file = join(paths.patches, filename); if (!existsSync(file)) throw new Error(`Patch not found: ${patchId}`); const parsed = JSON.parse(readFileSync(file, "utf-8")) as Omit & { skipped_ops?: Array }; return { ...parsed, skipped_ops: (parsed.skipped_ops ?? []).map((entry): PatchSkip => typeof entry === "string" ? { op_id: entry, reason: "legacy_unknown", detail: "Historical patch did not record why this operation was skipped." } : entry), }; } export function applyPatch(root: string, patch: MemoryPatch, options: ApplyPatchOptions): MemoryPatch { const privacyTargets = patch.ops .filter((op) => isSelected(op, options.selectedOpIds) && op.op === "delete" && op.deletion_mode === "privacy_purge" && op.target_id) .map((op) => op.target_id!); if (privacyTargets.length > 0) validatePrivacyArtifactSet(root); const correlatedCandidateIds = privacyTargets.length > 0 ? collectPrivacyCandidateCorrelations(root, privacyTargets) : []; const executionPatch = redactPrivacyPatchForExecution(patch, privacyTargets, correlatedCandidateIds); writePatchFile(root, redactPrivacyPatchForPersistence(patch, privacyTargets, correlatedCandidateIds)); for (const targetId of privacyTargets) purgeCorrelatedPrivacyArtifacts(root, targetId); const applied_ops: string[] = []; const skipped_ops: PatchSkip[] = []; for (const op of executionPatch.ops) { const selected = isSelected(op, options.selectedOpIds); const decision = selected ? applyDecision(root, op) : reject("not_selected", `Operation ${op.op_id} was not selected for application.`); if (decision.ok) { applyOp(root, patch.patch_id, op, options.now); applied_ops.push(op.op_id); } else { if (selected && op.candidate_id) markCandidateIfNew(root, op.candidate_id, "rejected"); skipped_ops.push({ op_id: op.op_id, ...(op.candidate_id ? { candidate_id: op.candidate_id } : {}), reason: decision.reason, detail: decision.detail, }); } } const status: MemoryPatch["status"] = applied_ops.length === 0 && skipped_ops.length > 0 ? "rejected_at_apply" : skipped_ops.length > 0 ? "partially_applied" : "applied"; const appliedPatch: MemoryPatch = { ...executionPatch, status, applied_at: options.now, applied_ops, skipped_ops, }; const persistedPatch = redactPrivacyPatchForPersistence(appliedPatch, privacyTargets, correlatedCandidateIds); writePatchFile(root, persistedPatch); const appliedOps = executionPatch.ops.filter((op) => applied_ops.includes(op.op_id)); renderMemoryToDisk(root); runPostMutationChecks({ root, patchId: patch.patch_id, ops: appliedOps, affectedRecordIds: affectedRecordIdsFromPatchOps(appliedOps), mode: postMutationModeFromOps(appliedOps), phase: "post_patch", }); // FTS/qmd sync remains a caller invariant: extension flows call updateQmd()/syncFtsIndex() // after patch application. Keeping this here avoids coupling patch application to an index backend. return persistedPatch; }