@sema-agent/core 5.31.0 → 5.33.0

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/agents/cascade.d.ts +49 -1
  3. package/dist/agents/cascade.js +2 -2
  4. package/dist/agents/verify.d.ts +70 -4
  5. package/dist/agents/verify.js +62 -16
  6. package/dist/core/checkpoint-store.d.ts +95 -0
  7. package/dist/core/checkpoint-store.js +40 -0
  8. package/dist/core/hooks.d.ts +14 -6
  9. package/dist/core/hooks.js +14 -3
  10. package/dist/core/memory-engine/file-backend.d.ts +172 -22
  11. package/dist/core/memory-engine/file-backend.js +877 -79
  12. package/dist/core/memory-engine/memory-backend-contract.js +33 -0
  13. package/dist/core/runner/assemble-result.d.ts +7 -0
  14. package/dist/core/runner/assemble-result.js +1 -1
  15. package/dist/core/runner/prepare-acquire-reconcile.d.ts +72 -0
  16. package/dist/core/runner/prepare-acquire-reconcile.js +126 -0
  17. package/dist/core/runner/prepare-config-doors.d.ts +140 -0
  18. package/dist/core/runner/prepare-config-doors.js +250 -0
  19. package/dist/core/runner/prepare-safety-scan.d.ts +53 -0
  20. package/dist/core/runner/prepare-safety-scan.js +80 -0
  21. package/dist/core/runner/prepare-task.d.ts +28 -80
  22. package/dist/core/runner/prepare-task.js +102 -586
  23. package/dist/core/runner/prepare-workspace-restore.d.ts +102 -0
  24. package/dist/core/runner/prepare-workspace-restore.js +144 -0
  25. package/dist/core/runner/runtask.js +8 -2
  26. package/dist/core/tool-policy.d.ts +25 -0
  27. package/dist/core/types.d.ts +149 -13
  28. package/dist/index.d.ts +5 -4
  29. package/dist/index.js +2 -2
  30. package/dist/orchestration/workflow-governance.d.ts +6 -4
  31. package/dist/tools/fs/bash-readonly-classifier.d.ts +9 -3
  32. package/dist/tools/fs/bash-readonly-classifier.js +4 -1
  33. package/dist/tools/fs/fs-bash.d.ts +19 -3
  34. package/dist/tools/fs/fs-bash.js +26 -1
  35. package/dist/tools/fs/index.d.ts +27 -7
  36. package/dist/tools/fs/index.js +7 -2
  37. package/dist/tools/fs/read-deny.d.ts +66 -8
  38. package/dist/tools/fs/read-deny.js +75 -39
  39. package/dist/tools/fs/read-face.d.ts +24 -2
  40. package/dist/tools/fs/read-face.js +9 -0
  41. package/dist/tools/fs/search.js +2 -0
  42. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { jaccardDistance, termSet } from "../memory-vector.js";
5
5
  import { MAX_MEMORY_BYTES } from "../memory.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
7
  import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, writeAllSync, } from "./layout.js";
8
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, writeAllSync, } from "./layout.js";
9
9
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
10
10
  export const MEMORY_INDEX_FILENAME = "MEMORY.md";
11
11
  export const DEFAULT_MAX_ENTRY_DEPTH = 3;
@@ -63,16 +63,257 @@ export function scanEntryFiles(dir, opts = {}) {
63
63
  return out;
64
64
  }
65
65
  const LEDGER_FILE = "revs.json";
66
+ const LEDGER_SCHEMA_VERSION = 2;
67
+ const LEDGER_V1_BACKUP_FILE = "revs.v1.json.bak";
68
+ const TRANSFERS_FILE = "transfers.jsonl";
66
69
  const JOURNAL_FILE = "journal.json";
67
70
  const TXN_LOCK_STALE_MS = 30_000;
68
71
  const TXN_LOCK_WAIT_MS = 15_000;
69
72
  const TXN_LOCK_STEAL_GRACE_MS = 250;
70
73
  const SHADOW_DIR = "shadow";
