@sema-agent/core 7.30.1 → 7.31.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/dist/agents/cross-session-judge.d.ts +4 -12
  3. package/dist/agents/cross-session-judge.js +4 -11
  4. package/dist/agents/peer-session-drain.js +0 -5
  5. package/dist/agents/send-message-tool.js +1 -0
  6. package/dist/agents/subagent.d.ts +1 -0
  7. package/dist/agents/subagent.js +1 -0
  8. package/dist/core/ask-origin.d.ts +6 -0
  9. package/dist/core/ask-origin.js +10 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/engine-notice.d.ts +4 -0
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +4 -0
  15. package/dist/core/memory-engine/data-plane.d.ts +3 -1
  16. package/dist/core/memory-engine/data-plane.js +1 -1
  17. package/dist/core/memory-engine/delegation-settlement.d.ts +44 -3
  18. package/dist/core/memory-engine/delegation-settlement.js +38 -3
  19. package/dist/core/memory-engine/engine.d.ts +133 -27
  20. package/dist/core/memory-engine/engine.js +452 -151
  21. package/dist/core/memory-engine/entry-scan.d.ts +23 -0
  22. package/dist/core/memory-engine/entry-scan.js +55 -0
  23. package/dist/core/memory-engine/file-backend.d.ts +31 -23
  24. package/dist/core/memory-engine/file-backend.js +174 -100
  25. package/dist/core/memory-engine/layer-observations.d.ts +109 -0
  26. package/dist/core/memory-engine/layer-observations.js +148 -0
  27. package/dist/core/memory-engine/layout.d.ts +40 -4
  28. package/dist/core/memory-engine/layout.js +43 -7
  29. package/dist/core/memory-engine/types.d.ts +26 -2
  30. package/dist/core/parent-spec-seat.d.ts +4 -1
  31. package/dist/core/parent-spec-seat.js +2 -0
  32. package/dist/core/park-selfcheck.js +1 -0
  33. package/dist/core/runner/announce-once-ledger.d.ts +26 -0
  34. package/dist/core/runner/announce-once-ledger.js +37 -0
  35. package/dist/core/runner/contracts.d.ts +22 -5
  36. package/dist/core/runner/denial-limit-arms.d.ts +2 -2
  37. package/dist/core/runner/prepare-acquire-reconcile.d.ts +0 -4
  38. package/dist/core/runner/prepare-acquire-reconcile.js +1 -3
  39. package/dist/core/runner/prepare-file-history.d.ts +13 -15
  40. package/dist/core/runner/prepare-file-history.js +57 -43
  41. package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
  42. package/dist/core/runner/prepare-listings.d.ts +3 -1
  43. package/dist/core/runner/prepare-listings.js +2 -2
  44. package/dist/core/runner/prepare-memory.js +16 -5
  45. package/dist/core/runner/prepare-park-ask.js +27 -16
  46. package/dist/core/runner/prepare-prompt-inputs.d.ts +8 -1
  47. package/dist/core/runner/prepare-prompt-inputs.js +5 -3
  48. package/dist/core/runner/prepare-task.js +7 -5
  49. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -0
  50. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  51. package/dist/core/runner/run-attachment-seats.js +1 -1
  52. package/dist/core/runner/run-leg.js +61 -52
  53. package/dist/core/runner/run-settle-and-teardown.js +4 -0
  54. package/dist/core/runner/run-turn-boundary.js +6 -6
  55. package/dist/core/runner/turn-attachments.d.ts +6 -6
  56. package/dist/core/runner-deps.d.ts +3 -1
  57. package/dist/core/secret-env.d.ts +12 -1
  58. package/dist/core/secret-env.js +27 -2
  59. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  60. package/dist/core/store-contracts/checkpoint-store-contract.js +22 -0
  61. package/dist/core/task-result.d.ts +1 -1
  62. package/dist/core/task-spec.d.ts +17 -8
  63. package/dist/core/tool-spec.d.ts +1 -1
  64. package/dist/core/wiring-manifest.d.ts +10 -0
  65. package/dist/core/wiring-manifest.js +7 -1
  66. package/dist/engine/execution-env/node-execution-env.d.ts +6 -0
  67. package/dist/engine/execution-env/node-execution-env.js +15 -17
  68. package/dist/engine/harness/agent-harness.js +7 -5
  69. package/dist/engine/lsp/node-lsp-manager.d.ts +4 -0
  70. package/dist/engine/lsp/node-lsp-manager.js +10 -4
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.js +1 -1
  73. package/dist/tools/fs/shell-read-plan.d.ts +2 -2
  74. package/dist/tools/fs/shell-read-plan.js +15 -10
  75. package/dist/tools/fs/shell-read-word.d.ts +5 -4
  76. package/dist/tools/fs/shell-read-word.js +3 -2
  77. package/package.json +1 -1
  78. package/test/export-surface.snapshot.json +7 -1
  79. package/wire-changes/core@7.31.0.json +705 -0
  80. package/wire-changes/core@7.31.1.json +189 -0
