@sema-agent/core 5.45.0 → 5.47.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 (80) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/agents/subagent.js +130 -3
  3. package/dist/core/checkpoint-store.d.ts +12 -0
  4. package/dist/core/governance-codes.d.ts +13 -0
  5. package/dist/core/governance-codes.js +33 -0
  6. package/dist/core/hooks.d.ts +9 -2
  7. package/dist/core/hooks.js +6 -5
  8. package/dist/core/memory-engine/content-origin.d.ts +3 -1
  9. package/dist/core/memory-engine/delegation-settlement.d.ts +318 -0
  10. package/dist/core/memory-engine/delegation-settlement.js +661 -0
  11. package/dist/core/memory-engine/engine.d.ts +209 -4
  12. package/dist/core/memory-engine/engine.js +885 -39
  13. package/dist/core/memory-engine/export-bundle.d.ts +10 -1
  14. package/dist/core/memory-engine/export-bundle.js +21 -0
  15. package/dist/core/memory-engine/file-backend.d.ts +34 -4
  16. package/dist/core/memory-engine/file-backend.js +168 -40
  17. package/dist/core/memory-engine/frontmatter.d.ts +69 -1
  18. package/dist/core/memory-engine/frontmatter.js +156 -2
  19. package/dist/core/memory-engine/header-hints.d.ts +17 -0
  20. package/dist/core/memory-engine/header-hints.js +6 -0
  21. package/dist/core/memory-engine/index.d.ts +7 -5
  22. package/dist/core/memory-engine/index.js +5 -3
  23. package/dist/core/memory-engine/layout.d.ts +39 -2
  24. package/dist/core/memory-engine/layout.js +27 -14
  25. package/dist/core/memory-engine/memory-backend-contract.js +108 -0
  26. package/dist/core/memory-engine/origin-clearance.d.ts +66 -0
  27. package/dist/core/memory-engine/origin-clearance.js +84 -0
  28. package/dist/core/memory-engine/provenance-wording.d.ts +50 -0
  29. package/dist/core/memory-engine/provenance-wording.js +15 -0
  30. package/dist/core/memory-engine/sync-client.d.ts +1 -1
  31. package/dist/core/memory-engine/sync-client.js +33 -1
  32. package/dist/core/memory-engine/tools.d.ts +64 -3
  33. package/dist/core/memory-engine/tools.js +37 -9
  34. package/dist/core/memory-engine/types.d.ts +145 -3
  35. package/dist/core/memory-engine/types.js +1 -1
  36. package/dist/core/reminder-mint.d.ts +70 -0
  37. package/dist/core/reminder-mint.js +25 -0
  38. package/dist/core/runner/git-status-frame.d.ts +3 -14
  39. package/dist/core/runner/git-status-frame.js +39 -14
  40. package/dist/core/runner/prepare-config-doors.d.ts +4 -0
  41. package/dist/core/runner/prepare-config-doors.js +15 -0
  42. package/dist/core/runner/prepare-hands-readface.d.ts +5 -11
  43. package/dist/core/runner/prepare-hands-readface.js +26 -0
  44. package/dist/core/runner/prepare-memory.d.ts +11 -0
  45. package/dist/core/runner/prepare-memory.js +61 -24
  46. package/dist/core/runner/prepare-task.d.ts +46 -1
  47. package/dist/core/runner/prepare-task.js +128 -23
  48. package/dist/core/runner/runtask.js +62 -55
  49. package/dist/core/session-reconcile.js +3 -2
  50. package/dist/core/side-query.d.ts +11 -1
  51. package/dist/core/side-query.js +3 -0
  52. package/dist/core/types.d.ts +85 -10
  53. package/dist/core/types.js +3 -0
  54. package/dist/engine/harness/types.d.ts +46 -1
  55. package/dist/engine/harness/types.js +11 -0
  56. package/dist/engine/session/import-validate.js +6 -1
  57. package/dist/engine/session/session.d.ts +20 -0
  58. package/dist/engine/session/session.js +26 -1
  59. package/dist/index.d.ts +3 -1
  60. package/dist/index.js +3 -1
  61. package/dist/orchestration/run-workflow-tool.d.ts +16 -0
  62. package/dist/orchestration/run-workflow-tool.js +23 -3
  63. package/dist/orchestration/workflow-governance.d.ts +8 -1
  64. package/dist/prompt-assembly/epoch.js +2 -0
  65. package/dist/prompt-assembly/types.d.ts +6 -0
  66. package/dist/prompts/default.d.ts +13 -1
  67. package/dist/prompts/default.js +5 -1
  68. package/dist/tools/fs/fs-bash.d.ts +4 -0
  69. package/dist/tools/fs/fs-bash.js +1 -1
  70. package/dist/tools/fs/fs-read.d.ts +1 -1
  71. package/dist/tools/fs/fs-read.js +8 -7
  72. package/dist/tools/fs/fs-shared.d.ts +10 -4
  73. package/dist/tools/fs/fs-shared.js +6 -3
  74. package/dist/tools/fs/gh-rate-limit.d.ts +4 -1
  75. package/dist/tools/fs/gh-rate-limit.js +3 -2
  76. package/dist/tools/fs/index.d.ts +10 -2
  77. package/dist/tools/fs/index.js +2 -1
  78. package/dist/tools/task-list.d.ts +5 -1
  79. package/package.json +1 -1
  80. package/test/export-surface.snapshot.json +21 -2
@@ -1,5 +1,5 @@
1
1
  import { type TransferEvidence } from "./file-backend.js";
