@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 +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/org-promote.ts +15 -10
- package/src/tidy.ts +25 -11
- package/src/vault-log.ts +21 -1
- package/src/writer.ts +7 -2
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
package/src/org-promote.ts
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
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
|
-
/**
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|