@@ -0,0 +1,23 @@
1
+ /** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
2
+ * transaction staging files), and anything not `.md`. */
3
+ export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
4
+ /** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
5
+ export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
6
+ /** One on-disk entry file the scan found. */
7
+ export interface ScannedEntryFile {
8
+ /** Absolute path. */
9
+ path: string;
10
+ /** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
11
+ slug: string;
12
+ }
13
+ /**
14
+ * Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
15
+ * ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
16
+ * what counts as an entry file. A scope dir is that scope's home and nothing else — no other
17
+ * scope's directory can lie under it, so there is nothing to exclude.
18
+ */
19
+ export declare function scanEntryFiles(dir: string, opts?: {
20
+ maxDepth?: number;
21
+ /** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
22
+ onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
23
+ }): ScannedEntryFile[];
@@ -0,0 +1,55 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ export const MEMORY_INDEX_FILENAME = "MEMORY.md";
4
+ export const DEFAULT_MAX_ENTRY_DEPTH = 3;
5
+ export function scanEntryFiles(dir, opts = {}) {
6
+ const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
7
+ const out = [];
8
+ const walk = (d, depth) => {
9
+ let names;
10
+ try {
11
+ names = readdirSync(d);
12
+ }
13
+ catch {
14
+ if (d !== dir)
15
+ opts.onSkip?.(d, "unreadable");
16
+ return;
17
+ }
18
+ for (const name of names.sort()) {
19
+ if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
20
+ if (name.startsWith("."))
21
+ opts.onSkip?.(join(d, name), "dotfile");
22
+ continue;
23
+ }
24
+ const p = join(d, name);
25
+ let st;
26
+ try {
27
+ st = lstatSync(p);
28
+ }
29
+ catch {
30
+ opts.onSkip?.(p, "unreadable");
31
+ continue;
32
+ }
33
+ if (st.isSymbolicLink()) {
34
+ opts.onSkip?.(p, "symlink");
35
+ continue;
36
+ }
37
+ if (st.isDirectory()) {
38
+ if (depth + 1 <= maxDepth)
39
+ walk(p, depth + 1);
40
+ else
41
+ opts.onSkip?.(p, "depth");
42
+ continue;
43
+ }
44
+ if (!st.isFile())
45
+ continue;
46
+ if (!name.endsWith(".md")) {
47
+ opts.onSkip?.(p, "nonmd");
48
+ continue;
49
+ }
50
+ out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
51
+ }
52
+ };
53
+ walk(dir, 0);
54
+ return out;
55
+ }
@@ -1,29 +1,7 @@
1
1
  import { scopeDirName, type ChallengeEvent, type MemoryPartitionIncidentSink } from "./layout.js";
2
2
  import type { MemoryBundleImportPlan, MemoryImportReport } from "./export-bundle.js";
3
3
  import type { HarvestRejection, MemoryBackend, MemoryEntry, MemoryEntryHeader, MemoryScopeEnumeration, NotePatch, PatchReport, ScoredMemoryEntry } from "./types.js";
4
- /** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
5
- * transaction staging files), and anything not `.md`. */
6
- export declare const MEMORY_INDEX_FILENAME = "MEMORY.md";
7
- /** Default nesting depth cap under a scope dir (CC memory is flat; a small allowance for topic dirs). */
8
- export declare const DEFAULT_MAX_ENTRY_DEPTH = 3;
9
- /** One on-disk entry file the scan found. */
10
- export interface ScannedEntryFile {
11
- /** Absolute path. */
12
- path: string;
13
- /** Slug = path relative to the scope dir, without `.md` (may contain `/` for nested topics). */
14
- slug: string;
15
- }
16
- /**
17
- * Enumerate a scope dir's entry files (depth-capped, symlinks skipped, dotfiles/index/non-`.md`
18
- * ignored). Shared by the backend and the engine's harvest scan so the two can never disagree on
19
- * what counts as an entry file. A scope dir is that scope's home and nothing else — no other
20
- * scope's directory can lie under it, so there is nothing to exclude.
21
- */
22
- export declare function scanEntryFiles(dir: string, opts?: {
23
- maxDepth?: number;
24
- /** Harvest gate hook: called for every path the scan SKIPS, with why (backend scans ignore it). */
25
- onSkip?: (path: string, kind: "symlink" | "depth" | "nonmd" | "dotfile" | "unreadable") => void;
26
- }): ScannedEntryFile[];
4
+ export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles, type ScannedEntryFile } from "./entry-scan.js";
27
5
  /** design/178 v2-b §3.3(b) — the explicit erasure selector (each form resolves to an explicit id
28
6
  * list before anything is deleted; there is no whole-store selector). */
29
7
  export type ErasureSelect = {
@@ -375,6 +353,8 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
375
353
  * NEVER under {@link directoryRoot}, always on the config-root side even for an in-repo memory dir.
376
354
  * The engine picks this up (like `directoryRoot`) so both sides share ONE control plane. */
377
355
  readonly controlPlaneRoot: string;
356
+ /** {@link directoryRoot}'s physical form at construction — the root every act on a store path is judged against. */
357
+ private readonly physicalRoot;
378
358
  /** Injected clock (opus 1.258 LOW: match the engine's `this.now` determinism discipline). */
379
359
  private readonly now;
380
360
  /** The construction-time incident sink (partition adoption, layout adoption on the standalone faces). */
@@ -701,6 +681,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
701
681
  * §3.1 ⓪ (r5-①): "no lock" is ENOENT-only — a stat FAILURE folded into "nobody is committing"
702
682
  * let a transient EIO run the unlocked recovery (and its staged sweep) over a live transaction. */
703
683
  private txnInFlight;
684
+ /**
685
+ * The act gate for store paths ({@link storeTargetEscape}) over `ops`' writes and deletes — shadow
686
+ * ops name control-plane files, not store paths. Refuses with `memory.target_outside_store` at the
687
+ * first op that would land outside the store, naming the op and where it resolves. Runs over a
688
+ * whole batch before its commit marker (no journal is written; a caller that staged files removes
689
+ * them) and per op at the act itself; past the marker a refusal keeps the journal, which replays
690
+ * once the path resolves inside the store again.
691
+ */
692
+ private refuseEscapingTargets;
693
+ /** At the head of every face (both recovery legs, the lock-free faces' layout seat, the adoption face):
694
+ * the store root still resolves to the directory this backend opened. A root re-pointed since would
695
+ * carry every read, adoption, sweep and restore — not only the journal's acts — into another directory. */
696
+ private assertRootIntact;
704
697
  /** Remove orphaned `.stage-*` files (a crash BEFORE the journal rename = clean rollback: nothing
705
698
  * was committed, the staged tmps are litter). Dotfiles are invisible to entry scans regardless. */
706
699
  private sweepStagedFiles;
@@ -737,6 +730,14 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
737
730
  * the probe classifies those inline and keeps going — a store with a lingering copy never
738
731
  * contends for the writer lock on every read. */
739
732
  private readScope;
733
+ /** #1076b — the observation-table door of the adopting read: bytes a session recorded appearing
734
+ * while it held this layer read-only are never adopted. Gate-failing bytes are not taken here —
735
+ * the inbound gate's containment keeps its door. A hit is announced, the committed copy stands in
736
+ * when this path is the entry's bound projection, and the disk is left as found (the account it
737
+ * is judged against is current — the scan sees to that). The notice is deduplicated against what
738
+ * is still queued, so every session that meets the file is told once. Returns whether the file
739
+ * was settled (the caller moves on). */
740
+ private settledAsObserved;
740
741
  /**
741
742
  * design-A RB-5 ([A3]) — the read-side whitewash gate, shared by the bound-here divergence lane
742
743
  * and the move+edit lane so the two can never drift: an out-of-session disk change (git pull /
@@ -846,6 +847,13 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
846
847
  * WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
847
848
  */
848
849
  retrievalView(): MemoryBackend;
850
+ /** #1076b — the lock-free retrieval face serves no recorded divergence either (side-effect-free, so
851
+ * no notice): the committed copy stands in at the entry's bound projection, anything else is
852
+ * withheld. The table is read per call; the account only when it names one of these ids. */
853
+ private withoutObserved;
854
+ /** The committed rows read afresh from disk (no cache install), or undefined when the account
855
+ * cannot be read — a fault, never folded into "no rows". */
856
+ private committedRowsOrFault;
849
857
  /** Dedup keys for divergence findings the restricted view already recorded: one materialize reads
850
858
  * a scope several times (listHeaders, then getByIds per scope), and a checkpoint-cadence harvest
851
859
  * re-audits the same still-pending state — without the key, one untouched divergence would mint
@@ -1,4 +1,4 @@
1
- import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
1
+ import { existsSync, fstatSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync, utimesSync } from "node:fs";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { dirname, join, relative } from "node:path";
4
4
  import { jaccardDistance, termSet } from "../memory-vector.js";
@@ -6,63 +6,13 @@ import { MAX_MEMORY_BYTES } from "../memory.js";
6
6
  import { inlineUntrusted } from "../untrusted-text.js";
7
7
  import { ambiguousOriginRepresentation, committedDistilledOf, committedOriginOf, computeEntryRev, distilledEquals, entryFromFile, isValidEntryId, originEquals, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { convertV1SidecarKeys } from "./delegation-settlement.js";
9
- import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, writeAllSync, } from "./layout.js";
9
+ import { layerObservationLookup, observedDivergenceNotice } from "./layer-observations.js";
10
+ import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
11
+ import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, adoptRootScopeLayout, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, permissionRemedyOf, withPermissionRemedy, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, layoutNeedsAdoption, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, scopeRegistryShape, stageImportLatch, landingPathOf, storeTargetEscape, TARGET_OUTSIDE_STORE, writeAllSync, } from "./layout.js";
10
12
  import { screenInboundEntries } from "./data-plane.js";
11
13
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
12
14
  import { thrownText } from "../thrown-value.js";
13
- export const MEMORY_INDEX_FILENAME = "MEMORY.md";
14
- export const DEFAULT_MAX_ENTRY_DEPTH = 3;
15
- export function scanEntryFiles(dir, opts = {}) {
16
- const maxDepth = opts.maxDepth ?? DEFAULT_MAX_ENTRY_DEPTH;
17
- const out = [];
18
- const walk = (d, depth) => {
19
- let names;
20
- try {
21
- names = readdirSync(d);
22
- }
23
- catch {
24
- if (d !== dir)
25
- opts.onSkip?.(d, "unreadable");
26
- return;
27
- }
28
- for (const name of names.sort()) {
29
- if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME) {
30
- if (name.startsWith("."))
31
- opts.onSkip?.(join(d, name), "dotfile");
32
- continue;
33
- }
34
- const p = join(d, name);
35
- let st;
36
- try {
37
- st = lstatSync(p);
38
- }
39
- catch {
40
- opts.onSkip?.(p, "unreadable");
41
- continue;
42
- }
43
- if (st.isSymbolicLink()) {
44
- opts.onSkip?.(p, "symlink");
45
- continue;
46
- }
47
- if (st.isDirectory()) {
48
- if (depth + 1 <= maxDepth)
49
- walk(p, depth + 1);
50
- else
51
- opts.onSkip?.(p, "depth");
52
- continue;
53
- }
54
- if (!st.isFile())
55
- continue;
56
- if (!name.endsWith(".md")) {
57
- opts.onSkip?.(p, "nonmd");
58
- continue;
59
- }
60
- out.push({ path: p, slug: relative(dir, p).replace(/\.md$/, "") });
61
- }
62
- };
63
- walk(dir, 0);
64
- return out;
65
- }
15
+ export { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./entry-scan.js";
66
16
  export function canonicalJsonStringify(value) {
67
17
  if (Array.isArray(value))
68
18
  return `[${value.map((v) => (v === undefined || typeof v === "function" || typeof v === "symbol" ? "null" : canonicalJsonStringify(v))).join(",")}]`;
@@ -257,6 +207,45 @@ function validateLedgerRowsV2(rowsRaw, path) {
257
207
  }
258
208
  return out;
259
209
  }
210
+ function parseJournal(raw, jp) {
211
+ try {
212
+ const parsed = JSON.parse(raw);
213
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
214
+ throw new Error("wrong shape");
215
+ return parsed;
216
+ }
217
+ catch {
218
+ throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
219
+ }
220
+ }
221
+ function journalLedgerSnapshot(journal, jp) {
222
+ const hasV2 = journal.ledger2 !== undefined;
223
+ const hasV1 = journal.ledger !== undefined;
224
+ if (hasV2 === hasV1) {
225
+ throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
226
+ }
227
+ if (hasV2) {
228
+ const env = journal.ledger2;
229
+ if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
230
+ throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
231
+ }
232
+ const extras = {};
233
+ for (const [k, val] of Object.entries(env))
234
+ if (k !== "v" && k !== "rows")
235
+ extras[k] = val;
236
+ return { form: "v2", rows: validateLedgerRowsV2(env.rows, jp), extras };
237
+ }
238
+ const map = journal.ledger;
239
+ if (!map || typeof map !== "object" || Array.isArray(map))
240
+ throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
241
+ const rows = Object.create(null);
242
+ for (const [id, rev] of Object.entries(map)) {
243
+ if (typeof rev !== "string")
244
+ throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
245
+ rows[id] = { rev };
246
+ }
247
+ return { form: "v1", rows };
248
+ }
260
249
  function parseLedgerText(raw, path) {
261
250
  let parsed;
262
251
  try {
@@ -533,6 +522,7 @@ function probeProjection(absPath, id) {
533
522
  export class FileMemoryEngineBackend {
534
523
  directoryRoot;
535
524
  controlPlaneRoot;
525
+ physicalRoot;
536
526
  now;
537
527
  onIncident;
538
528
  ledger;
@@ -556,6 +546,7 @@ export class FileMemoryEngineBackend {
556
546
  adoptCanonicalKeyedControlDir(resolveMemoryEngineRoot(), dir, opts.onIncident);
557
547
  this.controlPlaneRoot = opts.controlDir ?? deriveControlPlaneDir(resolveMemoryEngineRoot(), dir);
558
548
  ensureDirExists(dir);
549
+ this.physicalRoot = landingPathOf(dir);
559
550
  ensureDirExists(this.controlPlaneRoot);
560
551
  this.recoverJournal({ unlocked: true });
561
552
  try {
@@ -588,6 +579,7 @@ export class FileMemoryEngineBackend {
588
579
  });
589
580
  }
590
581
  ensureLayout(ownerCandidate) {
582
+ this.assertRootIntact();
591
583
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
592
584
  return;
593
585
  const lock = this.tryAcquireTxnLockSync();
@@ -602,6 +594,7 @@ export class FileMemoryEngineBackend {
602
594
  }
603
595
  }
604
596
  async adoptRootScopeLayout(opts = {}) {
597
+ this.assertRootIntact();
605
598
  if (!layoutNeedsAdoption(this.directoryRoot, this.controlPlaneRoot))
606
599
  return;
607
600
  const lock = await this.acquireTxnLock();
@@ -1222,6 +1215,7 @@ export class FileMemoryEngineBackend {
1222
1215
  }
1223
1216
  }
1224
1217
  recoverJournal(fence) {
1218
+ this.assertRootIntact();
1225
1219
  if (fence.token === undefined) {
1226
1220
  if (this.txnInFlight())
1227
1221
  return;
@@ -1240,44 +1234,9 @@ export class FileMemoryEngineBackend {
1240
1234
  const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
1241
1235
  const raw = readControlFileOrAbsent(jp, "transaction journal");
1242
1236
  if (raw !== undefined) {
1243
- let journal;
1244
- try {
1245
- const parsed = JSON.parse(raw);
1246
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
1247
- throw new Error("wrong shape");
1248
- journal = parsed;
1249
- }
1250
- catch {
1251
- throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
1252
- }
1253
- const hasV2 = journal.ledger2 !== undefined;
1254
- const hasV1 = journal.ledger !== undefined;
1255
- if (hasV2 === hasV1) {
1256
- throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
1257
- }
1258
- let snapshotRows;
1259
- let snapshotExtras;
1260
- if (hasV2) {
1261
- const env = journal.ledger2;
1262
- if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
1263
- throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
1264
- }
1265
- snapshotRows = validateLedgerRowsV2(env.rows, jp);
1266
- snapshotExtras = {};
1267
- for (const [k, val] of Object.entries(env))
1268
- if (k !== "v" && k !== "rows")
1269
- snapshotExtras[k] = val;
1270
- }
1271
- else {
1272
- const map = journal.ledger;
1273
- if (!map || typeof map !== "object" || Array.isArray(map))
1274
- throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
1275
- snapshotRows = Object.create(null);
1276
- for (const [id, rev] of Object.entries(map)) {
1277
- if (typeof rev !== "string")
1278
- throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
1279
- snapshotRows[id] = { rev };
1280
- }
1237
+ const journal = parseJournal(raw, jp);
1238
+ const { rows: snapshotRows, extras: snapshotExtras, form: snapshotForm } = journalLedgerSnapshot(journal, jp);
1239
+ if (snapshotForm === "v1") {
1281
1240
  try {
1282
1241
  const diskRaw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
1283
1242
  const diskParsed = diskRaw !== undefined ? JSON.parse(diskRaw) : undefined;
@@ -1314,7 +1273,7 @@ export class FileMemoryEngineBackend {
1314
1273
  }
1315
1274
  journalEvs.set(e.ev, canonical);
1316
1275
  }
1317
- if (hasV1)
1276
+ if (snapshotForm === "v1")
1318
1277
  throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
1319
1278
  if (journalEvs.size > 0) {
1320
1279
  for (const row of this.readTransferChain(fenceToken)) {
@@ -1445,7 +1404,10 @@ export class FileMemoryEngineBackend {
1445
1404
  }
1446
1405
  }
1447
1406
  this.assertTxnLockOwnership(fenceToken);
1407
+ const replayAt = `the journal replay (journal kept at ${jp})`;
1408
+ this.refuseEscapingTargets(journal.ops, replayAt);
1448
1409
  for (const op of journal.ops) {
1410
+ this.refuseEscapingTargets([op], replayAt);
1449
1411
  try {
1450
1412
  if (op.kind === "write" && op.content !== undefined) {
1451
1413
  atomicWriteFileSync(op.target, op.content);
@@ -1464,12 +1426,12 @@ export class FileMemoryEngineBackend {
1464
1426
  }
1465
1427
  catch (err) {
1466
1428
  const code = err.code;
1467
- throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(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`);
1429
+ throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (thrownText(err))}) — the journal is kept at ${jp}; ${permissionRemedyOf(err, this.directoryRoot) ?? "an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path"}`);
1468
1430
  }
1469
1431
  }
1470
1432
  this.recoveryCommitProbe?.();
1471
1433
  this.assertTxnLockOwnership(fenceToken);
1472
- if (hasV2) {
1434
+ if (snapshotForm === "v2") {
1473
1435
  this.ledger = snapshotRows;
1474
1436
  this.envelopeExtras = snapshotExtras;
1475
1437
  this.persistedSchemaVersion = "v2";
@@ -1503,6 +1465,24 @@ export class FileMemoryEngineBackend {
1503
1465
  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)`);
1504
1466
  }
1505
1467
  }
1468
+ refuseEscapingTargets(ops, at) {
1469
+ for (const op of ops) {
1470
+ if (op.kind !== "write" && op.kind !== "delete")
1471
+ continue;
1472
+ for (const path of op.staged === undefined ? [op.target] : [op.target, op.staged]) {
1473
+ const escape = storeTargetEscape(this.physicalRoot, path);
1474
+ if (escape === undefined)
1475
+ continue;
1476
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: ${at} refused a ${op.kind} of ${op.target}${path === op.target ? "" : ` (its staged file ${path})`} — ${escape}; not performed`, TARGET_OUTSIDE_STORE);
1477
+ }
1478
+ }
1479
+ }
1480
+ assertRootIntact() {
1481
+ const now = landingPathOf(this.directoryRoot);
1482
+ if (now === this.physicalRoot)
1483
+ return;
1484
+ throw erasureCodedError(`${TARGET_OUTSIDE_STORE}: the store root ${this.directoryRoot} now resolves to ${now}, not ${this.physicalRoot} where this backend opened it — refused; open a new backend on the directory you mean`, TARGET_OUTSIDE_STORE);
1485
+ }
1506
1486
  sweepStagedFiles() {
1507
1487
  const walk = (d, depth) => {
1508
1488
  let names;
@@ -1589,6 +1569,9 @@ export class FileMemoryEngineBackend {
1589
1569
  const moveEvents = [];
1590
1570
  const moveAnnouncements = [];
1591
1571
  const skippedUnreadable = [];
1572
+ let table;
1573
+ const observed = () => (table ??= layerObservationLookup(this.controlPlaneRoot));
1574
+ let freshAccount;
1592
1575
  for (const f of files) {
1593
1576
  let text;
1594
1577
  try {
@@ -1608,6 +1591,12 @@ export class FileMemoryEngineBackend {
1608
1591
  const committed = rowsRev(rows, entry.id);
1609
1592
  const binding = rowsBinding(rows, entry.id);
1610
1593
  const relPath = relative(this.directoryRoot, f.path);
1594
+ if (probeOnly === true && observed().covers(entry.id)) {
1595
+ const fresh = (freshAccount ??= this.committedRowsOrFault() ?? null);
1596
+ const freshBinding = fresh !== null ? rowsBinding(fresh, entry.id) : undefined;
1597
+ if (fresh === null || rowsRev(fresh, entry.id) !== committed || freshBinding?.scope !== binding?.scope || freshBinding?.slug !== binding?.slug)
1598
+ return undefined;
1599
+ }
1611
1600
  if (committed !== undefined && binding !== undefined && !(binding.scope === scope && binding.slug === f.slug)) {
1612
1601
  const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
1613
1602
  if (probe === "present") {
@@ -1618,6 +1607,8 @@ export class FileMemoryEngineBackend {
1618
1607
  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`);
1619
1608
  continue;
1620
1609
  }
1610
+ if (this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "quarantined-only"))
1611
+ continue;
1621
1612
  if (probeOnly)
1622
1613
  return undefined;
1623
1614
  if (lockToken !== undefined)
@@ -1630,6 +1621,8 @@ export class FileMemoryEngineBackend {
1630
1621
  continue;
1631
1622
  }
1632
1623
  if (committed !== entry.rev) {
1624
+ if (committed !== undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
1625
+ continue;
1633
1626
  if (probeOnly)
1634
1627
  return undefined;
1635
1628
  if (lockToken !== undefined)
@@ -1664,6 +1657,8 @@ export class FileMemoryEngineBackend {
1664
1657
  entry = entryFromFile(shadowText, entry.id, f.slug, scope);
1665
1658
  }
1666
1659
  else {
1660
+ if (committed === undefined && this.settledAsObserved(observed(), entries, entry, relPath, f.slug, scope, committed, binding, text, "restored"))
1661
+ continue;
1667
1662
  if (committed !== undefined)
1668
1663
  adoptedExternal.push(relPath);
1669
1664
  bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug: f.slug, at: this.now() }, moveEvents, "adopted-move");
@@ -1740,6 +1735,25 @@ export class FileMemoryEngineBackend {
1740
1735
  this.batchScan.set(scope, entries);
1741
1736
  return entries;
1742
1737
  }
1738
+ settledAsObserved(table, out, entry, relPath, slug, scope, committed, binding, text, containment) {
1739
+ if (!table.covers(entry.id) || this.inboundGate(relPath, text) !== undefined)
1740
+ return false;
1741
+ if (committed !== undefined && committed !== entry.rev && this.whitewashInboundFinding(relPath, entry, containment) !== undefined)
1742
+ return false;
1743
+ const hit = table.find(entry.id, committed, entry.rev);
1744
+ if (hit === undefined)
1745
+ return false;
1746
+ const standIn = committed !== undefined && binding !== undefined && binding.scope === scope && binding.slug === slug ? this.readCommittedShadow(entry.id) : undefined;
1747
+ const notice = observedDivergenceNotice(relPath, standIn !== undefined, hit.undecided === true);
1748
+ try {
1749
+ enqueueMemoryAnnouncement(this.controlPlaneRoot, { kind: "external", at: this.now(), items: [notice] }, { supersedesPrefix: notice });
1750
+ }
1751
+ catch {
1752
+ }
1753
+ if (standIn !== undefined)
1754
+ out.push(entryFromFile(standIn, entry.id, slug, scope));
1755
+ return true;
1756
+ }
1743
1757
  whitewashInboundFinding(relPath, entry, containment) {
1744
1758
  const committedShadow = this.readCommittedShadow(entry.id);
1745
1759
  const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
@@ -1862,14 +1876,46 @@ export class FileMemoryEngineBackend {
1862
1876
  throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
1863
1877
  };
1864
1878
  return {
1865
- listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.readScope(s, false))),
1866
- getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.readScope(s, false))),
1867
- search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.readScope(s, false))),
1879
+ listHeaders: async (scopes) => (this.ensureLayout(), this.listHeadersFrom(scopes, (s) => this.withoutObserved(this.readScope(s, false), s))),
1880
+ getByIds: async (ids) => (this.ensureLayout(), this.getByIdsFrom(ids, (s) => this.withoutObserved(this.readScope(s, false), s))),
1881
+ search: async (query, scopes, opts) => (this.ensureLayout(), this.searchFrom(query, scopes, opts, (s) => this.withoutObserved(this.readScope(s, false), s))),
1868
1882
  applyPatches: async () => refuse("applyPatches"),
1869
1883
  getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
1870
1884
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
1871
1885
  };
1872
1886
  }
1887
+ withoutObserved(entries, scope) {
1888
+ const table = layerObservationLookup(this.controlPlaneRoot);
1889
+ if (!entries.some((e) => table.covers(e.id)))
1890
+ return entries;
1891
+ const account = this.committedRowsOrFault();
1892
+ if (account === undefined)
1893
+ return entries.filter((e) => !table.covers(e.id));
1894
+ const out = [];
1895
+ for (const e of entries) {
1896
+ const committed = rowsRev(account, e.id);
1897
+ const binding = rowsBinding(account, e.id);
1898
+ const boundHere = binding !== undefined && binding.scope === scope && binding.slug === e.slug;
1899
+ if ((committed === e.rev && boundHere) || table.find(e.id, committed, e.rev) === undefined) {
1900
+ out.push(e);
1901
+ continue;
1902
+ }
1903
+ const standIn = committed !== undefined && boundHere ? this.readCommittedShadow(e.id) : undefined;
1904
+ if (standIn !== undefined)
1905
+ out.push(entryFromFile(standIn, e.id, e.slug, scope));
1906
+ }
1907
+ return out;
1908
+ }
1909
+ committedRowsOrFault() {
1910
+ const path = join(this.controlPlaneRoot, LEDGER_FILE);
1911
+ try {
1912
+ const raw = readControlFileOrAbsent(path, "committed-rev ledger");
1913
+ return raw === undefined ? Object.create(null) : parseLedgerText(raw, path).rows;
1914
+ }
1915
+ catch {
1916
+ return undefined;
1917
+ }
1918
+ }
1873
1919
  restrictedFindingKeys = new Set();
1874
1920
  recordRestrictedFinding(key, finding) {
1875
1921
  if (this.restrictedFindingKeys.has(key))
@@ -2187,6 +2233,9 @@ export class FileMemoryEngineBackend {
2187
2233
  try {
2188
2234
  return await this.applyPatchesLocked(patches, lock.token);
2189
2235
  }
2236
+ catch (err) {
2237
+ throw withPermissionRemedy(err, this.directoryRoot);
2238
+ }
2190
2239
  finally {
2191
2240
  lock.release();
2192
2241
  }
@@ -2343,16 +2392,37 @@ export class FileMemoryEngineBackend {
2343
2392
  return report;
2344
2393
  }
2345
2394
  const txn = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2395
+ const stagedFiles = [];
2396
+ const refuseUnstaging = (batch, at) => {
2397
+ try {
2398
+ this.refuseEscapingTargets(batch, at);
2399
+ }
2400
+ catch (err) {
2401
+ for (const f of stagedFiles) {
2402
+ try {
2403
+ const st = lstatSync(f.path);
2404
+ if (st.dev === f.dev && st.ino === f.ino)
2405
+ rmSync(f.path, { force: true });
2406
+ }
2407
+ catch {
2408
+ }
2409
+ }
2410
+ throw err;
2411
+ }
2412
+ };
2346
2413
  let n = 0;
2347
2414
  for (const op of ops) {
2348
2415
  if (op.kind !== "write" || op.content === undefined)
2349
2416
  continue;
2417
+ refuseUnstaging([op], "applyPatches (staging)");
2350
2418
  const staged = join(dirname(op.target), `.stage-${txn}-${n++}`);
2351
2419
  mkdirSync(dirname(op.target), { recursive: true });
2352
2420
  const fd = openSync(staged, "w", 0o600);
2353
2421
  try {
2354
2422
  writeAllSync(fd, op.content);
2355
2423
  fsyncSync(fd);
2424
+ const st = fstatSync(fd);
2425
+ stagedFiles.push({ path: staged, dev: st.dev, ino: st.ino });
2356
2426
  }
2357
2427
  finally {
2358
2428
  closeSync(fd);
@@ -2370,8 +2440,10 @@ export class FileMemoryEngineBackend {
2370
2440
  if (coherentTransfers.length > 0)
2371
2441
  this.precheckTransfersAppendable();
2372
2442
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
2443
+ refuseUnstaging(ops, "applyPatches (commit point)");
2373
2444
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2374
2445
  for (const op of ops) {
2446
+ this.refuseEscapingTargets([op], "applyPatches (after the commit point; the journal is kept)");
2375
2447
  if (op.kind === "write" && op.staged) {
2376
2448
  renameSync(op.staged, op.target);
2377
2449
  }
@@ -2796,8 +2868,10 @@ export class FileMemoryEngineBackend {
2796
2868
  this.assertTxnLockOwnership(lock.token);
2797
2869
  this.precheckTransfersAppendable();
2798
2870
  const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), transfers };
2871
+ this.refuseEscapingTargets(ops, "eraseWithEvidence (commit point)");
2799
2872
  atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
2800
2873
  for (const op of ops) {
2874
+ this.refuseEscapingTargets([op], "eraseWithEvidence (after the commit point; the journal is kept)");
2801
2875
  if (op.kind === "delete") {
2802
2876
  rmSync(op.target, { force: true });
2803
2877
  }
@@ -2865,7 +2939,7 @@ export class FileMemoryEngineBackend {
2865
2939
  }
2866
2940
  catch (err) {
2867
2941
  this.ledger = undefined;
2868
- throw err;
2942
+ throw withPermissionRemedy(err, this.directoryRoot);
2869
2943
  }
2870
2944
  finally {
2871
2945
  lock.release();