2
- import type { MemoryEntry } from "./types.js";
2
+ import { type MemoryEntry } from "./types.js";
3
3
  /** One exported challenge event (unresolved generations only). Generations are DESTINATION-minted:
4
4
  * the bundle deliberately carries no generation number — the importing store re-mints events under
5
5
  * its own idempotent namespace and allocates generations there. */
@@ -15,6 +15,10 @@ export interface BundlePollutedSession {
15
15
  sessionId: string;
16
16
  at: number;
17
17
  reason: string;
18
+ /** design/336 — the record's structured mechanical cause, carried verbatim (absent = the source
19
+ * record predates the vocabulary or genuinely recorded none; the destination's mints then fall
20
+ * back to "observed"). */
21
+ cause?: import("./types.js").MemoryOriginCause;
18
22
  }
19
23
  /** One exported committed lineage contribution (verbatim `lastRev`/`lastAt`). */
20
24
  export interface BundleLineageRow {
@@ -22,6 +26,11 @@ export interface BundleLineageRow {
22
26
  sessionId: string;
23
27
  lastRev: string;
24
28
  lastAt: number;
29
+ /** design/336 — the contribution's content carries its external-origin marker (see
30
+ * {@link import("./layout.js").LineageContribution.marked}). MUST travel with the row: dropping
31
+ * it re-quarantines the source's tag-admissions at the destination (the import pollution sweep
32
+ * challenges unmarked contributions of imported polluted sessions). */
33
+ marked?: true;
25
34
  }
26
35
  /**
27
36
  * design/178 v2 §4.3 + v2-c §6 — the governance-complete export bundle. The governance section is
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { canonicalJsonStringify } from "./file-backend.js";
3
3
  import { computeEntryRev } from "./frontmatter.js";
4
+ import { MEMORY_ORIGIN_CAUSES } from "./types.js";
5
+ import { ambiguousOriginRepresentation, committedOriginOf } from "./frontmatter.js";
4
6
  export const BUNDLE_SECTION_KEYS = ["meta", "entries", "governance", "residuals"];
5
7
  function sha256Line(text) {
6
8
  return createHash("sha256").update(`${text}\n`, "utf8").digest("hex");
@@ -69,6 +71,15 @@ function entryInvalid(raw, scopes) {
69
71
  return `entry ${JSON.stringify(e.id)} frontmatter.provenance is malformed`;
70
72
  }
71
73
  }
74
+ if (fm.origin !== undefined) {
75
+ const o = fm.origin;
76
+ if (!isRecord(o) || o.taint !== "external" || typeof o.at !== "number" || !Number.isFinite(o.at) || (o.cause !== undefined && !(typeof o.cause === "string" && MEMORY_ORIGIN_CAUSES.includes(o.cause)))) {
77
+ return `entry ${JSON.stringify(e.id)} frontmatter.origin is malformed`;
78
+ }
79
+ }
80
+ if (ambiguousOriginRepresentation(fm)) {
81
+ return `entry ${JSON.stringify(e.id)} carries conflicting or duplicated origin representations (ambiguous marker representation)`;
82
+ }
72
83
  const recomputed = computeEntryRev({ id: e.id, frontmatter: fm, body: e.body });
73
84
  if (recomputed !== e.rev)
74
85
  return `entry ${JSON.stringify(e.id)} rev ${JSON.stringify(e.rev)} does not equal its recomputed content rev ${JSON.stringify(recomputed)} (forged or corrupted package)`;
@@ -156,6 +167,8 @@ export function memoryBundleInvalid(raw, opts = {}) {
156
167
  return "polluted-session row has no at";
157
168
  if (typeof raw2.reason !== "string")
158
169
  return "polluted-session row has no reason";
170
+ if (raw2.cause !== undefined && !(typeof raw2.cause === "string" && MEMORY_ORIGIN_CAUSES.includes(raw2.cause)))
171
+ return "polluted-session row cause is not a known spelling";
159
172
  if (pollutedIds.has(raw2.sessionId))
160
173
  return `bundle.governance.pollutedSessions carries sessionId ${JSON.stringify(raw2.sessionId)} twice (bad package)`;
161
174
  pollutedIds.add(raw2.sessionId);
@@ -175,6 +188,14 @@ export function memoryBundleInvalid(raw, opts = {}) {
175
188
  return "lineage row has no lastRev";
176
189
  if (typeof raw2.lastAt !== "number")
177
190
  return "lineage row has no lastAt";
191
+ if (raw2.marked !== undefined && raw2.marked !== true)
192
+ return "lineage row marked is not the literal true";
193
+ if (raw2.marked === true) {
194
+ const carried = entriesArr.find((e) => e.id === raw2.entryId);
195
+ if (carried !== undefined && committedOriginOf(carried.frontmatter) === undefined) {
196
+ return `bundle.governance.lineage row (${String(raw2.entryId)}, ${String(raw2.sessionId)}) claims marked:true but the carried entry has no origin marker (dishonest package)`;
197
+ }
198
+ }
178
199
  const key = `${raw2.entryId}\u0000${raw2.sessionId}`;
179
200
  if (lineagePairs.has(key))
180
201
  return `bundle.governance.lineage carries (${raw2.entryId}, ${raw2.sessionId}) twice (bad package)`;
@@ -323,11 +323,13 @@ export interface MemoryExportSnapshot {
323
323
  sessionId: string;
324
324
  lastRev: string;
325
325
  lastAt: number;
326
+ marked?: true;
326
327
  }>;
327
328
  pollutedSessions: Array<{
328
329
  sessionId: string;
329
330
  at: number;
330
331
  reason: string;
332
+ cause?: import("./types.js").MemoryOriginCause;
331
333
  }>;
332
334
  quarantined: string[];
333
335
  quarantineOpaque: number;
@@ -419,6 +421,14 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
419
421
  private adoptionNoticeKeys;
420
422
  /** Test seam (§7.4 r4-④ arm): force a post-commit-point transfer-append failure. */
421
423
  private transfersAppendFault?;
424
+ /** Test seam (#262 ③/#191 review r1 fence pins): runs inside `recoverJournal` between the redo
425
+ * replay and the commit-boundary ownership re-assertion — a test flips the lock owner (or swaps
426
+ * the journal) here to exercise the mid-replay steal arms. Production leaves it unset. */
427
+ private recoveryCommitProbe?;
428
+ /** Test seam (#262 ③/#191 review r2): runs inside the torn-tail heal between the marker work and
429
+ * the pre-truncation ownership re-assertion — a test flips the lock owner here to prove the
430
+ * chain is never truncated after an ownership loss. Production leaves it unset. */
431
+ private chainHealProbe?;
422
432
  /** Test seam (v2-c §3-2 fence pins): runs INSIDE the export composite's mutex hold, after the
423
433
  * data reads and before the second fingerprint — a test injects a governance-face write here to
424
434
  * exercise the drift-retry and the three-round refusal. Production leaves it unset. */
@@ -511,11 +521,23 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
511
521
  /** Is the durable chain-degradation marker in place? Presence is ENOENT-only-absent (a probe
512
522
  * failure is never read as "not degraded" — fail-closed). */
513
523
  private chainDegradedMarkerPresent;
514
- /** §3-4 r6 — mint the chain-degradation marker DURABLY (wx + fsync + read-back verification).
515
- * Idempotent on an existing marker. Throws when the marker cannot be proven on disk — the caller
516
- * (the heal) must then ABORT with the chain untouched: truncating first would reopen the exact
517
- * window this marker closes ("chain reads complete, no marker" after an out-of-band tear). */
524
+ /** §3-4 r6 — mint the chain-degradation marker DURABLY (wx + fsync data + fsync DIRECTORY +
525
+ * read-back verification). Idempotent on an existing marker. Throws when the marker cannot be
526
+ * proven on disk — the caller (the heal) must then ABORT with the chain untouched: truncating
527
+ * first would reopen the exact window this marker closes ("chain reads complete, no marker"
528
+ * after an out-of-band tear). */
518
529
  private mintChainDegradedMarker;
530
+ /** backlog #270 — make a just-created control-plane directory ENTRY power-loss durable: open the
531
+ * control-plane root and fsync it. Fail-closed on failure: the one caller (the marker mint) must
532
+ * abort its heal with the chain byte-untouched — an unproven marker must never authorize a
533
+ * truncation, and the abort leaves the SAFER state either way (the standing marker or the still-
534
+ * visible tear both re-enter the heal on the next chain read). Platform notes: Node's fsync maps
535
+ * to fsync(2) — on macOS a full power-loss guarantee would additionally need F_FULLFSYNC
536
+ * (fcntl), which Node cannot reach; this is the same accepted bar as every other fsync in this
537
+ * store. On win32 a directory cannot be opened as an fd (fs.open rejects directories there), so
538
+ * the entry-durability half is structurally out of reach — an explicit documented arm, not a
539
+ * swallowed error; NTFS metadata journaling is the (weaker) cover that platform gets. */
540
+ private fsyncControlPlaneDir;
519
541
  /**
520
542
  * Read the WHOLE evidence chain in order (validated, collision-checked), healing a TORN TAIL (a
521
543
  * crash mid-append): the fragment is quarantined for evidence, the sound prefix stands (lines are
@@ -530,6 +552,13 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
530
552
  * Same-`ev` collision law (§4.4, read-time arm): canonical-form-equal duplicates are tolerated
531
553
  * (append-idempotency residue); DIFFERENT payloads under one ev are fail-closed — an event id is
532
554
  * one identity, and a reader must never pick a side by file order.
555
+ *
556
+ * `fenceToken` (#262 ③/#191 review r1) — this "read" MUTATES on a torn tail (quarantine + marker
557
+ * + truncation), so a caller whose entry assert is far behind it (the recovery leg's validation
558
+ * reads sit after unbounded parsing work) passes its lock token and the heal re-verifies
559
+ * ownership immediately before its first mutation: a recoverer stolen from mid-validation must
560
+ * not truncate a chain the stealer is appending to. Callers whose entry assert is adjacent keep
561
+ * their documented assert→act residue and may omit it.
533
562
  */
534
563
  private readTransferChain;
535
564
  /** The chain's ev → canonical-payload map (§4.4 upgraded load shape — the append path compares
@@ -871,6 +900,7 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
871
900
  /** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
872
901
  search(query: string, scopes: readonly string[], opts?: {
873
902
  limit?: number;
903
+ exposureBands?: boolean;
874
904
  }): Promise<ScoredMemoryEntry[]>;
875
905
  private searchFrom;
876
906
  /**
@@ -4,7 +4,7 @@ import { dirname, join, relative } from "node:path";
4
4
  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
- import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
7
+ import { ambiguousOriginRepresentation, committedOriginOf, computeEntryRev, entryFromFile, isValidEntryId, originEquals, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, stageImportLatch, writeAllSync, } from "./layout.js";
9
9
  import { screenInboundEntries } from "./data-plane.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
@@ -540,6 +540,8 @@ export class FileMemoryEngineBackend {
540
540
  unboundRowsKnown = false;
541
541
  adoptionNoticeKeys = new Set();
542
542
  transfersAppendFault;
543
+ recoveryCommitProbe;
544
+ chainHealProbe;
543
545
  exportFaceProbe;
544
546
  deletedIdsDigest;
545
547
  inboundFindings = [];
@@ -735,11 +737,18 @@ export class FileMemoryEngineBackend {
735
737
  const tryMkdir = () => {
736
738
  try {
737
739
  mkdirSync(this.controlPlaneRoot, { recursive: true });
740
+ }
741
+ catch (err) {
742
+ throw new ControlPlaneCorruptError(`memory control-plane root could not be created (${err.code ?? "io error"}) at ${this.controlPlaneRoot} — a lock-acquisition fault is not contention; deferring recovery/migration on it would be silent (fail-closed)`, { cause: err });
743
+ }
744
+ try {
738
745
  mkdirSync(lockDir);
739
746
  return true;
740
747
  }
741
- catch {
742
- return false;
748
+ catch (err) {
749
+ if (err.code === "EEXIST")
750
+ return false;
751
+ throw new ControlPlaneCorruptError(`memory txn lock could not be created (${err.code ?? "io error"}) at ${lockDir} — a lock-acquisition fault is not contention; deferring recovery/migration on it would be silent (fail-closed)`, { cause: err });
743
752
  }
744
753
  };
745
754
  let acquired = tryMkdir();
@@ -748,7 +757,22 @@ export class FileMemoryEngineBackend {
748
757
  try {
749
758
  mtimeMs = statSync(lockDir).mtimeMs;
750
759
  }
751
- catch {
760
+ catch (err) {
761
+ if (err.code !== "ENOENT") {
762
+ throw new ControlPlaneCorruptError(`memory txn lock state could not be probed (${err.code ?? "io error"}) at ${lockDir} — a probe failure is not contention (fail-closed)`, { cause: err });
763
+ }
764
+ let occupant;
765
+ try {
766
+ occupant = lstatSync(lockDir);
767
+ }
768
+ catch (lerr) {
769
+ if (lerr.code !== "ENOENT") {
770
+ throw new ControlPlaneCorruptError(`memory txn lock re-probe failed (${lerr.code ?? "io error"}) at ${lockDir} — a probe failure is not contention (fail-closed)`, { cause: lerr });
771
+ }
772
+ }
773
+ if (occupant !== undefined && !occupant.isDirectory()) {
774
+ throw new ControlPlaneCorruptError(`memory txn lock path is occupied by an invalid node (a dangling symlink?) at ${lockDir} — not contention and not a release race; remove the occupant (fail-closed)`, { cause: err });
775
+ }
752
776
  mtimeMs = undefined;
753
777
  }
754
778
  if (mtimeMs !== undefined && this.now() - mtimeMs > TXN_LOCK_STALE_MS) {
@@ -757,7 +781,10 @@ export class FileMemoryEngineBackend {
757
781
  statSync(ownerPath);
758
782
  tokenPresent = true;
759
783
  }
760
- catch {
784
+ catch (err) {
785
+ if (err.code !== "ENOENT") {
786
+ throw new ControlPlaneCorruptError(`memory txn lock owner token could not be probed (${err.code ?? "io error"}) at ${ownerPath} — a probe failure is not a mid-acquisition (fail-closed)`, { cause: err });
787
+ }
761
788
  }
762
789
  if (tokenPresent) {
763
790
  const tomb = join(this.controlPlaneRoot, `txn.lock.stale-${randomUUID()}`);
@@ -766,7 +793,10 @@ export class FileMemoryEngineBackend {
766
793
  renameSync(lockDir, tomb);
767
794
  stolen = true;
768
795
  }
769
- catch {
796
+ catch (err) {
797
+ if (err.code !== "ENOENT") {
798
+ throw new ControlPlaneCorruptError(`memory txn lock stale-steal rename failed (${err.code ?? "io error"}) at ${lockDir} — a rename fault is not a lost race (fail-closed)`, { cause: err });
799
+ }
770
800
  }
771
801
  if (stolen) {
772
802
  rmSync(tomb, { recursive: true, force: true });
@@ -819,7 +849,7 @@ export class FileMemoryEngineBackend {
819
849
  if (lock === undefined)
820
850
  return;
821
851
  try {
822
- this.recoverJournal();
852
+ this.recoverJournal({ token: lock.token });
823
853
  this.migrateLockedIfNeeded(lock.token);
824
854
  }
825
855
  finally {
@@ -892,8 +922,10 @@ export class FileMemoryEngineBackend {
892
922
  fd = openSync(path, "wx", 0o600);
893
923
  }
894
924
  catch (err) {
895
- if (err.code === "EEXIST")
925
+ if (err.code === "EEXIST") {
926
+ this.fsyncControlPlaneDir();
896
927
  return;
928
+ }
897
929
  throw new ControlPlaneCorruptError(`evidence-chain degradation marker could not be created at ${path} (${err.code ?? "io error"}) — heal aborted, the chain is unchanged (fail-closed)`);
898
930
  }
899
931
  try {
@@ -903,11 +935,32 @@ export class FileMemoryEngineBackend {
903
935
  finally {
904
936
  closeSync(fd);
905
937
  }
938
+ this.fsyncControlPlaneDir();
906
939
  if (readControlFileOrAbsent(path, "evidence-chain degradation marker") === undefined) {
907
940
  throw new ControlPlaneCorruptError(`evidence-chain degradation marker vanished after its write at ${path} — heal aborted, the chain is unchanged (fail-closed)`);
908
941
  }
909
942
  }
910
- readTransferChain() {
943
+ fsyncControlPlaneDir() {
944
+ if (process.platform === "win32")
945
+ return;
946
+ let dfd;
947
+ try {
948
+ dfd = openSync(this.controlPlaneRoot, "r");
949
+ }
950
+ catch (err) {
951
+ throw new ControlPlaneCorruptError(`evidence-chain degradation marker's directory could not be opened for fsync (${err.code ?? "io error"}) at ${this.controlPlaneRoot} — heal aborted, the chain is unchanged (fail-closed: an unproven marker must not authorize a truncation)`, { cause: err });
952
+ }
953
+ try {
954
+ fsyncSync(dfd);
955
+ }
956
+ catch (err) {
957
+ throw new ControlPlaneCorruptError(`evidence-chain degradation marker's directory entry could not be fsynced (${err.code ?? "io error"}) at ${this.controlPlaneRoot} — heal aborted, the chain is unchanged (fail-closed: an unproven marker must not authorize a truncation)`, { cause: err });
958
+ }
959
+ finally {
960
+ closeSync(dfd);
961
+ }
962
+ }
963
+ readTransferChain(fenceToken) {
911
964
  const path = join(this.controlPlaneRoot, TRANSFERS_FILE);
912
965
  const raw = readControlFileOrAbsent(path, "transfer evidence log");
913
966
  const out = [];
@@ -940,6 +993,8 @@ export class FileMemoryEngineBackend {
940
993
  }
941
994
  }
942
995
  const outOfBand = !journalHasPendingEvidence;
996
+ if (fenceToken !== undefined)
997
+ this.assertTxnLockOwnership(fenceToken);
943
998
  let fragmentPath;
944
999
  try {
945
1000
  ensureDirExists(join(this.controlPlaneRoot, QUARANTINE_DIR));
@@ -951,6 +1006,9 @@ export class FileMemoryEngineBackend {
951
1006
  }
952
1007
  if (outOfBand)
953
1008
  this.mintChainDegradedMarker(fragmentPath);
1009
+ this.chainHealProbe?.();
1010
+ if (fenceToken !== undefined)
1011
+ this.assertTxnLockOwnership(fenceToken);
954
1012
  try {
955
1013
  atomicWriteFileSync(path, lines.slice(0, i).join("\n") + (i > 0 ? "\n" : ""));
956
1014
  }
@@ -1110,21 +1168,22 @@ export class FileMemoryEngineBackend {
1110
1168
  closeSync(fd);
1111
1169
  }
1112
1170
  }
1113
- recoverJournal(opts = {}) {
1114
- if (opts.unlocked) {
1171
+ recoverJournal(fence) {
1172
+ if (fence.token === undefined) {
1115
1173
  if (this.txnInFlight())
1116
1174
  return;
1117
1175
  const lock = this.tryAcquireTxnLockSync();
1118
1176
  if (lock === undefined)
1119
1177
  return;
1120
1178
  try {
1121
- this.recoverJournal();
1179
+ this.recoverJournal({ token: lock.token });
1122
1180
  }
1123
1181
  finally {
1124
1182
  lock.release();
1125
1183
  }
1126
1184
  return;
1127
1185
  }
1186
+ const fenceToken = fence.token;
1128
1187
  const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
1129
1188
  const raw = readControlFileOrAbsent(jp, "transaction journal");
1130
1189
  if (raw !== undefined) {
@@ -1205,7 +1264,7 @@ export class FileMemoryEngineBackend {
1205
1264
  if (hasV1)
1206
1265
  throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
1207
1266
  if (journalEvs.size > 0) {
1208
- for (const row of this.readTransferChain()) {
1267
+ for (const row of this.readTransferChain(fenceToken)) {
1209
1268
  const j = journalEvs.get(row.ev);
1210
1269
  if (j !== undefined && j !== row.canonical) {
1211
1270
  throw new ControlPlaneCorruptError(`transaction journal event id ${JSON.stringify(row.ev)} collides with a DIFFERENT payload already on the evidence chain (fail-closed before redo): ${jp}`);
@@ -1242,7 +1301,7 @@ export class FileMemoryEngineBackend {
1242
1301
  }
1243
1302
  }
1244
1303
  if (e.req !== undefined && !anchorRows.some((a) => a.req === e.req)) {
1245
- chainAnchors ??= this.readTransferChain()
1304
+ chainAnchors ??= this.readTransferChain(fenceToken)
1246
1305
  .map((r) => r.parsed)
1247
1306
  .filter((p) => p.channel === "erasure-anchor" && p.origin === undefined);
1248
1307
  const matching = chainAnchors.filter((p) => p.req === e.req);
@@ -1254,7 +1313,7 @@ export class FileMemoryEngineBackend {
1254
1313
  }
1255
1314
  const chainAnchorEvsByReq = new Map();
1256
1315
  if (anchorRows.length > 0) {
1257
- for (const p of (chainAnchors ??= this.readTransferChain()
1316
+ for (const p of (chainAnchors ??= this.readTransferChain(fenceToken)
1258
1317
  .map((r) => r.parsed)
1259
1318
  .filter((p) => p.channel === "erasure-anchor" && p.origin === undefined))) {
1260
1319
  const set = chainAnchorEvsByReq.get(p.req) ?? new Set();
@@ -1332,6 +1391,7 @@ export class FileMemoryEngineBackend {
1332
1391
  }
1333
1392
  }
1334
1393
  }
1394
+ this.assertTxnLockOwnership(fenceToken);
1335
1395
  for (const op of journal.ops) {
1336
1396
  try {
1337
1397
  if (op.kind === "write" && op.content !== undefined) {
@@ -1354,6 +1414,8 @@ export class FileMemoryEngineBackend {
1354
1414
  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`);
1355
1415
  }
1356
1416
  }
1417
+ this.recoveryCommitProbe?.();
1418
+ this.assertTxnLockOwnership(fenceToken);
1357
1419
  if (hasV2) {
1358
1420
  this.ledger = snapshotRows;
1359
1421
  this.envelopeExtras = snapshotExtras;
@@ -1366,8 +1428,15 @@ export class FileMemoryEngineBackend {
1366
1428
  this.persistedSchemaVersion = "v1";
1367
1429
  this.persistLedgerV1(snapshotRows);
1368
1430
  }
1369
- rmSync(jp, { force: true });
1431
+ if (typeof journal.txn === "string") {
1432
+ this.closeJournalIfOurs(journal.txn);
1433
+ }
1434
+ else {
1435
+ this.assertTxnLockOwnership(fenceToken);
1436
+ rmSync(jp, { force: true });
1437
+ }
1370
1438
  }
1439
+ this.assertTxnLockOwnership(fenceToken);
1371
1440
  this.sweepStagedFiles();
1372
1441
  }
1373
1442
  txnInFlight() {
@@ -1452,6 +1521,9 @@ export class FileMemoryEngineBackend {
1452
1521
  if (first !== undefined) {
1453
1522
  return { path: relPath, code: first.code, reason: `inbound memory change blocked: ${first.reason} (quarantined)` };
1454
1523
  }
1524
+ if (ambiguousOriginRepresentation(parseEntryFile(text).frontmatter)) {
1525
+ return { path: relPath, code: "invalid", reason: `inbound memory change blocked: conflicting or duplicated origin representations (ambiguous external-origin marker; quarantined)` };
1526
+ }
1455
1527
  return undefined;
1456
1528
  }
1457
1529
  readScope(scope, sync, probeOnly, lockToken) {
@@ -1628,17 +1700,28 @@ export class FileMemoryEngineBackend {
1628
1700
  whitewashInboundFinding(relPath, entry, containment) {
1629
1701
  const committedShadow = this.readCommittedShadow(entry.id);
1630
1702
  const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
1631
- if (committedFm?.provenance?.kind !== "repo_file")
1703
+ if (committedFm === undefined)
1632
1704
  return undefined;
1633
- const stripsProvenance = entry.frontmatter.provenance === undefined;
1634
- const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
1635
- if (!stripsProvenance && !stripsTrust)
1636
- return undefined;
1637
- return {
1638
- path: relPath,
1639
- code: "invalid",
1640
- 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)"}`,
1641
- };
1705
+ if (committedFm.provenance?.kind === "repo_file") {
1706
+ const stripsProvenance = entry.frontmatter.provenance === undefined;
1707
+ const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
1708
+ if (stripsProvenance || stripsTrust) {
1709
+ return {
1710
+ path: relPath,
1711
+ code: "invalid",
1712
+ 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)"}`,
1713
+ };
1714
+ }
1715
+ }
1716
+ const committedOrigin = committedOriginOf(committedFm);
1717
+ if (committedOrigin !== undefined && !originEquals(committedOrigin, committedOriginOf(entry.frontmatter))) {
1718
+ return {
1719
+ path: relPath,
1720
+ code: "invalid",
1721
+ reason: `inbound memory change blocked: external-origin marker whitewash refused — the on-disk change strips or rewrites the origin marker of a marked entry ${containment === "restored" ? "(quarantined; committed content restored)" : "(quarantined)"}`,
1722
+ };
1723
+ }
1724
+ return undefined;
1642
1725
  }
1643
1726
  adoptMoveCandidateLocked(rows, entry, absPath, slug, scope, relPath, text, committedRev, from, moveEvents, moveAnnouncements, adoptedExternal) {
1644
1727
  const census = this.censusProjectionsLocked();
@@ -1685,7 +1768,7 @@ export class FileMemoryEngineBackend {
1685
1768
  try {
1686
1769
  this.assertTxnLockOwnership(lock.token);
1687
1770
  this.ledger = undefined;
1688
- this.recoverJournal();
1771
+ this.recoverJournal({ token: lock.token });
1689
1772
  this.migrateLockedIfNeeded(lock.token);
1690
1773
  return this.readScope(scope, true, false, lock.token);
1691
1774
  }
@@ -2052,7 +2135,8 @@ export class FileMemoryEngineBackend {
2052
2135
  scored.push({ ...headerOf(e, join(dir, `${e.slug}.md`)), score: d });
2053
2136
  }
2054
2137
  }
2055
- scored.sort((a, b) => a.score - b.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2138
+ const band = opts?.exposureBands === true ? (h) => (h.exposure === "external" ? 1 : 0) : () => 0;
2139
+ scored.sort((a, b) => band(a) - band(b) || a.score - b.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2056
2140
  return scored.slice(0, limit);
2057
2141
  }
2058
2142
  async applyPatches(patches) {
@@ -2177,7 +2261,7 @@ export class FileMemoryEngineBackend {
2177
2261
  async applyPatchesLocked(patches, lockToken) {
2178
2262
  this.assertTxnLockOwnership(lockToken);
2179
2263
  this.ledger = undefined;
2180
- this.recoverJournal();
2264
+ this.recoverJournal({ token: lockToken });
2181
2265
  this.migrateLockedIfNeeded(lockToken);
2182
2266
  const rows = cloneRows(this.loadLedger());
2183
2267
  const report = { applied: [], conflicts: [] };
@@ -2291,6 +2375,14 @@ export class FileMemoryEngineBackend {
2291
2375
  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)` });
2292
2376
  return;
2293
2377
  }
2378
+ if (patch.op !== "delete" && patch.entry !== undefined && ambiguousOriginRepresentation(patch.entry.frontmatter)) {
2379
+ report.conflicts.push({
2380
+ op: patch.op,
2381
+ id: patch.id,
2382
+ reason: `external-origin marker representation conflict: the entry carries conflicting or duplicated origin representations (malformed patch refused)`,
2383
+ });
2384
+ return;
2385
+ }
2294
2386
  if (patch.op === "add") {
2295
2387
  const entry = patch.entry;
2296
2388
  if (!entry) {
@@ -2299,6 +2391,19 @@ export class FileMemoryEngineBackend {
2299
2391
  }
2300
2392
  const locatedAnywhere = this.locateById(patch.id);
2301
2393
  const existingAnywhere = locatedAnywhere !== undefined && plannedDeletes.has(patch.id) ? undefined : locatedAnywhere;
2394
+ if (locatedAnywhere !== undefined) {
2395
+ const shadowText = this.readCommittedShadow(patch.id);
2396
+ const committedFm = shadowText !== undefined ? parseEntryFile(shadowText).frontmatter : locatedAnywhere.entry.frontmatter;
2397
+ const committedOrigin = committedOriginOf(committedFm);
2398
+ if (committedOrigin !== undefined && !originEquals(committedOrigin, committedOriginOf(entry.frontmatter))) {
2399
+ report.conflicts.push({
2400
+ op: "add",
2401
+ id: patch.id,
2402
+ reason: `external-origin marker whitewash refused: the add strips or rewrites the origin marker of a marked entry (malformed patch refused)`,
2403
+ });
2404
+ return;
2405
+ }
2406
+ }
2302
2407
  if (patch.guard === "absent") {
2303
2408
  if (existingAnywhere) {
2304
2409
  const currentRev = rowsRev(rows, patch.id) ?? existingAnywhere.entry.rev;
@@ -2376,6 +2481,15 @@ export class FileMemoryEngineBackend {
2376
2481
  return;
2377
2482
  }
2378
2483
  }
2484
+ const committedOrigin = committedOriginOf(committedFm);
2485
+ if (committedOrigin !== undefined && !originEquals(committedOrigin, committedOriginOf(patch.entry.frontmatter))) {
2486
+ report.conflicts.push({
2487
+ op: "update",
2488
+ id: patch.id,
2489
+ reason: `external-origin marker whitewash refused: the update strips or rewrites the origin marker of a marked entry (malformed patch refused)`,
2490
+ });
2491
+ return;
2492
+ }
2379
2493
  }
2380
2494
  const currentRev = rowsRev(rows, patch.id) ?? found.entry.rev;
2381
2495
  if (patch.baseRev !== undefined && patch.baseRev !== currentRev) {
@@ -2447,10 +2561,10 @@ export class FileMemoryEngineBackend {
2447
2561
  try {
2448
2562
  this.assertTxnLockOwnership(lock.token);
2449
2563
  this.ledger = undefined;
2450
- this.recoverJournal();
2564
+ this.recoverJournal({ token: lock.token });
2451
2565
  this.migrateLockedIfNeeded(lock.token);
2452
2566
  const rows = cloneRows(this.loadLedger());
2453
- const chain = this.readTransferChain();
2567
+ const chain = this.readTransferChain(lock.token);
2454
2568
  const degraded = this.chainDegradedMarkerPresent();
2455
2569
  const custodyState = degraded ? "damaged" : "complete";
2456
2570
  const selectHash = erasureSelectHash(select);
@@ -3141,7 +3255,7 @@ export class FileMemoryEngineBackend {
3141
3255
  try {
3142
3256
  this.assertTxnLockOwnership(lock.token);
3143
3257
  this.ledger = undefined;
3144
- this.recoverJournal();
3258
+ this.recoverJournal({ token: lock.token });
3145
3259
  const rows = this.loadLedger();
3146
3260
  if (this.persistedSchemaVersion !== "v2") {
3147
3261
  throw erasureCodedError("memory export refused: the committed-rev ledger is still schema v1 (pre-binding) — the store migrates itself at its next write/read entry or checkControlPlane; an export never triggers the migration", FileMemoryEngineBackend.EXPORT_INCOMPLETE);
@@ -3187,9 +3301,9 @@ export class FileMemoryEngineBackend {
3187
3301
  const lineage = [];
3188
3302
  for (const [entryId, sessions] of Object.entries(lineageRec.committed)) {
3189
3303
  for (const [sessionId, c] of Object.entries(sessions))
3190
- lineage.push({ entryId, sessionId, lastRev: c.lastRev, lastAt: c.lastAt });
3304
+ lineage.push({ entryId, sessionId, lastRev: c.lastRev, lastAt: c.lastAt, ...(c.marked === true ? { marked: true } : {}) });
3191
3305
  }
3192
- const pollutedSessions = Object.entries(polluted).map(([sessionId, r]) => ({ sessionId, at: r.at, reason: r.reason }));
3306
+ const pollutedSessions = Object.entries(polluted).map(([sessionId, r]) => ({ sessionId, at: r.at, reason: r.reason, ...(r.cause !== undefined ? { cause: r.cause } : {}) }));
3193
3307
  try {
3194
3308
  this.assertTxnLockOwnership(lock.token);
3195
3309
  }
@@ -3224,7 +3338,7 @@ export class FileMemoryEngineBackend {
3224
3338
  try {
3225
3339
  this.assertTxnLockOwnership(lock.token);
3226
3340
  this.ledger = undefined;
3227
- this.recoverJournal();
3341
+ this.recoverJournal({ token: lock.token });
3228
3342
  const rows = this.loadLedger();
3229
3343
  if (this.persistedSchemaVersion !== "v2")
3230
3344
  return { custody: [], unbound: [], complete: false, unsliceableCustody: 0, reason: "the committed-rev ledger is still schema v1 (migrate first)" };
@@ -3259,7 +3373,7 @@ export class FileMemoryEngineBackend {
3259
3373
  const lock = await this.acquireTxnLock(timings);
3260
3374
  try {
3261
3375
  this.assertTxnLockOwnership(lock.token);
3262
- this.recoverJournal();
3376
+ this.recoverJournal({ token: lock.token });
3263
3377
  return this.custodyImportLocked(rows, opts);
3264
3378
  }
3265
3379
  finally {
@@ -3328,7 +3442,7 @@ export class FileMemoryEngineBackend {
3328
3442
  try {
3329
3443
  this.assertTxnLockOwnership(lock.token);
3330
3444
  this.ledger = undefined;
3331
- this.recoverJournal();
3445
+ this.recoverJournal({ token: lock.token });
3332
3446
  this.migrateLockedIfNeeded(lock.token);
3333
3447
  const ctl = this.controlPlaneRoot;
3334
3448
  const txnId = importLatchTxnId(bundleHash);
@@ -3450,8 +3564,21 @@ export class FileMemoryEngineBackend {
3450
3564
  const orphanIds = new Set();
3451
3565
  const lineageToApply = [];
3452
3566
  for (const row of plan.lineage) {
3453
- if (eligible.has(row.entryId))
3567
+ if (eligible.has(row.entryId)) {
3568
+ if (row.marked === true) {
3569
+ const shadow = this.readCommittedShadow(row.entryId);
3570
+ let committedFm = shadow !== undefined ? parseEntryFile(shadow).frontmatter : undefined;
3571
+ if (committedFm === undefined)
3572
+ committedFm = this.locateById(row.entryId)?.entry.frontmatter;
3573
+ if (committedFm !== undefined && committedOriginOf(committedFm) === undefined) {
3574
+ const { marked: _dropped, ...unmarked } = row;
3575
+ lineageToApply.push(unmarked);
3576
+ report.conflicts.push({ id: row.entryId, reason: `lineage row claims marked:true but the committed content carries no origin marker — the bit was dropped (the pollution sweep judges the contribution)` });
3577
+ continue;
3578
+ }
3579
+ }
3454
3580
  lineageToApply.push(row);
3581
+ }
3455
3582
  else if (destKnown(row.entryId) || entryById.has(row.entryId))
3456
3583
  withheldIds.add(row.entryId);
3457
3584
  else
@@ -3476,14 +3603,14 @@ export class FileMemoryEngineBackend {
3476
3603
  ...(c.challengedRev !== undefined ? { challengedRev: c.challengedRev } : {}),
3477
3604
  })));
3478
3605
  for (const p of [...plan.pollutedSessions].sort((a, b) => (a.sessionId < b.sessionId ? -1 : 1))) {
3479
- const contributions = lineageContributionsOfSession(ctl, p.sessionId);
3606
+ const contributions = lineageContributionsOfSession(ctl, p.sessionId).filter((c) => c.marked !== true);
3480
3607
  this.appendImportChallengesVerified(contributions.map((c) => ({
3481
3608
  eventId: `imp-sweep:${bundleHash}:pollution:${p.sessionId}:${p.at}:${c.entryId}`,
3482
3609
  entryId: c.entryId,
3483
3610
  reason: `challenged: the contributing session was marked polluted`,
3484
3611
  challengedRev: c.lastRev,
3485
3612
  })));
3486
- const outcome = importSessionPollution(ctl, p.sessionId, { at: p.at, reason: p.reason });
3613
+ const outcome = importSessionPollution(ctl, p.sessionId, { at: p.at, reason: p.reason, ...(p.cause !== undefined ? { cause: p.cause } : {}) });
3487
3614
  if (outcome === "divergent")
3488
3615
  report.pollutionDivergence.push({ sessionId: p.sessionId, kept: "destination" });
3489
3616
  }
@@ -3568,6 +3695,7 @@ function headerOf(e, path) {
3568
3695
  mtimeMs,
3569
3696
  rev: e.rev,
3570
3697
  sizeBytes: Buffer.byteLength(serializeEntryFile(e), "utf8"),
3698
+ ...(committedOriginOf(e.frontmatter) !== undefined ? { exposure: "external" } : {}),
3571
3699
  };
3572
3700
  }
3573
3701
  function readSafe(path) {