@staix/vault-mem 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-10-30
4
+
5
+ - The `tidy` LINT event and the org-promotion SYNC events are bound to the vaults resolved at the start of the run; if a vault id is re-registered before the append, the event is refused with LOG_DRIFT instead of landing in the other vault. The committed notes are not rolled back.
6
+ - Tidy and org promotion bind every note write to the same vaults: a re-registration before a write is refused with VAULT_DRIFT and nothing is written. A bound log append reports any re-registration as LOG_DRIFT, before policy or path checks.
7
+ - Org promotion and `tidy --log` report `applied: true` when a write committed and a later step (lock removal, directory flush) failed; tidy then lists the log in `written`.
8
+
3
9
  ## [0.2.0] - 2026-10-30
4
10
 
5
11
  - Fine-grained PII policy: `vault.pii` can set `allow`, `warn` or `deny` per category (email, phone, the global `piiPatterns`, named per-vault patterns) and per surface (notes, log, promotion), with an allow list of exact addresses, domains and phone numbers. The `allow`/`deny` shorthand keeps its previous meaning.
package/README.md CHANGED
@@ -140,7 +140,7 @@ Every MCP log append and CLI call with a nonempty `AGENTHUB_PEER_ID`, `CLAUDECOD
140
140
 
141
141
  `appendLog` validates the registered canonical vault and write/channel policy, acquires a separate `log.lock` (bounded two-second contention wait), then makes one O_APPEND write and fsyncs the log. New log files are mode 0600 and their parent directory is flushed. Linked/nonregular files and an incomplete existing final line are refused. Cooperating appenders share the lock; a short write is truncated back only when the same file still has the expected size, and its tail still equals our exact prefix, then flushed. Error-after-write cases inspect at most one event's bytes. Failed/ambiguous writes or cleanup are explicit; the receipt reports `appended` (possibly null when unknown), `synced`, `bytesWritten` (possibly null) and `rolledBack` when a partial append was removed. Before reporting sync, the current log and vault identities are rechecked; rotation produces LOG_DRIFT and an unknown current-path append state. A synced event can coexist with a cleanup error. No existing event is rewritten. A crash can retain `log.lock`, and failed short-write recovery can retain an incomplete line; inspect the file and stopped writer before manual recovery. There is no automatic cleanup or new TTY requirement.
142
142
 
143
- Successful changed mutations default to EXTRACT, link to CONNECT, move to ROUTE, and supersede/tidy to RETHINK. Default events use project `-`, affected paths and the operation name rather than note text. Library callers can supply `WriteRequest.log` to override the event or `false` to disable it; CLI writers accept `--log JSON` for overrides. Preview/check/no-op/refused mutations never append. The append is bound to the mutation's resolved vault identity/policy, so re-registration cannot route its event to another vault. After a committed mutation, log append is independent: `ok`/`applied` describe the note operation, `log` holds its append receipt, and `logged` is true only when the event was appended and synced. A log error does not roll back a successfully saved note. CLI exits 1 for a log failure even when the note receipt says `ok: true`; inspect the receipt before retrying to avoid duplicate mutations/events. An override is validated at append time, not during a note-only dry run.
143
+ Successful changed mutations default to EXTRACT, link to CONNECT, move to ROUTE, and supersede/tidy to RETHINK. Default events use project `-`, affected paths and the operation name rather than note text. Library callers can supply `WriteRequest.log` to override the event or `false` to disable it; CLI writers accept `--log JSON` for overrides. Preview/check/no-op/refused mutations never append. The append is bound to the mutation's resolved vault identity/policy, so re-registration cannot route its event to another vault; the same binding covers the tidy LINT event and both SYNC events of org promotion, which are refused with LOG_DRIFT instead. A bound append compares the registry before any policy or path check, so a re-registration always reads as LOG_DRIFT. Tidy and org promotion also bind every note write to the vaults resolved at the start of the run: if a vault id is re-registered before a write, that write is refused with VAULT_DRIFT and nothing is written to either vault. After a committed mutation, log append is independent: `ok`/`applied` describe the note operation, `log` holds its append receipt, and `logged` is true only when the event was appended and synced. A log error does not roll back a successfully saved note. CLI exits 1 for a log failure even when the note receipt says `ok: true`; inspect the receipt before retrying to avoid duplicate mutations/events. An override is validated at append time, not during a note-only dry run.
144
144
 
145
145
  Readers never create a home, log or lock, and work with `write: none`. They skip blank/comment lines, return the last matching 1..200 records (default 50), accept an optional canonical TYPE and non-backtracking regex subset up to 256 characters (`|`, `^`, `$`, `.`, character classes, `\d`/`\s`/`\w` and their inverses, escaped literals, and atom `*`/`+`/`?`; groups, backreferences and brace quantifiers are refused), and scan a fixed descriptor-size snapshot. Streaming is capped at 32 MiB; individual historical lines over 4096 bytes and incomplete final lines are omitted with warnings, and the serialized line array stays within 128 KiB. Above 20000 data lines, the manual-archival warning is preserved. Read failure returns `dataLines: null` rather than claiming a count. Golden synthetic fixtures and concurrent/fault persistence tests cover the format and actual append state; no live vault log was read or modified for this increment.
146
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,9 +9,9 @@ import { VaultIndex } from "./vault-index.ts";
9
9
  import { applyWrite } from "./writer.ts";
10
10
  import { notePath } from "./write-operations.ts";
11
11
  import { screenContent } from "./write-validation.ts";
12
- import { appendLog } from "./vault-log.ts";
12
+ import { appendLog, logBinding } from "./vault-log.ts";
13
13
  import type { WriteIssue, WriteReceipt } from "./write-types.ts";
14
- import type { LogReceipt } from "./log-types.ts";
14
+ import type { LogBinding, LogReceipt } from "./log-types.ts";
15
15
  import { analysisInput } from "./analysis-input.ts";
16
16
  import { lintVault } from "./vault-lint.ts";
17
17
  import type { AnalysisSnapshot } from "./analysis-types.ts";
@@ -182,6 +182,10 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
182
182
  if (target.kind !== "org" || target.write === "none") return fail(issue("POLICY", "promotion target must be a writable org vault"));
183
183
  if (request.channel === "mcp" && target.write !== "mcp") return fail(issue("POLICY", "target vault write policy refuses MCP"));
184
184
  if (request.channel === "mcp" && source.write !== "mcp") return fail(issue("POLICY", "source provenance write needs write=mcp on the personal vault"));
185
+ // SYNC events must land in the vaults resolved here, even if the registry is changed before the appends.
186
+ let bindings: Record<"source" | "target", LogBinding>;
187
+ try { bindings = { source: logBinding(source, config), target: logBinding(target, config) }; }
188
+ catch { return fail(issue("V04", "source or target vault path unavailable")); }
185
189
  let path: string;
186
190
  const index = new VaultIndex(home, source, all.map(v => v.path));
187
191
  try { index.scan(); path = `notes/${index.show(request.note, 1).path}`; }
@@ -207,13 +211,13 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
207
211
  try { existing = parseFrontmatter(readSource(target, targetPath)).data; } catch { /* unreadable: treated as foreign */ }
208
212
  if (existing.promoted_from !== result.source || prior.includes(promotedTo)) return fail(issue("V08", `${target.id} already has ${targetPath}; move or delete it in the org vault first`, targetPath));
209
213
  const set = { promoted_to: prior.length ? [...prior, promotedTo] : promotedTo };
210
- const plan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
214
+ const plan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env, binding: bindings.source });
211
215
  const fingerprint = sha({ recover: plan.fingerprint, promotedTo });
212
216
  const recovery: OrgPromoteResult = { ...result, target: `${target.id}:${targetPath}`, screen: { passed: true, issues: [] }, fingerprint, recovery: "provenance-only" };
213
217
  if (!plan.ok) return { ...recovery, issues: plan.issues };
214
218
  if (request.confirm !== true) return { ...recovery, ok: true, preview: true };
215
219
  if (request.fingerprint !== fingerprint) return { ...recovery, issues: [issue("V09", "stale or missing preview fingerprint")] };
216
- const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", log: false }, { env: options.env });
220
+ const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set }, channel: request.channel ?? "cli", log: false }, { env: options.env, binding: bindings.source });
217
221
  return { ...recovery, ok: provenance.ok, applied: provenance.applied, provenance, issues: provenance.ok ? [] : provenance.issues };
218
222
  }
219
223
  const missingTopics = topics.filter(topic => !targetNotes.mocs.has(linkKey(topic)));
@@ -234,10 +238,10 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
234
238
  const refusing = screen.filter(found => found.severity === "error");
235
239
  result.screen = { passed: refusing.length === 0, issues: screen };
236
240
  if (refusing.length) return { ...result, issues: screen };
237
- const planned = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env, piiSurface: "promotion" });
241
+ const planned = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env, piiSurface: "promotion", binding: bindings.target });
238
242
  const provenanceSet = { promoted_to: prior.length ? [...prior.filter(p => p !== promotedTo), promotedTo] : promotedTo };
239
243
  const provenanceText = emitFrontmatter(parsed, provenanceSet);
240
- const provenancePlan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env });
244
+ const provenancePlan = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", dryRun: true }, { env: options.env, binding: bindings.source });
241
245
  result.fingerprint = sha({ target: planned.fingerprint, source: provenancePlan.fingerprint, text, provenanceText });
242
246
  // Org lint runs on the preview too (the note overlaid on the org snapshot), so a note that would fail lint is never written.
243
247
  const previewLint = lintPromoted(home, target, targetPath, text);
@@ -247,12 +251,13 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
247
251
  if (request.confirm !== true) return { ...result, ok: true, preview: true, issues: [...planned.issues, ...provenancePlan.issues] };
248
252
  if (request.fingerprint !== result.fingerprint) return { ...result, issues: [issue("V09", "stale or missing preview fingerprint")] };
249
253
  const event = { type: "SYNC", project: request.logProject ?? "-", source: `${source.id}:${path}`, dest: `${target.id}:${targetPath}`, note: `promoted: ${typeof parsed.data.type === "string" ? parsed.data.type : "note"}` };
250
- const note = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", log: false }, { env: options.env, piiSurface: "promotion" });
251
- if (!note.ok || !note.applied) return { ...result, note, issues: note.issues };
252
- const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", log: false }, { env: options.env });
254
+ const note = applyWrite(home, { vault: target.id, operation: { op: "create", path: targetPath, frontmatter: {}, body: text }, channel: request.channel ?? "cli", log: false }, { env: options.env, piiSurface: "promotion", binding: bindings.target });
255
+ // A create that committed and then failed a later step (cleanup) is still a written org note.
256
+ if (!note.ok || !note.applied) return { ...result, applied: note.applied === true, note, issues: note.issues };
257
+ const provenance = applyWrite(home, { vault: source.id, operation: { op: "frontmatter", path, set: provenanceSet }, channel: request.channel ?? "cli", log: false }, { env: options.env, binding: bindings.source });
253
258
  const logs = { source: null as LogReceipt | null, target: null as LogReceipt | null };
254
259
  for (const [key, vault] of [["target", target], ["source", source]] as const) {
255
- try { logs[key] = appendLog(home, { vault: vault.id, event, channel: request.channel ?? "cli" }, { env: options.env }); }
260
+ try { logs[key] = appendLog(home, { vault: vault.id, event, channel: request.channel ?? "cli" }, { env: options.env, binding: bindings[key] }); }
256
261
  catch { logs[key] = { ok: false, appended: null, synced: false, bytesWritten: null, issues: [issue("LOG_IO", "log append failed")] }; }
257
262
  }
258
263
  const lint = lintPromoted(home, target, targetPath, text);
package/src/tidy.ts CHANGED
@@ -9,7 +9,7 @@ import { extractLinks, normalizeTarget } from "./links.ts";
9
9
  import { isDenied } from "./platform.ts";
10
10
  import { loadRegistry, syncDirectory } from "./registry.ts";
11
11
  import { normalizeSource, sourceReferences, sourceWorkspaceRoot, missingSourceWarnings } from "./source.ts";
12
- import { appendLog, LOG_LOCK } from "./vault-log.ts";
12
+ import { appendLog, logBinding, LOG_LOCK } from "./vault-log.ts";
13
13
  import { appendSection, foldConnections, h2Sections } from "./write-operations.ts";
14
14
  import { SCHEMA_KEYS, SECRET_PATTERNS, TYPED_KEYS, screenContent } from "./write-validation.ts";
15
15
  import { redactPii } from "./pii.ts";
@@ -300,8 +300,11 @@ function sanitizeLog(text: string, vault: VaultSpec, config: VmemConfig): { text
300
300
  }).join("\n");
301
301
  return { text: out, lines, redactions, formats, changes };
302
302
  }
303
- /** Replace the log under its own lock, atomically (the one sanctioned in-place rewrite of the append-only log). */
304
- function rewriteLog(root: string, expected: string, next: string): void {
303
+ /**
304
+ * Replace the log under its own lock, atomically (the one sanctioned in-place rewrite of the append-only log).
305
+ * `state.committed` turns true once the rename lands, so a caller can tell a later failure (fsync, lock removal) apart.
306
+ */
307
+ function rewriteLog(root: string, expected: string, next: string, state: { committed: boolean }): void {
305
308
  const lock = join(root, LOG_LOCK);
306
309
  const fd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
307
310
  let temp: string | undefined;
@@ -311,7 +314,7 @@ function rewriteLog(root: string, expected: string, next: string): void {
311
314
  temp = join(root, `.vmem-log-${randomUUID()}.tmp`);
312
315
  const tfd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
313
316
  try { writeFileSync(tfd, next); fsyncSync(tfd); } finally { closeSync(tfd); }
314
- renameSync(temp, join(root, "log")); temp = undefined;
317
+ renameSync(temp, join(root, "log")); temp = undefined; state.committed = true;
315
318
  syncDirectory(root);
316
319
  } finally {
317
320
  if (temp) try { unlinkSync(temp); } catch { /* best effort */ }
@@ -334,6 +337,8 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
334
337
  if (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp")) return { ...result, issues: [issue("POLICY", "vault write policy refuses this channel")] };
335
338
  const root = realpathSync(vault.path);
336
339
  if (root !== vault.path || isDenied(root, config.denyPaths)) return { ...result, issues: [issue("V06", "vault path denied or changed")] };
340
+ // The LINT event must land in the vault this run read and wrote, even if the registry is changed meanwhile.
341
+ const binding = logBinding(vault, config);
337
342
  const map = request.map ?? { enums: {}, keys: {} };
338
343
  // Only flat notes/*.md are read: .obsidian/, templates/ and self/ are never touched.
339
344
  let notes: Map<string, string>;
@@ -369,8 +374,9 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
369
374
  // Validate every planned note with the writer (dry-run). A note the writer would refuse is reported and skipped
370
375
  // (left fully unchanged for manual repair) instead of blocking the rest of the migration.
371
376
  for (const change of [...planned]) {
372
- const dry = applyWrite(home, { vault: vault.id, operation: { op: "sanitize", path: change.path, text: change.after }, channel: request.channel ?? "cli", dryRun: true, log: false }, { env: options.env });
377
+ const dry = applyWrite(home, { vault: vault.id, operation: { op: "sanitize", path: change.path, text: change.after }, channel: request.channel ?? "cli", dryRun: true, log: false }, { env: options.env, binding });
373
378
  const errors = dry.issues.filter(found => found.severity === "error");
379
+ if (errors.some(found => found.code === "VAULT_DRIFT")) return { ...result, issues: errors.filter(found => found.code === "VAULT_DRIFT") };
374
380
  if (!errors.length) continue;
375
381
  planned.splice(planned.indexOf(change), 1);
376
382
  result.reported.push({ path: change.path, message: `skipped: the writer refuses the sanitized note (${[...new Set(errors.map(found => found.code))].join(", ")}); repair by hand` });
@@ -385,18 +391,26 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
385
391
  if (request.confirm !== true) return { ...result, ok: true, preview: true };
386
392
  if (request.fingerprint !== result.fingerprint) return { ...result, issues: [issue("V09", "stale or missing preview fingerprint")] };
387
393
  for (const change of planned) {
388
- const receipt = applyWrite(home, { vault: vault.id, operation: { op: "sanitize", path: change.path, text: change.after }, channel: request.channel ?? "cli", expectedShas: { [change.path]: sha256(change.before) }, log: false }, { env: options.env });
389
- if (!receipt.ok || !receipt.applied) return { ...result, issues: receipt.issues.length ? receipt.issues : [issue("IO", "note write failed", change.path)] };
394
+ const receipt = applyWrite(home, { vault: vault.id, operation: { op: "sanitize", path: change.path, text: change.after }, channel: request.channel ?? "cli", expectedShas: { [change.path]: sha256(change.before) }, log: false }, { env: options.env, binding });
395
+ // Notes written before a failure stay committed, so the result reports them as applied.
396
+ if (!receipt.ok || !receipt.applied) {
397
+ if (receipt.applied) result.written.push(change.path); // committed, then a later step of the write failed
398
+ return { ...result, applied: result.written.length > 0, issues: receipt.issues.length ? receipt.issues : [issue("IO", "note write failed", change.path)] };
399
+ }
390
400
  result.written.push(change.path);
391
401
  }
392
402
  if (logPlan) {
393
- if (isDenied(join(root, "log"), loadRegistry(home).denyPaths)) return { ...result, issues: [issue("V06", "the vault log is denied by the path policy; log not rewritten")] };
394
- try { rewriteLog(root, logPlan.before, logPlan.text); result.written.push("log"); }
395
- catch (error) { return { ...result, issues: [issue("LOG", error instanceof Error ? error.message.slice(0, 200) : "log rewrite failed")] }; }
403
+ if (isDenied(join(root, "log"), loadRegistry(home).denyPaths)) return { ...result, applied: result.written.length > 0, issues: [issue("V06", "the vault log is denied by the path policy; log not rewritten")] };
404
+ const state = { committed: false };
405
+ try { rewriteLog(root, logPlan.before, logPlan.text, state); result.written.push("log"); }
406
+ catch (error) {
407
+ if (state.committed) result.written.push("log"); // the new log is in place; a later step failed
408
+ return { ...result, applied: result.written.length > 0, issues: [issue("LOG", error instanceof Error ? error.message.slice(0, 200) : "log rewrite failed")] };
409
+ }
396
410
  }
397
411
  const counted = Object.values(result.counts).reduce((sum, n) => sum + n, 0);
398
412
  if (planned.length || logPlan) {
399
- const receipt = appendLog(home, { vault: vault.id, event: { type: "LINT", project: "-", source: "vmem:tidy", note: result.event! }, channel: request.channel ?? "cli" }, { env: options.env });
413
+ const receipt = appendLog(home, { vault: vault.id, event: { type: "LINT", project: "-", source: "vmem:tidy", note: result.event! }, channel: request.channel ?? "cli" }, { env: options.env, binding });
400
414
  if (!receipt.ok) return { ...result, applied: true, issues: receipt.issues };
401
415
  }
402
416
  return { ...result, ok: true, applied: result.written.length > 0 || counted > 0 };
package/src/vault-log.ts CHANGED
@@ -7,6 +7,7 @@ import { canonicalPath, isDenied } from "./platform.ts";
7
7
  import { formatLogEvent, LOG_TYPES } from "./log-format.ts";
8
8
  import type { LogBinding, LogContext, LogReadOptions, LogReadResult, LogReceipt, LogRequest } from "./log-types.ts";
9
9
  import type { WriteIssue } from "./write-types.ts";
10
+ import type { VaultSpec, VmemConfig } from "./config.ts";
10
11
  export const LOG_LOCK = "log.lock";
11
12
  export const MAX_LOG_READ_BYTES = 32 * 1024 * 1024;
12
13
  export const LARGE_LOG_LINES = 20000;
@@ -64,13 +65,32 @@ function context(home: string, id: string, channel: "cli" | "mcp" | undefined, w
64
65
  return { vault, config, channel, ...(write && vault.registry ? { projectIds: registryIds(vault.registry, config.denyPaths) } : {}) };
65
66
  }
66
67
  /** One O_APPEND write per event. A separate cooperative lock permits safe short-write rollback. */
68
+ /** Bind a later append to the vault spec, path policy and root identity resolved now (refused with LOG_DRIFT if they change). */
69
+ export function logBinding(vault: VaultSpec, config: VmemConfig): LogBinding {
70
+ const identity = lstatSync(vault.path);
71
+ return { vault, denyPaths: config.denyPaths ?? [], piiPatterns: config.piiPatterns ?? [], dev: Number(identity.dev), ino: Number(identity.ino) };
72
+ }
73
+ /** True when the registered vault still equals the binding: spec, path policy and root identity. Never throws. */
74
+ export function matchesBinding(config: VmemConfig, id: string, binding: LogBinding): boolean {
75
+ const vault = config.vaults.find(v => v.id === id);
76
+ if (!vault || JSON.stringify(vault) !== JSON.stringify(binding.vault) || JSON.stringify(config.denyPaths ?? []) !== JSON.stringify(binding.denyPaths) || JSON.stringify(config.piiPatterns ?? []) !== JSON.stringify(binding.piiPatterns)) return false;
77
+ try { const identity = lstatSync(vault.path); return Number(identity.dev) === binding.dev && Number(identity.ino) === binding.ino; } catch { return false; }
78
+ }
67
79
  export function appendLog(home: string, request: LogRequest, options: { env?: NodeJS.ProcessEnv; now?: Date; binding?: LogBinding } = {}): LogReceipt {
68
80
  const result: LogReceipt = { ok: false, appended: false, synced: false, bytesWritten: 0, issues: [] };
69
81
  let fd: number | undefined, lockFd: number | undefined, lockFile: string | undefined, identity: { ino: number; dev: number } | undefined;
70
82
  const error = (code: string, message: string) => result.issues.push({ code, severity: "error", message });
71
83
  try {
72
84
  if (!request || typeof request !== "object" || typeof request.vault !== "string" || Object.keys(request).some(key => !["vault", "event", "channel"].includes(key))) refuse("L09", "invalid log request");
73
- const ctx = context(home, request.vault, request.channel, true);
85
+ // A bound append compares the registry first, so any re-registration reads as drift rather than a policy or path error.
86
+ if (options.binding && !matchesBinding(loadRegistry(home), request.vault, options.binding)) refuse("LOG_DRIFT", "vault identity or policy changed after mutation; log not appended");
87
+ let ctx: ReturnType<typeof context>;
88
+ try { ctx = context(home, request.vault, request.channel, true); }
89
+ catch (caught) {
90
+ // A re-registration between the binding check and this second registry read still reads as drift.
91
+ if (options.binding && !matchesBinding(loadRegistry(home), request.vault, options.binding)) refuse("LOG_DRIFT", "vault identity or policy changed after mutation; log not appended");
92
+ throw caught;
93
+ }
74
94
  const rootIdentity = lstatSync(ctx.vault.path);
75
95
  if (options.binding) {
76
96
  const bound = options.binding;
package/src/writer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendLog } from "./vault-log.ts";
1
+ import { appendLog, matchesBinding } from "./vault-log.ts";
2
2
  import type { LogEvent } from "./log-types.ts";
3
3
  import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, randomUUID } from "node:crypto";
@@ -12,6 +12,7 @@ import { canonicalPath, isDenied } from "./platform.ts";
12
12
  import { moveSourcePath, notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
13
13
  import { validateWrite } from "./write-validation.ts";
14
14
  import type { PiiSurface } from "./config.ts";
15
+ import type { LogBinding } from "./log-types.ts";
15
16
  import type { ChangeReceipt, PlannedChange, WriteIssue, WriteReceipt, WriteRequest } from "./write-types.ts";
16
17
  export const MUTATION_LOCK = "mutation.lock";
17
18
  export const MAX_WRITE_BYTES = 256 * 1024;
@@ -112,7 +113,8 @@ function duplicateWarnings(request: WriteRequest, notes: Map<string, string>, ch
112
113
  } finally { db.close(); }
113
114
  }
114
115
  /** Single mutation entry point. Preview/dry-run perform no writes, including locks and caches. */
115
- export function applyWrite(home: string, request: WriteRequest, options: { env?: NodeJS.ProcessEnv; piiSurface?: PiiSurface } = {}): WriteReceipt {
116
+ /** `options.binding` (internal callers composing several writes) refuses with VAULT_DRIFT unless the registry still matches it. */
117
+ export function applyWrite(home: string, request: WriteRequest, options: { env?: NodeJS.ProcessEnv; piiSurface?: PiiSurface; binding?: LogBinding } = {}): WriteReceipt {
116
118
  let lockFd: number | undefined, lockFile: string | undefined, lockIdentity: { ino: number; dev: number } | undefined;
117
119
  let index: VaultIndex | undefined;
118
120
  const temps = new Set<string>(), createdDirs: string[] = [], committed: PlannedChange[] = [];
@@ -125,6 +127,7 @@ export function applyWrite(home: string, request: WriteRequest, options: { env?:
125
127
  if (request.expectedShas !== undefined && (!request.expectedShas || typeof request.expectedShas !== "object" || Array.isArray(request.expectedShas))) refuse("V09", "expectedShas must be a mapping");
126
128
  if (request.channel !== undefined && !["cli", "mcp"].includes(request.channel)) refuse("POLICY", "unknown write channel");
127
129
  const config = loadRegistry(home), vault = config.vaults.find(value => value.id === request.vault);
130
+ if (options.binding && !matchesBinding(config, request.vault, options.binding)) refuse("VAULT_DRIFT", "vault identity or policy changed since the run started; nothing written");
128
131
  if (!vault) refuse("V04", "unknown vault id");
129
132
  if (request.operation.op !== "check" && (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses this channel");
130
133
  root = realpathSync(vault.path);
@@ -155,6 +158,8 @@ export function applyWrite(home: string, request: WriteRequest, options: { env?:
155
158
  if (!issues.some(issue => issue.severity === "error")) issues.push(...duplicateWarnings(request, notes, changes));
156
159
  const input = { vault: request.vault, channel: request.channel ?? "cli", operation: request.operation, expectedShas: request.expectedShas ?? {}, log: request.log ?? null };
157
160
  const identity = lstatSync(root);
161
+ // The identity every later commit check uses must still be the bound root, not a directory swapped in meanwhile.
162
+ if (options.binding && (Number(identity.dev) !== options.binding.dev || Number(identity.ino) !== options.binding.ino)) refuse("VAULT_DRIFT", "vault root changed since the run started; nothing written");
158
163
  fingerprint = sha256(canonical({ input, vault, policy: { denyPaths: config.denyPaths ?? [], piiPatterns: config.piiPatterns ?? [], piiSurface: options.piiSurface ?? "notes" }, root, identity: [identity.dev, identity.ino], files: [...notes].map(([path, text]) => [path, sha256(text)]), changes: receipts(changes) }));
159
164
  if (twoStep && request.confirm === true && (typeof request.fingerprint !== "string" || request.fingerprint !== fingerprint)) refuse("V09", "stale or missing preview fingerprint");
160
165
  if (issues.some(issue => issue.severity === "error")) return result = { ok: false, applied: false, issues, changes: receipts(changes), fingerprint, indexUpdated: false };