74
+ function rowsRev(rows, id) {
75
+ return rows[id]?.rev;
76
+ }
77
+ function rowsBinding(rows, id) {
78
+ const row = rows[id];
79
+ if (row === undefined || typeof row.scope !== "string" || typeof row.slug !== "string")
80
+ return undefined;
81
+ return { scope: row.scope, slug: row.slug };
82
+ }
83
+ function rowsEntries(rows) {
84
+ return Object.entries(rows);
85
+ }
86
+ function deleteRow(rows, id) {
87
+ delete rows[id];
88
+ }
89
+ function hasUnboundRows(rows) {
90
+ return rowsEntries(rows).some(([id]) => rowsBinding(rows, id) === undefined);
91
+ }
92
+ function unboundRowIds(rows) {
93
+ return rowsEntries(rows)
94
+ .filter(([id]) => rowsBinding(rows, id) === undefined)
95
+ .map(([id]) => id);
96
+ }
97
+ function bindingEquals(a, b) {
98
+ if (a === undefined || b === undefined)
99
+ return a === b;
100
+ return a.scope === b.scope && a.slug === b.slug;
101
+ }
102
+ function bindRowTracked(rows, id, next, transfers, channel) {
103
+ const old = rows[id];
104
+ const oldBinding = rowsBinding(rows, id);
105
+ const row = { ...(old ?? {}), rev: next.rev, scope: next.scope, slug: next.slug, at: next.at };
106
+ if (oldBinding !== undefined && !bindingEquals(oldBinding, { scope: next.scope, slug: next.slug })) {
107
+ row.prev = { scope: oldBinding.scope, slug: oldBinding.slug, at: next.at };
108
+ transfers.push({ ev: randomUUID(), channel, id, from: oldBinding, to: { scope: next.scope, slug: next.slug }, at: next.at });
109
+ }
110
+ else if (old !== undefined && oldBinding === undefined) {
111
+ transfers.push({ ev: randomUUID(), channel: "migration-bind", id, to: { scope: next.scope, slug: next.slug }, at: next.at });
112
+ }
113
+ rows[id] = row;
114
+ }
115
+ function cloneRows(rows) {
116
+ const out = {};
117
+ for (const [id, row] of rowsEntries(rows))
118
+ out[id] = { ...row, ...(row.prev !== undefined ? { prev: { ...row.prev } } : {}) };
119
+ return out;
120
+ }
121
+ function ledgerEnvelope(rows, extras) {
122
+ return { v: LEDGER_SCHEMA_VERSION, ...(extras ?? {}), rows };
123
+ }
124
+ function slugEscapes(slug) {
125
+ if (slug.length === 0 || slug.startsWith("/") || slug.includes("\\"))
126
+ return true;
127
+ return slug.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
128
+ }
129
+ function validateLedgerRowsV2(rowsRaw, path) {
130
+ if (!rowsRaw || typeof rowsRaw !== "object" || Array.isArray(rowsRaw)) {
131
+ throw new ControlPlaneCorruptError(`committed-rev ledger rows have the wrong shape: ${path}`);
132
+ }
133
+ const out = {};
134
+ for (const [id, rowRaw] of Object.entries(rowsRaw)) {
135
+ if (!rowRaw || typeof rowRaw !== "object" || Array.isArray(rowRaw)) {
136
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has the wrong shape: ${path}`);
137
+ }
138
+ const row = rowRaw;
139
+ if (typeof row.rev !== "string") {
140
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has no string rev: ${path}`);
141
+ }
142
+ const hasScope = row.scope !== undefined;
143
+ const hasSlug = row.slug !== undefined;
144
+ if (hasScope !== hasSlug) {
145
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} is half-bound (scope and slug must appear together): ${path}`);
146
+ }
147
+ if (hasScope && (typeof row.scope !== "string" || typeof row.slug !== "string")) {
148
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a non-string binding: ${path}`);
149
+ }
150
+ if (typeof row.slug === "string" && slugEscapes(row.slug)) {
151
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a path-escaping slug: ${path}`);
152
+ }
153
+ if (row.at !== undefined && (typeof row.at !== "number" || !Number.isInteger(row.at))) {
154
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a non-integer 'at': ${path}`);
155
+ }
156
+ if (row.prev !== undefined) {
157
+ const prev = row.prev;
158
+ if (!prev || typeof prev !== "object" || Array.isArray(prev) || typeof prev.scope !== "string" || typeof prev.slug !== "string" || typeof prev.at !== "number" || !Number.isInteger(prev.at)) {
159
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a malformed 'prev' trace: ${path}`);
160
+ }
161
+ if (slugEscapes(prev.slug)) {
162
+ throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a path-escaping 'prev' slug: ${path}`);
163
+ }
164
+ }
165
+ out[id] = { ...row };
166
+ }
167
+ return out;
168
+ }
169
+ function parseLedgerText(raw, path) {
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(raw);
173
+ }
174
+ catch {
175
+ throw new ControlPlaneCorruptError(`committed-rev ledger is unparseable: ${path}`);
176
+ }
177
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
178
+ throw new ControlPlaneCorruptError(`committed-rev ledger has the wrong shape: ${path}`);
179
+ }
180
+ const rec = parsed;
181
+ if ("v" in rec) {
182
+ const v = rec.v;
183
+ if (typeof v !== "number" || !Number.isInteger(v)) {
184
+ throw new ControlPlaneCorruptError(`committed-rev ledger schema version is not an integer: ${path}`);
185
+ }
186
+ if (v > LEDGER_SCHEMA_VERSION) {
187
+ throw new ControlPlaneCorruptError(`committed-rev ledger schema v${v} is newer than this engine supports (v${LEDGER_SCHEMA_VERSION}) — refusing to reinterpret it: ${path}`);
188
+ }
189
+ if (v !== LEDGER_SCHEMA_VERSION) {
190
+ throw new ControlPlaneCorruptError(`committed-rev ledger has an unknown enveloped schema v${v}: ${path}`);
191
+ }
192
+ const rows = validateLedgerRowsV2(rec.rows, path);
193
+ const envelopeExtras = {};
194
+ for (const [k, val] of Object.entries(rec)) {
195
+ if (k !== "v" && k !== "rows")
196
+ envelopeExtras[k] = val;
197
+ }
198
+ return { form: "v2", rows, envelopeExtras };
199
+ }
200
+ const rows = {};
201
+ for (const [k, v] of Object.entries(rec)) {
202
+ if (typeof v !== "string") {
203
+ throw new ControlPlaneCorruptError(`committed-rev ledger entry ${JSON.stringify(k)} is not a string: ${path}`);
204
+ }
205
+ rows[k] = { rev: v };
206
+ }
207
+ return { form: "v1", rows };
208
+ }
209
+ function transferEventInvalid(raw) {
210
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
211
+ return "event is not an object";
212
+ const e = raw;
213
+ if (typeof e.ev !== "string" || e.ev.length === 0)
214
+ return "missing event id 'ev'";
215
+ if (typeof e.at !== "number" || !Number.isInteger(e.at))
216
+ return "missing/non-integer 'at'";
217
+ const bindingInvalid = (b) => {
218
+ if (!b || typeof b !== "object" || Array.isArray(b))
219
+ return true;
220
+ const r = b;
221
+ return typeof r.scope !== "string" || typeof r.slug !== "string";
222
+ };
223
+ if (e.channel === "migration") {
224
+ if (typeof e.boundRows !== "number" || !Number.isInteger(e.boundRows))
225
+ return "migration summary missing integer 'boundRows'";
226
+ if (typeof e.unboundRows !== "number" || !Number.isInteger(e.unboundRows))
227
+ return "migration summary missing integer 'unboundRows'";
228
+ if (e.id !== undefined || e.from !== undefined || e.to !== undefined)
229
+ return "migration summary must not carry id/from/to";
230
+ return undefined;
231
+ }
232
+ if (e.channel === "adopted-move" || e.channel === "applyPatches-move" || e.channel === "migration-bind") {
233
+ if (typeof e.id !== "string" || e.id.length === 0)
234
+ return "row transfer missing 'id'";
235
+ if (bindingInvalid(e.to))
236
+ return "row transfer missing/malformed 'to'";
237
+ if (e.channel === "migration-bind") {
238
+ if (e.from !== undefined && bindingInvalid(e.from))
239
+ return "malformed 'from'";
240
+ }
241
+ else if (bindingInvalid(e.from)) {
242
+ return `'${e.channel}' requires a well-formed 'from'`;
243
+ }
244
+ return undefined;
245
+ }
246
+ return `unknown channel ${JSON.stringify(e.channel)}`;
247
+ }
248
+ function readControlFileOrAbsent(path, what) {
249
+ try {
250
+ return readFileSync(path, "utf8");
251
+ }
252
+ catch (err) {
253
+ const code = err.code;
254
+ if (code === "ENOENT")
255
+ return undefined;
256
+ throw new ControlPlaneCorruptError(`${what} could not be read (${code ?? "io error"}) at ${path} — a read failure is not absence (fail-closed); fix the filesystem fault and retry`);
257
+ }
258
+ }
259
+ function journalOpInvalid(raw, directoryRoot) {
260
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
261
+ return "op is not an object";
262
+ const op = raw;
263
+ if (typeof op.target !== "string" || op.target.length === 0)
264
+ return "missing string target";
265
+ if (op.staged !== undefined && typeof op.staged !== "string")
266
+ return "non-string staged";
267
+ switch (op.kind) {
268
+ case "write":
269
+ if (typeof op.content !== "string")
270
+ return "write op without full content";
271
+ if (!isContainedIn(directoryRoot, op.target))
272
+ return "write target escapes the store root";
273
+ return undefined;
274
+ case "delete":
275
+ if (op.content !== undefined)
276
+ return "delete op must not carry content";
277
+ if (!isContainedIn(directoryRoot, op.target))
278
+ return "delete target escapes the store root";
279
+ return undefined;
280
+ case "shadow-write":
281
+ if (typeof op.content !== "string")
282
+ return "shadow-write op without full content";
283
+ if (!isValidEntryId(op.target))
284
+ return "shadow target is not an entry id";
285
+ return undefined;
286
+ case "shadow-delete":
287
+ if (op.content !== undefined)
288
+ return "shadow-delete op must not carry content";
289
+ if (!isValidEntryId(op.target))
290
+ return "shadow target is not an entry id";
291
+ return undefined;
292
+ default:
293
+ return "unknown op kind";
294
+ }
295
+ }
296
+ function probeProjection(absPath, id) {
297
+ let text;
298
+ try {
299
+ text = readFileSync(absPath, "utf8");
300
+ }
301
+ catch (err) {
302
+ return err.code === "ENOENT" ? "absent" : "undecidable";
303
+ }
304
+ return parseEntryFile(text).id === id ? "present" : "undecidable";
305
+ }
71
306
  export class FileMemoryEngineBackend {
72
307
  directoryRoot;
73
308
  controlPlaneRoot;
74
309
  now;
75
310
  ledger;
311
+ envelopeExtras;
312
+ persistedSchemaVersion;
313
+ announcedV1Compat = false;
314
+ unboundRowsKnown = false;
315
+ adoptionNoticeKeys = new Set();
316
+ transfersAppendFault;
76
317
  inboundFindings = [];
77
318
  batchScan;
78
319
  constructor(dir, opts = {}) {
@@ -84,12 +325,28 @@ export class FileMemoryEngineBackend {
84
325
  ensureDirExists(dir);
85
326
  ensureDirExists(this.controlPlaneRoot);
86
327
  this.recoverJournal({ unlocked: true });
328
+ try {
329
+ this.loadLedger();
330
+ }
331
+ catch {
332
+ }
333
+ if (this.persistedSchemaVersion === "v1" || (this.ledger !== undefined && hasUnboundRows(this.ledger)))
334
+ this.tryOpportunisticMigrate();
87
335
  }
88
336
  checkControlPlane() {
89
337
  this.recoverJournal({ unlocked: true });
90
338
  this.ledger = undefined;
91
339
  this.loadLedger();
92
340
  registeredScopes(this.controlPlaneRoot);
341
+ if (this.persistedSchemaVersion === "v1" || hasUnboundRows(this.loadLedger()))
342
+ this.tryOpportunisticMigrate();
343
+ const rows = this.loadLedger();
344
+ if (this.persistedSchemaVersion === "v2" && hasUnboundRows(rows)) {
345
+ const ids = unboundRowIds(rows);
346
+ this.enqueueExternalItems([
347
+ `memory ledger has ${ids.length} unbound row(s) (no derivable committed projection): ${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", …" : ""} — v2 binding guarantees do not cover them until they converge (duplicate projections need an unrestricted harvest or manual removal)`,
348
+ ]);
349
+ }
93
350
  }
94
351
  drainInboundFindings() {
95
352
  const out = this.inboundFindings;
@@ -106,56 +363,422 @@ export class FileMemoryEngineBackend {
106
363
  if (this.ledger)
107
364
  return this.ledger;
108
365
  const path = join(this.controlPlaneRoot, LEDGER_FILE);
109
- let raw;
110
- try {
111
- raw = readFileSync(path, "utf8");
112
- }
113
- catch {
114
- raw = undefined;
115
- }
366
+ const raw = readControlFileOrAbsent(path, "committed-rev ledger");
116
367
  if (raw === undefined) {
117
368
  this.ledger = {};
369
+ this.persistedSchemaVersion = "v2";
370
+ this.unboundRowsKnown = false;
118
371
  return this.ledger;
119
372
  }
120
- let parsed;
373
+ const parsed = parseLedgerText(raw, path);
374
+ this.ledger = parsed.rows;
375
+ this.envelopeExtras = parsed.envelopeExtras;
376
+ this.persistedSchemaVersion = parsed.form;
377
+ this.unboundRowsKnown = hasUnboundRows(parsed.rows);
378
+ if (parsed.form === "v1" && !this.announcedV1Compat) {
379
+ this.announcedV1Compat = true;
380
+ this.enqueueExternalItems(["memory ledger v1-compat active — v2 binding guarantees not yet in force (the migration runs at the next uncontended entry)"]);
381
+ }
382
+ return this.ledger;
383
+ }
384
+ saveLedger() {
385
+ if (!this.ledger)
386
+ return;
387
+ if (this.persistedSchemaVersion !== "v2") {
388
+ throw new ControlPlaneCorruptError("committed-rev ledger write refused: the on-disk ledger is still schema v1 and the migration has not run (mutations must migrate first)");
389
+ }
390
+ atomicWriteFileSync(join(this.controlPlaneRoot, LEDGER_FILE), `${JSON.stringify(ledgerEnvelope(this.ledger, this.envelopeExtras), null, 2)}\n`);
391
+ this.unboundRowsKnown = hasUnboundRows(this.ledger);
392
+ }
393
+ persistLedgerV1(rows) {
394
+ const map = {};
395
+ for (const [id, row] of rowsEntries(rows))
396
+ map[id] = row.rev;
397
+ atomicWriteFileSync(join(this.controlPlaneRoot, LEDGER_FILE), `${JSON.stringify(map, null, 2)}\n`);
398
+ this.unboundRowsKnown = Object.keys(map).length > 0;
399
+ }
400
+ enqueueExternalItems(items) {
401
+ if (items.length === 0)
402
+ return;
121
403
  try {
122
- parsed = JSON.parse(raw);
404
+ enqueueMemoryAnnouncement(this.controlPlaneRoot, { kind: "external", at: this.now(), items });
123
405
  }
124
406
  catch {
125
- throw new ControlPlaneCorruptError(`committed-rev ledger is unparseable: ${path}`);
126
407
  }
127
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
128
- throw new ControlPlaneCorruptError(`committed-rev ledger has the wrong shape: ${path}`);
408
+ }
409
+ announceAdoptionNotice(key, item) {
410
+ if (this.adoptionNoticeKeys.has(key))
411
+ return;
412
+ this.adoptionNoticeKeys.add(key);
413
+ this.enqueueExternalItems([item]);
414
+ }
415
+ bindingAbsPath(binding) {
416
+ return join(scopeDirFor(this.directoryRoot, this.controlPlaneRoot, binding.scope), `${binding.slug}.md`);
417
+ }
418
+ bindingRelPath(binding) {
419
+ return relative(this.directoryRoot, this.bindingAbsPath(binding));
420
+ }
421
+ censusProjectionsLocked() {
422
+ let complete = true;
423
+ const byId = new Map();
424
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
425
+ const dir = this.scopeDir(scope);
426
+ const isRoot = dir === this.directoryRoot;
427
+ const exclude = isRoot ? this.excludedSubdirNames(scope) : undefined;
428
+ const walk = (d, depth) => {
429
+ let names;
430
+ try {
431
+ names = readdirSync(d);
432
+ }
433
+ catch (err) {
434
+ if (err.code === "ENOENT" && depth === 0)
435
+ return;
436
+ complete = false;
437
+ return;
438
+ }
439
+ for (const name of names.sort()) {
440
+ if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME)
441
+ continue;
442
+ const p = join(d, name);
443
+ let st;
444
+ try {
445
+ st = lstatSync(p);
446
+ }
447
+ catch {
448
+ complete = false;
449
+ continue;
450
+ }
451
+ if (st.isSymbolicLink())
452
+ continue;
453
+ if (st.isDirectory()) {
454
+ if (depth === 0 && exclude?.has(name))
455
+ continue;
456
+ if (depth + 1 <= DEFAULT_MAX_ENTRY_DEPTH)
457
+ walk(p, depth + 1);
458
+ continue;
459
+ }
460
+ if (!st.isFile() || !name.endsWith(".md"))
461
+ continue;
462
+ let text;
463
+ try {
464
+ text = readFileSync(p, "utf8");
465
+ }
466
+ catch {
467
+ complete = false;
468
+ continue;
469
+ }
470
+ const id = parseEntryFile(text).id;
471
+ if (id === undefined)
472
+ continue;
473
+ const list = byId.get(id) ?? [];
474
+ list.push({ scope, slug: relative(dir, p).replace(/\.md$/, "") });
475
+ byId.set(id, list);
476
+ }
477
+ };
478
+ walk(dir, 0);
479
+ }
480
+ return { complete, byId };
481
+ }
482
+ writeV1BackupOnce() {
483
+ const raw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
484
+ if (raw === undefined)
485
+ throw new ControlPlaneCorruptError(`committed-rev ledger vanished under the migration lock: ${join(this.controlPlaneRoot, LEDGER_FILE)}`);
486
+ const bak = join(this.controlPlaneRoot, LEDGER_V1_BACKUP_FILE);
487
+ let fd;
488
+ try {
489
+ fd = openSync(bak, "wx", 0o600);
490
+ }
491
+ catch (err) {
492
+ if (err.code === "EEXIST")
493
+ return;
494
+ throw err;
495
+ }
496
+ try {
497
+ writeAllSync(fd, raw);
498
+ fsyncSync(fd);
129
499
  }
130
- for (const [k, v] of Object.entries(parsed)) {
131
- if (typeof v !== "string")
132
- throw new ControlPlaneCorruptError(`committed-rev ledger entry ${JSON.stringify(k)} is not a string: ${path}`);
500
+ finally {
501
+ closeSync(fd);
133
502
  }
134
- this.ledger = parsed;
135
- return this.ledger;
136
503
  }
137
- saveLedger() {
138
- if (!this.ledger)
504
+ tryAcquireTxnLockSync() {
505
+ const lockDir = this.txnLockDir();
506
+ const ownerPath = join(lockDir, "owner");
507
+ const tryMkdir = () => {
508
+ try {
509
+ mkdirSync(this.controlPlaneRoot, { recursive: true });
510
+ mkdirSync(lockDir);
511
+ return true;
512
+ }
513
+ catch {
514
+ return false;
515
+ }
516
+ };
517
+ let acquired = tryMkdir();
518
+ if (!acquired) {
519
+ let mtimeMs;
520
+ try {
521
+ mtimeMs = statSync(lockDir).mtimeMs;
522
+ }
523
+ catch {
524
+ mtimeMs = undefined;
525
+ }
526
+ if (mtimeMs !== undefined && this.now() - mtimeMs > TXN_LOCK_STALE_MS) {
527
+ let tokenPresent = false;
528
+ try {
529
+ statSync(ownerPath);
530
+ tokenPresent = true;
531
+ }
532
+ catch {
533
+ }
534
+ if (tokenPresent) {
535
+ const tomb = join(this.controlPlaneRoot, `txn.lock.stale-${randomUUID()}`);
536
+ let stolen = false;
537
+ try {
538
+ renameSync(lockDir, tomb);
539
+ stolen = true;
540
+ }
541
+ catch {
542
+ }
543
+ if (stolen) {
544
+ rmSync(tomb, { recursive: true, force: true });
545
+ acquired = tryMkdir();
546
+ }
547
+ }
548
+ }
549
+ if (!acquired)
550
+ return undefined;
551
+ }
552
+ const token = randomUUID();
553
+ try {
554
+ const fd = openSync(ownerPath, "w", 0o600);
555
+ try {
556
+ writeAllSync(fd, token);
557
+ fsyncSync(fd);
558
+ }
559
+ finally {
560
+ closeSync(fd);
561
+ }
562
+ }
563
+ catch (err) {
564
+ try {
565
+ rmSync(lockDir, { recursive: true, force: true });
566
+ }
567
+ catch {
568
+ }
569
+ throw new Error(`memory txn lock owner token could not be written at ${ownerPath}: ${err instanceof Error ? err.message : String(err)}`);
570
+ }
571
+ return {
572
+ token,
573
+ release: () => {
574
+ try {
575
+ if (readFileSync(ownerPath, "utf8") !== token)
576
+ return;
577
+ }
578
+ catch {
579
+ return;
580
+ }
581
+ try {
582
+ rmSync(lockDir, { recursive: true, force: true });
583
+ }
584
+ catch {
585
+ }
586
+ },
587
+ };
588
+ }
589
+ tryOpportunisticMigrate() {
590
+ const lock = this.tryAcquireTxnLockSync();
591
+ if (lock === undefined)
592
+ return;
593
+ try {
594
+ this.recoverJournal();
595
+ this.migrateLockedIfNeeded(lock.token);
596
+ }
597
+ finally {
598
+ lock.release();
599
+ }
600
+ }
601
+ migrateLockedIfNeeded(lockToken) {
602
+ this.ledger = undefined;
603
+ const rows = this.loadLedger();
604
+ if (this.persistedSchemaVersion === "v2" && !hasUnboundRows(rows))
139
605
  return;
140
- atomicWriteFileSync(join(this.controlPlaneRoot, LEDGER_FILE), `${JSON.stringify(this.ledger, null, 2)}\n`);
606
+ const fullMigration = this.persistedSchemaVersion === "v1";
607
+ const census = this.censusProjectionsLocked();
608
+ if (!census.complete) {
609
+ this.announceAdoptionNotice("migration-incomplete", `memory ledger ${fullMigration ? "v1→v2 migration" : "unbound-row convergence"} abandoned this pass — the projection census could not read every scope (bindings derived from a partial view could be wrong); it retries at the next entry`);
610
+ return;
611
+ }
612
+ const now = this.now();
613
+ const next = {};
614
+ const bound = [];
615
+ const unbound = [];
616
+ const events = [];
617
+ for (const [id, row] of rowsEntries(rows)) {
618
+ if (!fullMigration && rowsBinding(rows, id) !== undefined) {
619
+ next[id] = { ...row };
620
+ continue;
621
+ }
622
+ const projections = census.byId.get(id) ?? [];
623
+ const sole = projections[0];
624
+ if (projections.length === 1 && sole !== undefined) {
625
+ next[id] = { ...row, scope: sole.scope, slug: sole.slug };
626
+ bound.push(id);
627
+ if (!fullMigration)
628
+ events.push({ ev: randomUUID(), channel: "migration-bind", id, to: sole, at: now });
629
+ }
630
+ else {
631
+ next[id] = { ...row };
632
+ unbound.push(`${id} (${projections.length} projection(s))`);
633
+ }
634
+ }
635
+ if (fullMigration) {
636
+ this.writeV1BackupOnce();
637
+ events.push({ ev: randomUUID(), channel: "migration", boundRows: bound.length, unboundRows: unbound.length, at: now });
638
+ }
639
+ else if (bound.length === 0) {
640
+ return;
641
+ }
642
+ this.assertTxnLockOwnership(lockToken);
643
+ this.precheckTransfersAppendable();
644
+ const journal = { txn: `${Date.now()}-migrate`, ops: [], ledger2: ledgerEnvelope(next, this.envelopeExtras), transfers: events };
645
+ atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
646
+ this.ledger = next;
647
+ this.persistedSchemaVersion = "v2";
648
+ this.saveLedger();
649
+ this.appendTransfers(events);
650
+ rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
651
+ this.enqueueExternalItems([
652
+ fullMigration
653
+ ? `memory ledger upgraded to v2 (${bound.length} row(s) bound, ${unbound.length} unbound${unbound.length > 0 ? `: ${unbound.slice(0, 8).join(", ")}${unbound.length > 8 ? ", …" : ""}` : ""})`
654
+ : `memory ledger unbound row(s) converged to bound: ${bound.slice(0, 8).join(", ")}${bound.length > 8 ? ", …" : ""}`,
655
+ ]);
656
+ }
657
+ readTransferEvs() {
658
+ const path = join(this.controlPlaneRoot, TRANSFERS_FILE);
659
+ const raw = readControlFileOrAbsent(path, "transfer evidence log");
660
+ const evs = new Set();
661
+ if (raw === undefined)
662
+ return evs;
663
+ const lines = raw.split("\n");
664
+ for (let i = 0; i < lines.length; i++) {
665
+ const line = lines[i];
666
+ if (line === undefined || line === "")
667
+ continue;
668
+ let parsed;
669
+ try {
670
+ parsed = JSON.parse(line);
671
+ }
672
+ catch {
673
+ const isTail = lines.slice(i + 1).every((l) => l === "");
674
+ if (isTail) {
675
+ try {
676
+ ensureDirExists(join(this.controlPlaneRoot, QUARANTINE_DIR));
677
+ atomicWriteFileSync(join(this.controlPlaneRoot, QUARANTINE_DIR, `transfers-torn-tail-${this.now()}.fragment`), line);
678
+ atomicWriteFileSync(path, lines.slice(0, i).join("\n") + (i > 0 ? "\n" : ""));
679
+ }
680
+ catch {
681
+ throw new ControlPlaneCorruptError(`transfer evidence log has a torn tail that could not be quarantined: ${path}`);
682
+ }
683
+ this.enqueueExternalItems([`transfer evidence log had a torn tail (crash mid-append) — the fragment is quarantined and the sound prefix stands: ${TRANSFERS_FILE}`]);
684
+ break;
685
+ }
686
+ throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is unparseable (not a torn tail — fail-closed): ${path}`);
687
+ }
688
+ const invalid = transferEventInvalid(parsed);
689
+ if (invalid !== undefined)
690
+ throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is invalid (${invalid}): ${path}`);
691
+ evs.add(parsed.ev);
692
+ }
693
+ return evs;
694
+ }
695
+ precheckTransfersAppendable() {
696
+ this.readTransferEvs();
697
+ const fd = openSync(join(this.controlPlaneRoot, TRANSFERS_FILE), "a", 0o600);
698
+ closeSync(fd);
699
+ }
700
+ appendTransfers(events) {
701
+ if (events.length === 0)
702
+ return;
703
+ this.transfersAppendFault?.();
704
+ const existing = this.readTransferEvs();
705
+ const missing = events.filter((e) => !existing.has(e.ev));
706
+ if (missing.length === 0)
707
+ return;
708
+ const fd = openSync(join(this.controlPlaneRoot, TRANSFERS_FILE), "a", 0o600);
709
+ try {
710
+ writeAllSync(fd, missing.map((e) => `${JSON.stringify(e)}\n`).join(""));
711
+ fsyncSync(fd);
712
+ }
713
+ finally {
714
+ closeSync(fd);
715
+ }
141
716
  }
142
717
  recoverJournal(opts = {}) {
143
718
  const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
144
719
  if (opts.unlocked && this.txnInFlight())
145
720
  return;
146
- const raw = readSafe(jp);
721
+ const raw = readControlFileOrAbsent(jp, "transaction journal");
147
722
  if (raw !== undefined) {
148
723
  let journal;
149
724
  try {
150
725
  const parsed = JSON.parse(raw);
151
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops) || typeof parsed.ledger !== "object") {
726
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
152
727
  throw new Error("wrong shape");
153
- }
154
728
  journal = parsed;
155
729
  }
156
730
  catch {
157
731
  throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
158
732
  }
733
+ const hasV2 = journal.ledger2 !== undefined;
734
+ const hasV1 = journal.ledger !== undefined;
735
+ if (hasV2 === hasV1) {
736
+ throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
737
+ }
738
+ let snapshotRows;
739
+ let snapshotExtras;
740
+ if (hasV2) {
741
+ const env = journal.ledger2;
742
+ if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
743
+ throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
744
+ }
745
+ snapshotRows = validateLedgerRowsV2(env.rows, jp);
746
+ snapshotExtras = {};
747
+ for (const [k, val] of Object.entries(env))
748
+ if (k !== "v" && k !== "rows")
749
+ snapshotExtras[k] = val;
750
+ }
751
+ else {
752
+ const map = journal.ledger;
753
+ if (!map || typeof map !== "object" || Array.isArray(map))
754
+ throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
755
+ snapshotRows = {};
756
+ for (const [id, rev] of Object.entries(map)) {
757
+ if (typeof rev !== "string")
758
+ throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
759
+ snapshotRows[id] = { rev };
760
+ }
761
+ }
762
+ for (const op of journal.ops) {
763
+ const invalid = journalOpInvalid(op, this.directoryRoot);
764
+ if (invalid !== undefined) {
765
+ throw new ControlPlaneCorruptError(`transaction journal op is invalid (${invalid}; kind=${JSON.stringify(op.kind)} target=${JSON.stringify(op.target)}) — the whole journal is refused and kept at ${jp} (control data is malformed: repair via the explicit control-plane rebuild path, not a replay guess)`);
766
+ }
767
+ }
768
+ if (journal.transfers !== undefined) {
769
+ if (!Array.isArray(journal.transfers))
770
+ throw new ControlPlaneCorruptError(`transaction journal transfers is not an array: ${jp}`);
771
+ for (const e of journal.transfers) {
772
+ const invalid = transferEventInvalid(e);
773
+ if (invalid !== undefined)
774
+ throw new ControlPlaneCorruptError(`transaction journal transfer event is invalid (${invalid}): ${jp}`);
775
+ if (e.channel !== "migration" && !bindingEquals(rowsBinding(snapshotRows, e.id), e.to)) {
776
+ throw new ControlPlaneCorruptError(`transaction journal transfer event for ${JSON.stringify(e.id)} disagrees with the snapshot binding: ${jp}`);
777
+ }
778
+ }
779
+ if (hasV1)
780
+ throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
781
+ }
159
782
  for (const op of journal.ops) {
160
783
  try {
161
784
  if (op.kind === "write" && op.content !== undefined) {
@@ -173,11 +796,23 @@ export class FileMemoryEngineBackend {
173
796
  rmSync(this.shadowPath(op.target), { force: true });
174
797
  }
175
798
  }
176
- catch {
799
+ catch (err) {
800
+ const code = err.code;
801
+ throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (err instanceof Error ? err.message : String(err))}) — the journal is kept at ${jp}; an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path`);
177
802
  }
178
803
  }
179
- this.ledger = { ...journal.ledger };
180
- this.saveLedger();
804
+ if (hasV2) {
805
+ this.ledger = snapshotRows;
806
+ this.envelopeExtras = snapshotExtras;
807
+ this.persistedSchemaVersion = "v2";
808
+ this.saveLedger();
809
+ this.appendTransfers(journal.transfers ?? []);
810
+ }
811
+ else {
812
+ this.ledger = snapshotRows;
813
+ this.persistedSchemaVersion = "v1";
814
+ this.persistLedgerV1(snapshotRows);
815
+ }
181
816
  rmSync(jp, { force: true });
182
817
  }
183
818
  this.sweepStagedFiles();
@@ -186,8 +821,11 @@ export class FileMemoryEngineBackend {
186
821
  try {
187
822
  return this.now() - statSync(this.txnLockDir()).mtimeMs < TXN_LOCK_STALE_MS;
188
823
  }
189
- catch {
190
- return false;
824
+ catch (err) {
825
+ const code = err.code;
826
+ if (code === "ENOENT")
827
+ return false;
828
+ throw new ControlPlaneCorruptError(`txn lock state could not be probed (${code ?? "io error"}) at ${this.txnLockDir()} — a probe failure is not "no writer" (fail-closed)`);
191
829
  }
192
830
  }
193
831
  sweepStagedFiles() {
@@ -269,13 +907,17 @@ export class FileMemoryEngineBackend {
269
907
  if (memo !== undefined)
270
908
  return memo;
271
909
  }
910
+ if (this.persistedSchemaVersion === "v1" || this.unboundRowsKnown)
911
+ this.tryOpportunisticMigrate();
272
912
  const dir = this.scopeDir(scope);
273
913
  const isRoot = dir === this.directoryRoot;
274
914
  const files = scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined });
275
915
  const entries = [];
276
- const ledger = sync ? this.loadLedger() : undefined;
916
+ const rows = sync ? this.loadLedger() : undefined;
277
917
  let ledgerChanged = false;
278
918
  const adoptedExternal = [];
919
+ const moveEvents = [];
920
+ const moveAnnouncements = [];
279
921
  const skippedUnreadable = [];
280
922
  for (const f of files) {
281
923
  let text;
@@ -290,35 +932,44 @@ export class FileMemoryEngineBackend {
290
932
  if (parsed.id === undefined)
291
933
  continue;
292
934
  let entry = entryFromFile(text, parsed.id, f.slug, scope);
293
- if (ledger) {
294
- const committed = ledger[entry.id];
935
+ if (rows) {
936
+ const committed = rowsRev(rows, entry.id);
937
+ const binding = rowsBinding(rows, entry.id);
938
+ const relPath = relative(this.directoryRoot, f.path);
939
+ if (committed !== undefined && binding !== undefined && !(binding.scope === scope && binding.slug === f.slug)) {
940
+ const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
941
+ if (probe === "present") {
942
+ this.announceAdoptionNotice(`copy|${relPath}|${entry.rev}`, `duplicate projection of committed entry ${entry.id} at ${JSON.stringify(inlineUntrusted(relPath))} — the committed projection lives at ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}; the copy is not served (a copy is not a scope credential) and stays on disk for an unrestricted harvest or manual removal to adjudicate`);
943
+ continue;
944
+ }
945
+ if (probe === "undecidable") {
946
+ this.announceAdoptionNotice(`bindprobe|${relPath}`, `committed entry ${entry.id} appears at ${JSON.stringify(inlineUntrusted(relPath))} but is bound to ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}, and the bound projection could not be probed conclusively (a read failure, or a foreign file at that address — not ENOENT) — nothing is served or adopted this pass`);
947
+ continue;
948
+ }
949
+ if (probeOnly)
950
+ return undefined;
951
+ if (lockToken !== undefined)
952
+ this.assertTxnLockOwnership(lockToken);
953
+ const adopted = this.adoptMoveCandidateLocked(rows, entry, f.path, f.slug, scope, relPath, text, committed, binding, moveEvents, moveAnnouncements, adoptedExternal);
954
+ if (adopted !== undefined) {
955
+ entries.push(adopted);
956
+ ledgerChanged = true;
957
+ }
958
+ continue;
959
+ }
295
960
  if (committed !== entry.rev) {
296
961
  if (probeOnly)
297
962
  return undefined;
298
963
  if (lockToken !== undefined)
299
964
  this.assertTxnLockOwnership(lockToken);
300
- const relPath = relative(this.directoryRoot, f.path);
301
965
  let finding = this.inboundGate(relPath, text);
302
- if (finding === undefined && committed !== undefined) {
303
- const committedShadow = this.readCommittedShadow(entry.id);
304
- const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
305
- if (committedFm?.provenance?.kind === "repo_file") {
306
- const stripsProvenance = entry.frontmatter.provenance === undefined;
307
- const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
308
- if (stripsProvenance || stripsTrust) {
309
- finding = {
310
- path: relPath,
311
- code: "invalid",
312
- reason: `inbound memory change blocked: repo_file provenance whitewash refused — the on-disk change drops ${stripsProvenance ? "provenance" : "trust"} from a repo-ingested entry (quarantined; committed content restored)`,
313
- };
314
- }
315
- }
316
- }
966
+ if (finding === undefined && committed !== undefined)
967
+ finding = this.whitewashInboundFinding(relPath, entry, "restored");
317
968
  if (finding) {
318
969
  const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
319
970
  if (shadowText === undefined) {
320
971
  if (committed !== undefined) {
321
- delete ledger[entry.id];
972
+ deleteRow(rows, entry.id);
322
973
  ledgerChanged = true;
323
974
  }
324
975
  continue;
@@ -327,8 +978,8 @@ export class FileMemoryEngineBackend {
327
978
  }
328
979
  else {
329
980
  if (committed !== undefined)
330
- adoptedExternal.push(relative(this.directoryRoot, f.path));
331
- ledger[entry.id] = entry.rev;
981
+ adoptedExternal.push(relPath);
982
+ bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug: f.slug, at: this.now() }, moveEvents, "adopted-move");
332
983
  try {
333
984
  atomicWriteFileSync(this.shadowPath(entry.id), text);
334
985
  }
@@ -350,7 +1001,25 @@ export class FileMemoryEngineBackend {
350
1001
  if (ledgerChanged) {
351
1002
  if (lockToken !== undefined)
352
1003
  this.assertTxnLockOwnership(lockToken);
353
- this.saveLedger();
1004
+ if (moveEvents.length > 0) {
1005
+ this.precheckTransfersAppendable();
1006
+ const journal = {
1007
+ txn: `${Date.now()}-${randomUUID().slice(0, 8)}`,
1008
+ ops: [],
1009
+ ledger2: ledgerEnvelope(rows, this.envelopeExtras),
1010
+ transfers: moveEvents,
1011
+ };
1012
+ atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
1013
+ this.saveLedger();
1014
+ this.appendTransfers(moveEvents);
1015
+ rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
1016
+ }
1017
+ else {
1018
+ this.saveLedger();
1019
+ }
1020
+ }
1021
+ if (moveAnnouncements.length > 0) {
1022
+ this.enqueueExternalItems(moveAnnouncements.slice(0, 12).concat(moveAnnouncements.length > 12 ? [`…and ${moveAnnouncements.length - 12} more adopted move(s)`] : []));
354
1023
  }
355
1024
  if (adoptedExternal.length > 0) {
356
1025
  try {
@@ -384,6 +1053,58 @@ export class FileMemoryEngineBackend {
384
1053
  this.batchScan.set(scope, entries);
385
1054
  return entries;
386
1055
  }
1056
+ whitewashInboundFinding(relPath, entry, containment) {
1057
+ const committedShadow = this.readCommittedShadow(entry.id);
1058
+ const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
1059
+ if (committedFm?.provenance?.kind !== "repo_file")
1060
+ return undefined;
1061
+ const stripsProvenance = entry.frontmatter.provenance === undefined;
1062
+ const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
1063
+ if (!stripsProvenance && !stripsTrust)
1064
+ return undefined;
1065
+ return {
1066
+ path: relPath,
1067
+ code: "invalid",
1068
+ reason: `inbound memory change blocked: repo_file provenance whitewash refused — the on-disk change drops ${stripsProvenance ? "provenance" : "trust"} from a repo-ingested entry ${containment === "restored" ? "(quarantined; committed content restored)" : "(quarantined)"}`,
1069
+ };
1070
+ }
1071
+ adoptMoveCandidateLocked(rows, entry, absPath, slug, scope, relPath, text, committedRev, from, moveEvents, moveAnnouncements, adoptedExternal) {
1072
+ const census = this.censusProjectionsLocked();
1073
+ if (!census.complete) {
1074
+ this.inboundFindings.push({
1075
+ path: relPath,
1076
+ code: "unreadable",
1077
+ reason: `move adoption deferred: committed entry ${entry.id} lost its committed projection, but the store-wide projection census could not read every scope — nothing is served or adopted off a partial view; the next pass retries`,
1078
+ });
1079
+ return undefined;
1080
+ }
1081
+ const projections = census.byId.get(entry.id) ?? [];
1082
+ const others = projections.filter((p) => !(p.scope === scope && p.slug === slug));
1083
+ if (others.length > 0) {
1084
+ this.announceAdoptionNotice(`ambiguous|${entry.id}|${projections
1085
+ .map((p) => `${p.scope}/${p.slug}`)
1086
+ .sort()
1087
+ .join(",")}`, `committed entry ${entry.id} lost its committed projection and now appears at ${projections.length} path(s) — no copy is a scope credential, so none is served or adopted (read order never picks an owner); an unrestricted harvest or manual removal adjudicates which is real`);
1088
+ return undefined;
1089
+ }
1090
+ const gateFinding = this.inboundGate(relPath, text) ?? (committedRev !== entry.rev ? this.whitewashInboundFinding(relPath, entry, "quarantined-only") : undefined);
1091
+ if (gateFinding !== undefined) {
1092
+ this.containInboundReject(gateFinding, absPath, text, entry.id, undefined);
1093
+ return undefined;
1094
+ }
1095
+ const now = this.now();
1096
+ bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug, at: now }, moveEvents, "adopted-move");
1097
+ moveAnnouncements.push(`out-of-session memory move adopted: ${entry.id} ${JSON.stringify(inlineUntrusted(this.bindingRelPath(from)))} → ${JSON.stringify(inlineUntrusted(relPath))} — re-read it before relying on prior knowledge of it`);
1098
+ if (committedRev !== entry.rev) {
1099
+ adoptedExternal.push(relPath);
1100
+ try {
1101
+ atomicWriteFileSync(this.shadowPath(entry.id), text);
1102
+ }
1103
+ catch {
1104
+ }
1105
+ }
1106
+ return entry;
1107
+ }
387
1108
  async readScopeAdopting(scope, timings) {
388
1109
  const probed = this.readScope(scope, true, true);
389
1110
  if (probed !== undefined)
@@ -393,6 +1114,7 @@ export class FileMemoryEngineBackend {
393
1114
  this.assertTxnLockOwnership(lock.token);
394
1115
  this.ledger = undefined;
395
1116
  this.recoverJournal();
1117
+ this.migrateLockedIfNeeded(lock.token);
396
1118
  return this.readScope(scope, true, false, lock.token);
397
1119
  }
398
1120
  catch (err) {
@@ -453,6 +1175,8 @@ export class FileMemoryEngineBackend {
453
1175
  restrictedIdlessScope;
454
1176
  restrictedIdlessBaseline;
455
1177
  readScopeCommitted(scope, audit) {
1178
+ if (this.persistedSchemaVersion === "v1" || this.unboundRowsKnown)
1179
+ this.tryOpportunisticMigrate();
456
1180
  const dir = this.scopeDir(scope);
457
1181
  const isRoot = dir === this.directoryRoot;
458
1182
  const files = scanEntryFiles(dir, {
@@ -471,7 +1195,7 @@ export class FileMemoryEngineBackend {
471
1195
  : undefined,
472
1196
  });
473
1197
  this.ledger = undefined;
474
- const ledger = this.loadLedger();
1198
+ const rows = this.loadLedger();
475
1199
  const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
476
1200
  const entries = [];
477
1201
  for (const f of files) {
@@ -496,8 +1220,38 @@ export class FileMemoryEngineBackend {
496
1220
  continue;
497
1221
  }
498
1222
  const entry = entryFromFile(text, parsed.id, f.slug, scope);
499
- const committed = ledger[entry.id];
1223
+ const committed = rowsRev(rows, entry.id);
1224
+ const binding = rowsBinding(rows, entry.id);
1225
+ const boundElsewhere = binding !== undefined && !(binding.scope === scope && binding.slug === f.slug);
500
1226
  if (committed === entry.rev) {
1227
+ if (boundElsewhere) {
1228
+ if (audit) {
1229
+ const boundRel = this.bindingRelPath(binding);
1230
+ const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
1231
+ if (probe === "present") {
1232
+ this.recordRestrictedFinding(`bindcopy|${rel}|${entry.rev}`, {
1233
+ path: rel,
1234
+ code: "restricted_divergence",
1235
+ reason: `this file carries the bytes of committed entry ${entry.id}, whose committed projection is in place at ${JSON.stringify(inlineUntrusted(boundRel))} — a filesystem copy is not a scope credential, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
1236
+ });
1237
+ }
1238
+ else if (probe === "absent") {
1239
+ this.recordRestrictedFinding(`bindmove|${rel}|${entry.rev}`, {
1240
+ path: rel,
1241
+ code: "restricted_divergence",
1242
+ reason: `committed entry ${entry.id} is bound to ${JSON.stringify(inlineUntrusted(boundRel))} but its projection appears here instead — an unattributed move, or a delete-then-plant; this adoption-restricted session serves neither reading and leaves adjudication to an unrestricted session`,
1243
+ });
1244
+ }
1245
+ else {
1246
+ this.recordRestrictedFinding(`bindprobe|${rel}`, {
1247
+ path: rel,
1248
+ code: "unreadable",
1249
+ reason: `committed entry ${entry.id} is bound elsewhere and the bound projection could not be probed conclusively (a read failure, not absence) — the binding audit for this file is incomplete; it is withheld this pass`,
1250
+ });
1251
+ }
1252
+ }
1253
+ continue;
1254
+ }
501
1255
  if (audit && this.readCommittedShadow(entry.id) === undefined) {
502
1256
  try {
503
1257
  atomicWriteFileSync(this.shadowPath(entry.id), text);
@@ -511,11 +1265,11 @@ export class FileMemoryEngineBackend {
511
1265
  const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
512
1266
  if (shadowText !== undefined) {
513
1267
  const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
514
- if (shadowEntry.rev === entry.rev) {
1268
+ if (shadowEntry.rev === entry.rev && !boundElsewhere) {
515
1269
  entries.push(entry);
516
1270
  continue;
517
1271
  }
518
- const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
1272
+ const otherProjection = binding !== undefined ? (boundElsewhere ? this.bindingRelPath(binding) : undefined) : this.idProjectionElsewhere(entry.id, f.path);
519
1273
  if (otherProjection !== undefined) {
520
1274
  if (audit) {
521
1275
  const finding = this.inboundGate(rel, text);
@@ -526,7 +1280,9 @@ export class FileMemoryEngineBackend {
526
1280
  this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
527
1281
  path: rel,
528
1282
  code: "restricted_divergence",
529
- reason: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
1283
+ reason: binding !== undefined
1284
+ ? `this file diverges from committed entry ${entry.id}, whose committed projection is bound at ${JSON.stringify(inlineUntrusted(otherProjection))} — the committed copy may only stand in at the entry's committed projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`
1285
+ : `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
530
1286
  });
531
1287
  }
532
1288
  }
@@ -595,6 +1351,7 @@ export class FileMemoryEngineBackend {
595
1351
  restrictedAdoptionView(opts) {
596
1352
  const audit = opts?.audit === true;
597
1353
  if (audit) {
1354
+ this.restrictedFindingKeys = new Set();
598
1355
  this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
599
1356
  this.restrictedIdlessBaseline = undefined;
600
1357
  }
@@ -615,7 +1372,15 @@ export class FileMemoryEngineBackend {
615
1372
  this.readScopeCommitted(scope, true);
616
1373
  const idlessBaseline = opts?.idlessWriteScope !== undefined && opts.idlessWriteScope === this.restrictedIdlessScope ? this.restrictedIdlessBaseline : undefined;
617
1374
  const idlessScope = idlessBaseline !== undefined ? opts?.idlessWriteScope : undefined;
618
- const ledger = { ...this.loadLedger() };
1375
+ if (this.persistedSchemaVersion === "v1" || hasUnboundRows(this.loadLedger()))
1376
+ this.tryOpportunisticMigrate();
1377
+ const rows = { ...this.loadLedger() };
1378
+ if (this.persistedSchemaVersion === "v2" && hasUnboundRows(rows)) {
1379
+ const unboundIds = unboundRowIds(rows);
1380
+ this.enqueueExternalItems([
1381
+ `memory ledger has ${unboundIds.length} unbound row(s) (no derivable committed projection): ${unboundIds.slice(0, 8).join(", ")}${unboundIds.length > 8 ? ", …" : ""} — v2 binding guarantees do not cover them until they converge`,
1382
+ ]);
1383
+ }
619
1384
  const present = new Map();
620
1385
  let complete = true;
621
1386
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
@@ -652,12 +1417,13 @@ export class FileMemoryEngineBackend {
652
1417
  }
653
1418
  }
654
1419
  for (const [id, paths] of present) {
655
- if (paths.length < 2 || ledger[id] === undefined)
1420
+ if (paths.length < 2 || rowsRev(rows, id) === undefined)
656
1421
  continue;
1422
+ const dupBinding = rowsBinding(rows, id);
657
1423
  this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
658
1424
  path: paths[0] ?? id,
659
1425
  code: "restricted_divergence",
660
- reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates which projection is real`,
1426
+ reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; ${dupBinding !== undefined ? `the committed projection is ${JSON.stringify(inlineUntrusted(this.bindingRelPath(dupBinding)))}, the other path(s) carry no account backing` : "this adoption-restricted session flags it"} and an unrestricted session's harvest adjudicates`,
661
1427
  });
662
1428
  }
663
1429
  if (!complete) {
@@ -668,16 +1434,30 @@ export class FileMemoryEngineBackend {
668
1434
  });
669
1435
  return;
670
1436
  }
671
- for (const [id, rev] of Object.entries(ledger)) {
672
- if (present.has(id))
673
- continue;
1437
+ for (const [id, row] of rowsEntries(rows)) {
1438
+ const binding = rowsBinding(rows, id);
1439
+ const paths = present.get(id) ?? [];
1440
+ if (binding === undefined) {
1441
+ if (paths.length > 0)
1442
+ continue;
1443
+ }
1444
+ else {
1445
+ if (paths.includes(this.bindingRelPath(binding)))
1446
+ continue;
1447
+ }
674
1448
  this.ledger = undefined;
675
- if (this.loadLedger()[id] === undefined)
1449
+ const current = this.loadLedger();
1450
+ if (rowsRev(current, id) === undefined)
676
1451
  continue;
677
- this.recordRestrictedFinding(`missing|${id}|${rev}`, {
678
- path: `id:${id}`,
1452
+ if (!bindingEquals(rowsBinding(current, id), binding))
1453
+ continue;
1454
+ const aliens = binding !== undefined ? paths.filter((p) => p !== this.bindingRelPath(binding)) : [];
1455
+ this.recordRestrictedFinding(`missing|${id}|${row.rev}`, {
1456
+ path: binding !== undefined ? this.bindingRelPath(binding) : `id:${id}`,
679
1457
  code: "restricted_divergence",
680
- reason: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
1458
+ reason: binding !== undefined
1459
+ ? `committed memory entry ${id} is missing from its committed projection ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))} with no transaction backing the removal${aliens.length > 0 ? ` while byte-carriers of its id appear at ${aliens.join(", ")} — an unattributed move, or a delete-then-plant` : ""}; this adoption-restricted session neither restores nor forgets it — an unrestricted session adjudicates`
1460
+ : `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
681
1461
  });
682
1462
  }
683
1463
  }
@@ -825,12 +1605,14 @@ export class FileMemoryEngineBackend {
825
1605
  async applyPatchesLocked(patches, lockToken) {
826
1606
  this.ledger = undefined;
827
1607
  this.recoverJournal();
828
- const ledger = { ...this.loadLedger() };
1608
+ this.migrateLockedIfNeeded(lockToken);
1609
+ const rows = cloneRows(this.loadLedger());
829
1610
  const report = { applied: [], conflicts: [] };
830
1611
  const ops = [];
1612
+ const transfers = [];
831
1613
  const plannedTargets = new Set();
832
1614
  const nonDeleteIds = new Set();
833
- const plannedDeletes = new Set();
1615
+ const plannedDeletes = new Map();
834
1616
  this.batchScan = new Map();
835
1617
  try {
836
1618
  for (const patch of patches) {
@@ -846,7 +1628,7 @@ export class FileMemoryEngineBackend {
846
1628
  }
847
1629
  nonDeleteIds.add(patch.id);
848
1630
  }
849
- this.planOne(patch, report, ops, ledger, plannedTargets, plannedDeletes);
1631
+ this.planOne(patch, report, ops, rows, plannedTargets, plannedDeletes, transfers);
850
1632
  }
851
1633
  catch (err) {
852
1634
  report.conflicts.push({ op: patch.op, id: patch.id, reason: `io error: ${err instanceof Error ? err.message : String(err)}` });
@@ -876,8 +1658,11 @@ export class FileMemoryEngineBackend {
876
1658
  }
877
1659
  op.staged = staged;
878
1660
  }
1661
+ const coherentTransfers = transfers.filter((e) => e.channel === "migration" || bindingEquals(rowsBinding(rows, e.id), e.to));
879
1662
  this.assertTxnLockOwnership(lockToken);
880
- const journal = { txn, ops, ledger };
1663
+ if (coherentTransfers.length > 0)
1664
+ this.precheckTransfersAppendable();
1665
+ const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
881
1666
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
882
1667
  for (const op of ops) {
883
1668
  if (op.kind === "write" && op.staged) {
@@ -893,16 +1678,21 @@ export class FileMemoryEngineBackend {
893
1678
  rmSync(this.shadowPath(op.target), { force: true });
894
1679
  }
895
1680
  }
896
- this.ledger = ledger;
1681
+ this.ledger = rows;
897
1682
  this.saveLedger();
1683
+ this.appendTransfers(coherentTransfers);
898
1684
  rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
899
1685
  return report;
900
1686
  }
901
- planOne(patch, report, ops, ledger, plannedTargets, plannedDeletes) {
1687
+ planOne(patch, report, ops, rows, plannedTargets, plannedDeletes, transfers) {
902
1688
  if (!isValidEntryId(patch.id)) {
903
1689
  report.conflicts.push({ op: patch.op, id: patch.id, reason: `invalid_id_shape (id ${JSON.stringify(patch.id)} fails the frontmatter id contract — the read side would drop it, leaving an applied-but-invisible entry)` });
904
1690
  return;
905
1691
  }
1692
+ if (patch.op !== "delete" && patch.entry !== undefined && slugEscapes(patch.entry.slug)) {
1693
+ report.conflicts.push({ op: patch.op, id: patch.id, reason: `invalid_slug_shape (slug ${JSON.stringify(patch.entry.slug)} is empty, absolute, or path-escaping — a projection must stay inside its scope dir)` });
1694
+ return;
1695
+ }
906
1696
  if (patch.op === "add") {
907
1697
  const entry = patch.entry;
908
1698
  if (!entry) {
@@ -913,7 +1703,7 @@ export class FileMemoryEngineBackend {
913
1703
  const existingAnywhere = locatedAnywhere !== undefined && plannedDeletes.has(patch.id) ? undefined : locatedAnywhere;
914
1704
  if (patch.guard === "absent") {
915
1705
  if (existingAnywhere) {
916
- const currentRev = ledger[patch.id] ?? existingAnywhere.entry.rev;
1706
+ const currentRev = rowsRev(rows, patch.id) ?? existingAnywhere.entry.rev;
917
1707
  if (currentRev !== computeEntryRev(entry)) {
918
1708
  report.conflicts.push({ op: "add", id: patch.id, reason: "add_guard_absent_conflict (id already exists at a different rev; add-if-absent refuses to overwrite)", currentRev });
919
1709
  return;
@@ -956,7 +1746,14 @@ export class FileMemoryEngineBackend {
956
1746
  plannedTargets.add(target);
957
1747
  ops.push({ kind: "write", target, content });
958
1748
  ops.push({ kind: "shadow-write", target: entry.id, content });
959
- ledger[entry.id] = computeEntryRev(entry);
1749
+ const now = this.now();
1750
+ bindRowTracked(rows, entry.id, { rev: computeEntryRev(entry), scope: entry.scope, slug, at: now }, transfers, "applyPatches-move");
1751
+ const priorBinding = plannedDeletes.get(entry.id);
1752
+ const justBound = rows[entry.id];
1753
+ if (priorBinding !== undefined && justBound !== undefined && !bindingEquals(priorBinding, { scope: entry.scope, slug })) {
1754
+ rows[entry.id] = { ...justBound, prev: { scope: priorBinding.scope, slug: priorBinding.slug, at: now } };
1755
+ transfers.push({ ev: randomUUID(), channel: "applyPatches-move", id: entry.id, from: priorBinding, to: { scope: entry.scope, slug }, at: now });
1756
+ }
960
1757
  report.applied.push({ op: "add", id: entry.id, slug });
961
1758
  return;
962
1759
  }
@@ -982,7 +1779,7 @@ export class FileMemoryEngineBackend {
982
1779
  }
983
1780
  }
984
1781
  }
985
- const currentRev = ledger[patch.id] ?? found.entry.rev;
1782
+ const currentRev = rowsRev(rows, patch.id) ?? found.entry.rev;
986
1783
  if (patch.baseRev !== undefined && patch.baseRev !== currentRev) {
987
1784
  report.conflicts.push({ op: patch.op, id: patch.id, reason: "rev mismatch (concurrent change)", baseRev: patch.baseRev, currentRev });
988
1785
  const shadowText = this.readCommittedShadow(patch.id);
@@ -994,8 +1791,9 @@ export class FileMemoryEngineBackend {
994
1791
  if (patch.op === "delete") {
995
1792
  ops.push({ kind: "delete", target: found.path });
996
1793
  ops.push({ kind: "shadow-delete", target: patch.id });
997
- delete ledger[patch.id];
998
- plannedDeletes.add(patch.id);
1794
+ const deletedBinding = rowsBinding(rows, patch.id);
1795
+ deleteRow(rows, patch.id);
1796
+ plannedDeletes.set(patch.id, deletedBinding);
999
1797
  report.applied.push({ op: "delete", id: patch.id, slug: found.entry.slug });
1000
1798
  return;
1001
1799
  }
@@ -1012,7 +1810,7 @@ export class FileMemoryEngineBackend {
1012
1810
  plannedTargets.add(target);
1013
1811
  ops.push({ kind: "write", target, content });
1014
1812
  ops.push({ kind: "shadow-write", target: entry.id, content });
1015
- ledger[entry.id] = computeEntryRev(entry);
1813
+ bindRowTracked(rows, entry.id, { rev: computeEntryRev(entry), scope: entry.scope, slug: entry.slug, at: this.now() }, transfers, "applyPatches-move");
1016
1814
  report.applied.push({ op: "update", id: entry.id, slug: entry.slug });
1017
1815
  }
1018
1816
  locateById(id, preferScope) {