@sema-agent/core 5.49.0 → 5.51.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 (46) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/agents/roster-store.js +4 -1
  3. package/dist/agents/send-message-tool.js +5 -5
  4. package/dist/agents/subagent.d.ts +6 -0
  5. package/dist/agents/subagent.js +142 -5
  6. package/dist/agents/teacher.js +4 -1
  7. package/dist/brain/anthropic.js +11 -20
  8. package/dist/brain/open-responses.js +6 -14
  9. package/dist/brain/openai.js +6 -18
  10. package/dist/brain/reasoning.d.ts +100 -8
  11. package/dist/brain/reasoning.js +39 -15
  12. package/dist/brain/request-params.d.ts +37 -1
  13. package/dist/brain/request-params.js +40 -2
  14. package/dist/core/auto-mode-prompt.js +9 -1
  15. package/dist/core/hooks.d.ts +24 -1
  16. package/dist/core/hooks.js +26 -4
  17. package/dist/core/mcp.d.ts +7 -1
  18. package/dist/core/mcp.js +64 -8
  19. package/dist/core/memory-engine/engine.d.ts +30 -1
  20. package/dist/core/memory-engine/engine.js +219 -18
  21. package/dist/core/memory-engine/layout.d.ts +43 -0
  22. package/dist/core/memory-engine/layout.js +59 -0
  23. package/dist/core/memory-engine/memory-backend-contract.js +87 -0
  24. package/dist/core/memory-engine/types.d.ts +13 -1
  25. package/dist/core/runner/assemble-result.d.ts +6 -0
  26. package/dist/core/runner/assemble-result.js +1 -1
  27. package/dist/core/runner/prepare-task.d.ts +15 -0
  28. package/dist/core/runner/prepare-task.js +104 -44
  29. package/dist/core/runner/runtask.d.ts +5 -1
  30. package/dist/core/runner/runtask.js +14 -7
  31. package/dist/core/task-registry-agent.js +9 -3
  32. package/dist/core/task-registry-shared.d.ts +6 -0
  33. package/dist/core/task-registry.js +4 -2
  34. package/dist/core/tool-policy.d.ts +37 -0
  35. package/dist/core/tool-policy.js +36 -3
  36. package/dist/core/tools.js +7 -0
  37. package/dist/core/types.d.ts +53 -1
  38. package/dist/engine/loop/agent-loop.js +95 -30
  39. package/dist/engine/loop/types.d.ts +32 -0
  40. package/dist/orchestration/run-workflow-tool.d.ts +12 -0
  41. package/dist/orchestration/run-workflow-tool.js +1 -1
  42. package/dist/orchestration/workflow-governance.d.ts +27 -0
  43. package/dist/orchestration/workflow-governance.js +13 -0
  44. package/dist/orchestration/workflow-primitives.d.ts +8 -1
  45. package/dist/orchestration/workflow-primitives.js +11 -3
  46. package/package.json +1 -1
package/dist/core/mcp.js CHANGED
@@ -650,7 +650,30 @@ export function mcpToolSchemaProblem(schema) {
650
650
  }
651
651
  return undefined;
652
652
  }
653
- export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure) {
653
+ export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations) {
654
+ let revocationProbeFailed = false;
655
+ const isServerRevoked = (serverName) => {
656
+ if (mcpRevocations === undefined)
657
+ return false;
658
+ try {
659
+ const r = mcpRevocations.isRevoked(serverName);
660
+ if (typeof r !== "boolean") {
661
+ throw new Error(`isRevoked returned ${r instanceof Promise ? "a Promise — the ledger seat is SYNCHRONOUS by contract" : `a non-boolean (${typeof r})`}`);
662
+ }
663
+ return r;
664
+ }
665
+ catch (e) {
666
+ if (!revocationProbeFailed) {
667
+ revocationProbeFailed = true;
668
+ try {
669
+ mcpRevocations.onProbeFailure?.(e);
670
+ }
671
+ catch {
672
+ }
673
+ }
674
+ return false;
675
+ }
676
+ };
654
677
  const mcpDisclosure = reminderDisclosure?.reminderMark !== undefined
655
678
  ? { mark: reminderDisclosure.reminderMark, windows: new Map(), ...(reminderDisclosure.counts !== undefined ? { counts: reminderDisclosure.counts } : {}) }
656
679
  : undefined;
@@ -665,7 +688,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
665
688
  const droppedTools = [];
666
689
  let disposing = false;
667
690
  const serverHandles = [];
668
- const settled = await Promise.allSettled(specs.map((spec) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure)));
691
+ const settled = await Promise.allSettled(specs.map((spec) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked)));
669
692
  try {
670
693
  for (let i = 0; i < specs.length; i++) {
671
694
  const r = settled[i];
@@ -701,7 +724,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
701
724
  statuses.push({ name: spec.name, status: "failed", error: namedMcpFailureText(r.reason) });
702
725
  }
703
726
  }
704
- const resourceTools = buildResourceTools(resourceServers);
727
+ const resourceTools = buildResourceTools(resourceServers, isServerRevoked);
705
728
  tools.push(...resourceTools.tools);
706
729
  toolAxes.push(...resourceTools.axes);
707
730
  }
@@ -729,10 +752,14 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
729
752
  results.push({ server: h.name, prefix: prefixOf(h.name), status: "not_connected", toolCount: 0, added: [], removed: [] });
730
753
  continue;
731
754
  }
755
+ if (isServerRevoked(h.name)) {
756
+ results.push({ server: h.name, prefix: prefixOf(h.name), status: "revoked", toolCount: 0, added: [], removed: [], error: "server revoked by the operator — the refresh did not contact it" });
757
+ continue;
758
+ }
732
759
  try {
733
760
  const listed = await listToolsLenient(h.client);
734
761
  cacheMcpToolMetadata(h.client, listed.tools);
735
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, h.spec, h.client, h.health, imageResizer, mcpDisclosure);
762
+ const { serverTools, serverAxes, dropped } = intakeListedTools(listed, h.spec, h.client, h.health, imageResizer, mcpDisclosure, isServerRevoked);
736
763
  const newNames = serverTools.map((t) => t.name);
737
764
  const added = newNames.filter((n) => !h.toolNames.includes(n));
738
765
  const removed = h.toolNames.filter((n) => !newNames.includes(n));
@@ -882,7 +909,7 @@ function renderDirChildren(server, uri, children, flags) {
882
909
  terminate: false,
883
910
  };
884
911
  }
885
- function buildResourceTools(resourceServers) {
912
+ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
886
913
  const listable = resourceServers.filter((rs) => rs.listAllowed);
887
914
  const readable = resourceServers.filter((rs) => rs.readAllowed);
888
915
  if (listable.length === 0 && readable.length === 0)
@@ -910,6 +937,11 @@ function buildResourceTools(resourceServers) {
910
937
  const all = [];
911
938
  const errors = [];
912
939
  for (const rs of targets) {
940
+ if (isServerRevoked(rs.server)) {
941
+ errors.push({ server: rs.server, error: "server revoked by the operator mid-session (request not sent)" });
942
+ sections.push(`[${rs.server}] Error: server revoked by the operator — its resources are unavailable this turn.`);
943
+ continue;
944
+ }
913
945
  if (rs.health.dead) {
914
946
  errors.push({ server: rs.server, error: "server disconnected (transport closed earlier in this task)" });
915
947
  sections.push(`[${rs.server}] Error: server disconnected — its resources are unavailable.`);
@@ -970,6 +1002,14 @@ function buildResourceTools(resourceServers) {
970
1002
  if (!rs)
971
1003
  return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with readable resources. Available: ${serverNames(readable)}.` }], details: undefined, terminate: false };
972
1004
  const what = `The read of resource ${inlineUntrusted(uri)}`;
1005
+ if (isServerRevoked(server)) {
1006
+ return {
1007
+ content: [{ type: "text", text: `${what} was refused: MCP server "${server}" was revoked by the operator mid-session. The request was NOT sent. The tool list updates at the next turn.` }],
1008
+ details: { error: "mcp.server_revoked", code: "mcp.server_revoked", server },
1009
+ terminate: false,
1010
+ isError: true,
1011
+ };
1012
+ }
973
1013
  if (rs.health.dead)
974
1014
  throwDeadServer(server, what);
975
1015
  const timeoutMs = mcpToolTimeoutMs();
@@ -1021,6 +1061,14 @@ function buildResourceTools(resourceServers) {
1021
1061
  if (!rs)
1022
1062
  return { content: [{ type: "text", text: `Error: no MCP server ${inlineUntrusted(server)} with listable resources. Available: ${serverNames(listable)}.` }], details: undefined, terminate: false };
1023
1063
  const what = `The directory listing of ${inlineUntrusted(uri)}`;
1064
+ if (isServerRevoked(server)) {
1065
+ return {
1066
+ content: [{ type: "text", text: `${what} was refused: MCP server "${server}" was revoked by the operator mid-session. The request was NOT sent. The tool list updates at the next turn.` }],
1067
+ details: { error: "mcp.server_revoked", code: "mcp.server_revoked", server },
1068
+ terminate: false,
1069
+ isError: true,
1070
+ };
1071
+ }
1024
1072
  if (rs.health.dead)
1025
1073
  throwDeadServer(server, what);
1026
1074
  const timeoutMs = mcpToolTimeoutMs();
@@ -1123,7 +1171,7 @@ const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient
1123
1171
  async function listToolsLenient(client, options) {
1124
1172
  return client.request({ method: "tools/list", params: {} }, LenientListToolsResultSchema, options);
1125
1173
  }
1126
- async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure) {
1174
+ async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure, isServerRevoked) {
1127
1175
  const elicitOn = spec.elicitation === true && onElicit !== undefined;
1128
1176
  const health = { dead: false, pendingElicitations: 0, lastElicitationClosedAt: 0 };
1129
1177
  const client = new Client({ name: `sema-core/${spec.name}`, version: "0.1.0" }, { capabilities: elicitOn ? { elicitation: { form: {} } } : {} });
@@ -1159,7 +1207,7 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1159
1207
  };
1160
1208
  const listed = await listToolsLenient(client, startupOpts);
1161
1209
  cacheMcpToolMetadata(client, listed.tools);
1162
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure);
1210
+ const { serverTools, serverAxes, dropped } = intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure, isServerRevoked);
1163
1211
  const caps = client.getServerCapabilities();
1164
1212
  const resourceInfo = caps?.resources
1165
1213
  ? {
@@ -1192,7 +1240,7 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1192
1240
  throw err;
1193
1241
  }
1194
1242
  }
1195
- function intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure) {
1243
+ function intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure, isServerRevoked) {
1196
1244
  const serverTools = [];
1197
1245
  const serverAxes = [];
1198
1246
  const dropped = [];
@@ -1235,6 +1283,14 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1235
1283
  ...(mcpAlwaysLoad ? { mcpAlwaysLoad: true } : {}),
1236
1284
  execute: async (_toolCallId, params, signal) => {
1237
1285
  const what = `The call to tool ${inlineUntrusted(remoteName)}`;
1286
+ if (isServerRevoked?.(spec.name) === true) {
1287
+ return {
1288
+ content: [{ type: "text", text: `The call to MCP server "${spec.name}" was refused: the server was revoked by the operator mid-session. The call was NOT sent, so the server did not execute it. The tool list updates at the next turn.` }],
1289
+ details: { error: "mcp.server_revoked", code: "mcp.server_revoked", server: spec.name },
1290
+ terminate: false,
1291
+ isError: true,
1292
+ };
1293
+ }
1238
1294
  if (health.dead)
1239
1295
  throwDeadServer(spec.name, what);
1240
1296
  const timeoutMs = mcpToolTimeoutMs();
@@ -849,6 +849,11 @@ export declare class MemoryEngine {
849
849
  * the deleted disk file WAS the backend's storage). Zero-copy skips getByIds: its read-side scan
850
850
  * cannot see a deleted file, and calling it mid-harvest would sync-adopt in-session edits. */
851
851
  private committedContentFor;
852
+ /** {@link committedContentFor} with the FAULT axis preserved: `fault: true` means the backend
853
+ * read THREW and no shadow answered — "could not read" — which consumers that make destructive
854
+ * decisions on absence (the projection-debt settlement clears a row on "entry gone") must
855
+ * distinguish from a clean miss. The 8 non-destructive read sites keep the folded face. */
856
+ private committedContentOrFault;
852
857
  /**
853
858
  * design/336 §2.2 (r4-9) — the COMMITTED frontmatter an origin carry-forward is computed against.
854
859
  * Deliberately NOT {@link committedContentFor}'s non-zero-copy leg: that one calls the backend's
@@ -910,8 +915,32 @@ export declare class MemoryEngine {
910
915
  /** Persist the canonical projection back to the session file (id minting / frontmatter completion).
911
916
  * C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal
912
917
  * commit point. Skips the write when the disk already holds the canonical bytes (zero-copy: the
913
- * journal's own execute step wrote them, making this an idempotent no-op). */
918
+ * journal's own execute step wrote them, making this an idempotent no-op). Returns whether the
919
+ * disk now holds the canonical bytes — the ordinary completion family stays best-effort on a
920
+ * `false` (the next materialize re-projects), but the id-COMPLETION family's caller reads it to
921
+ * keep the #366 projection-debt row standing (an id-less plane file must re-bind to its
922
+ * committed id at the next harvest, never re-admit as a duplicate). */
914
923
  private writeBackProjection;
924
+ /**
925
+ * #366 — repair an id-less-but-committed seat IN PLACE (the failed write-back's retry): write the
926
+ * committed canonical bytes over the seat and settle the debt row on success. Callers have
927
+ * already proven the seat carries no unadmitted edit (rev equality against its baseline), so the
928
+ * overwrite is content-preserving — it restores the id line, the completed frontmatter and any
929
+ * carried marker, nothing else. A refused read/write leaves the row standing (retried next pass;
930
+ * the next materialize's projection loop repairs and settles it too).
931
+ */
932
+ private repairProjectionSeat;
933
+ /**
934
+ * #366 — validate a projection-debt row against the COMMITTED state (side-effect-free read: the
935
+ * retrievalView face in copy-out, never the File backend's adopting `getByIds`). Three-valued on
936
+ * purpose: `valid` binds, `stale` clears the row (the seat claim dissolved — entry gone, moved or
937
+ * renamed), and `unknown` (committed state unreadable) makes the caller DEFER the file fail-closed
938
+ * — treating a transient read fault as "stale" would clear the row and mint the very duplicate
939
+ * the account exists to close. Zero-copy answers `stale` by construction: there the backend's own
940
+ * commit writes the id into the plane file, so any standing row is a leftover, and the plain
941
+ * `getByIds` there would sync-adopt mid-harvest.
942
+ */
943
+ private debtCommittedProjection;
915
944
  /** Sibling scope subdir names under `dir` (excluded from a scope-tree walk when `dir` is the root —
916
945
  * a root-owning layer's chmod/restore must never touch another scope's home). */
917
946
  private siblingScopeDirNames;
@@ -14,7 +14,7 @@ import { noReplaceRestore } from "./delegation-settlement.js";
14
14
  import { markOriginClearanceTombstoned, openOriginClearance, readOriginClearances, settleOriginClearance } from "./origin-clearance.js";
15
15
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
16
16
  import { assembleMemoryExportBundle, computeMemoryBundleHash, memoryBundleInvalid, } from "./export-bundle.js";
17
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, resolveChallengeEvent, stageLineagePending, } from "./layout.js";
17
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, readProjectionDebts, resolveChallengeEvent, settleProjectionDebts, stageLineagePending, stageProjectionDebts, } from "./layout.js";
18
18
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
19
19
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
20
20
 
@@ -876,6 +876,8 @@ export class MemoryEngine {
876
876
  this.discloseSessionAccountIncident("the committed snapshot was contended/incomplete — the id-bearing residue arm is degraded to id-less for this materialize", undefined);
877
877
  }
878
878
  }
879
+ const debtByRel = new Map(readProjectionDebts(this.controlDir).map((r) => [r.relPath, r.entryId]));
880
+ const canonicalMemoryDir = canonicalize(this.memoryDir);
879
881
  for (const f of scanEntryFiles(wroot, { maxDepth: this.maxDepth })) {
880
882
  const canonical = canonicalize(f.path);
881
883
  if (canonical !== wroot && !canonical.startsWith(`${wroot}${sep}`))
@@ -883,11 +885,20 @@ export class MemoryEngine {
883
885
  const text = readSafe(canonical);
884
886
  if (text === undefined)
885
887
  continue;
886
- const rel = relative(this.memoryDir, canonical);
888
+ const rel = relative(canonicalMemoryDir, canonical);
887
889
  if (rel === MEMORY_INDEX_FILENAME || rel.endsWith(`${sep}${MEMORY_INDEX_FILENAME}`))
888
890
  continue;
889
891
  const parsed = parseEntryFile(text);
890
- const uncommitted = parsed.id === undefined || (committedRevs !== undefined && !committedRevs.has(parsed.id));
892
+ const debtEntryId = parsed.id === undefined ? debtByRel.get(relative(canonicalMemoryDir, canonical)) : undefined;
893
+ let debtAttributed = false;
894
+ if (debtEntryId !== undefined) {
895
+ const proj = await this.debtCommittedProjection(debtEntryId);
896
+ if (proj.state === "unknown") {
897
+ throw new Error(`the committed state for debt-bound file ${rel} could not be read during crash-residue attribution — refusing to classify on a fault`);
898
+ }
899
+ debtAttributed = proj.state === "valid" && proj.scope === writeScope && proj.slug === f.slug;
900
+ }
901
+ const uncommitted = (parsed.id === undefined && !debtAttributed) || (parsed.id !== undefined && committedRevs !== undefined && !committedRevs.has(parsed.id));
891
902
  if (uncommitted) {
892
903
  unattributed.push(rel);
893
904
  continue;
@@ -1029,6 +1040,20 @@ export class MemoryEngine {
1029
1040
  handle.baseIds.set(path, entry.id);
1030
1041
  }
1031
1042
  }
1043
+ {
1044
+ const debts = readProjectionDebts(this.controlDir);
1045
+ if (debts.length > 0) {
1046
+ const projectedIdByRel = new Map(handle.materialized.filter((m) => !m.stub).map((m) => [m.relPath, m.id]));
1047
+ const healed = debts.filter((r) => projectedIdByRel.get(r.relPath) === r.entryId).map((r) => ({ relPath: r.relPath, entryId: r.entryId }));
1048
+ if (healed.length > 0) {
1049
+ try {
1050
+ settleProjectionDebts(this.controlDir, healed);
1051
+ }
1052
+ catch {
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1032
1057
  const indexGate = this.gateDerivedIndex(handle);
1033
1058
  if (indexGate !== undefined) {
1034
1059
  if (!indexGate.contained)
@@ -1508,6 +1533,17 @@ export class MemoryEngine {
1508
1533
  for (const [p, id] of idByBasePath)
1509
1534
  if (!handle.fileToScope.get(p) || handle.fileToScope.get(p) === handle.writeScope)
1510
1535
  basePathById.set(id, p);
1536
+ let projectionDebts;
1537
+ try {
1538
+ projectionDebts = new Map(readProjectionDebts(this.controlDir).map((r) => [r.relPath, { entryId: r.entryId, rev: r.rev }]));
1539
+ }
1540
+ catch (err) {
1541
+ report.ok = false;
1542
+ report.incident = { kind: "sidecar_corrupt", detail: `memory projection-debt ledger unreadable: ${err instanceof Error ? err.message : String(err)}` };
1543
+ return report;
1544
+ }
1545
+ const debtClears = [];
1546
+ const staleReplacedDebts = new Map();
1511
1547
  const patches = [];
1512
1548
  const pendingProjections = [];
1513
1549
  const idsAddedThisHarvest = new Set();
@@ -1528,8 +1564,12 @@ export class MemoryEngine {
1528
1564
  continue;
1529
1565
  }
1530
1566
  const fastBaseId = idByBasePath.get(f.canonical);
1531
- if (fastBaseId !== undefined && !stubByPath.has(f.canonical) && revOfText(f.text, fastBaseId) === revByBasePath.get(f.canonical))
1567
+ if (fastBaseId !== undefined && !stubByPath.has(f.canonical) && revOfText(f.text, fastBaseId) === revByBasePath.get(f.canonical)) {
1568
+ const fastDebt = projectionDebts.get(rel);
1569
+ if (fastDebt !== undefined && fastDebt.entryId === fastBaseId)
1570
+ await this.repairProjectionSeat(rel, f.canonical, fastBaseId, debtClears);
1532
1571
  continue;
1572
+ }
1533
1573
  processed++;
1534
1574
  if (pollutedReason !== undefined && !carry) {
1535
1575
  await containPollutedRecord(f);
@@ -1585,8 +1625,35 @@ export class MemoryEngine {
1585
1625
  continue;
1586
1626
  }
1587
1627
  const parsed = f.parsed;
1588
- const baseId = idByBasePath.get(f.canonical);
1628
+ let baseId = idByBasePath.get(f.canonical);
1589
1629
  let id = baseId ?? parsed.id;
1630
+ let debtReconciled = false;
1631
+ if (id === undefined && projectionDebts.size > 0) {
1632
+ const debt = projectionDebts.get(rel);
1633
+ if (debt !== undefined) {
1634
+ const committed = await this.debtCommittedProjection(debt.entryId);
1635
+ if (committed.state === "unknown") {
1636
+ report.rejections.push({
1637
+ path: rel,
1638
+ code: "deferred",
1639
+ reason: "memory write deferred: this file's recorded committed id could not be validated (committed state unreadable) — retried next harvest (fail-closed: a fresh mint here could duplicate the committed entry)",
1640
+ });
1641
+ continue;
1642
+ }
1643
+ if (committed.state === "valid" && committed.scope === writeScope && committed.slug === f.slug) {
1644
+ baseId = debt.entryId;
1645
+ id = debt.entryId;
1646
+ idByBasePath.set(f.canonical, debt.entryId);
1647
+ revByBasePath.set(f.canonical, debt.rev);
1648
+ debtReconciled = true;
1649
+ report.warnings.push(`${rel}: reconciled to its committed entry — a prior harvest committed this file but the id write-back did not land; the projection is repaired through the ordinary update lane, never re-admitted as a duplicate`);
1650
+ }
1651
+ else {
1652
+ debtClears.push({ relPath: rel, entryId: debt.entryId });
1653
+ staleReplacedDebts.set(rel, debt.entryId);
1654
+ }
1655
+ }
1656
+ }
1590
1657
  const minted = id === undefined;
1591
1658
  if (id === undefined)
1592
1659
  id = uuidv7();
@@ -1680,9 +1747,13 @@ export class MemoryEngine {
1680
1747
  continue;
1681
1748
  }
1682
1749
  if (baseId !== undefined) {
1683
- if (revOfText(f.text, id) === revByBasePath.get(f.canonical))
1750
+ if (revOfText(f.text, id) === revByBasePath.get(f.canonical)) {
1751
+ const seatDebt = projectionDebts.get(rel);
1752
+ if (seatDebt !== undefined && seatDebt.entryId === id)
1753
+ await this.repairProjectionSeat(rel, f.canonical, id, debtClears);
1684
1754
  continue;
1685
- pendingProjections.push({ path: f.canonical, entry, needed: minted || parsed.id !== id || needsCompletion(parsed, fm) });
1755
+ }
1756
+ pendingProjections.push({ path: f.canonical, entry, needed: minted || parsed.id !== id || needsCompletion(parsed, fm), ...(debtReconciled ? { idCompletion: true } : {}) });
1686
1757
  patches.push({ op: "update", id, entry, ...(revByBasePath.get(f.canonical) !== undefined ? { baseRev: revByBasePath.get(f.canonical) } : {}) });
1687
1758
  continue;
1688
1759
  }
@@ -1732,7 +1803,7 @@ export class MemoryEngine {
1732
1803
  entry.rev = computeEntryRev(entry);
1733
1804
  report.warnings.push(`${rel}: the file's model-written id was not adopted for this external-origin commit — committed under an engine-minted id (a marked entry's index/search handles carry no model-authored bytes)`);
1734
1805
  }
1735
- pendingProjections.push({ path: f.canonical, entry, needed: minted || reboundId !== undefined || remintedId !== undefined || needsCompletion(parsed, fm) });
1806
+ pendingProjections.push({ path: f.canonical, entry, needed: minted || reboundId !== undefined || remintedId !== undefined || needsCompletion(parsed, fm), ...(minted || reboundId !== undefined || remintedId !== undefined ? { idCompletion: true } : {}) });
1736
1807
  idsAddedThisHarvest.add(entry.id);
1737
1808
  if (parsed.id !== undefined && entry.id === parsed.id)
1738
1809
  modelIdAdds.add(entry.id);
@@ -1835,6 +1906,7 @@ export class MemoryEngine {
1835
1906
  p.entry.id = uuidv7();
1836
1907
  p.entry.rev = computeEntryRev(p.entry);
1837
1908
  p.id = p.entry.id;
1909
+ projection.idCompletion = true;
1838
1910
  report.warnings.push(`${flipRec.rel}: the file's model-written id was not adopted for this external-origin commit — committed under an engine-minted id (a marked entry's index/search handles carry no model-authored bytes)`);
1839
1911
  }
1840
1912
  }
@@ -1869,6 +1941,68 @@ export class MemoryEngine {
1869
1941
  }
1870
1942
  }
1871
1943
  }
1944
+ const debtStage = pendingProjections
1945
+ .filter((p) => p.idCompletion === true)
1946
+ .map((p) => {
1947
+ const relPath = relative(handle.memoryDir, p.path);
1948
+ const replaces = staleReplacedDebts.get(relPath);
1949
+ return { relPath, entryId: p.entry.id, rev: p.entry.rev, ...(replaces !== undefined ? { replaces } : {}) };
1950
+ });
1951
+ if (debtStage.length > 0) {
1952
+ let refusedStage = [];
1953
+ try {
1954
+ refusedStage = stageProjectionDebts(this.controlDir, debtStage, this.now).refused;
1955
+ }
1956
+ catch (err) {
1957
+ report.warnings.push(`memory projection-debt staging failed — duplicate-admission protection for this harvest's id write-backs is degraded (a failed write-back could re-admit its file as a duplicate until the next materialize repairs the plane): ${err instanceof Error ? err.message : String(err)}`);
1958
+ }
1959
+ if (refusedStage.length > 0) {
1960
+ const refusedIds = new Set(refusedStage.map((r) => r.entryId));
1961
+ for (let i = patches.length - 1; i >= 0; i--) {
1962
+ const p = patches[i];
1963
+ if (p.op !== "delete" && refusedIds.has(p.id))
1964
+ patches.splice(i, 1);
1965
+ }
1966
+ for (let i = pendingProjections.length - 1; i >= 0; i--) {
1967
+ const proj = pendingProjections[i];
1968
+ if (refusedIds.has(proj.entry.id)) {
1969
+ idByBasePath.delete(proj.path);
1970
+ revByBasePath.delete(proj.path);
1971
+ pendingProjections.splice(i, 1);
1972
+ }
1973
+ }
1974
+ for (const r of refusedStage) {
1975
+ report.rejections.push({
1976
+ path: r.relPath,
1977
+ code: "deferred",
1978
+ reason: "memory write deferred: a concurrent writer's projection-debt claim stands at this seat — retried next harvest (committing without a protected id write-back could duplicate the concurrent entry)",
1979
+ });
1980
+ report.warnings.push(`${r.relPath}: earlier notes about this file committing under a fresh id are superseded — its commit was WITHDRAWN this harvest (deferred to the next)`);
1981
+ }
1982
+ if (lineageArmed) {
1983
+ const remaining = patches.filter((p) => p.op !== "delete" && p.entry !== undefined).map((p) => ({ entryId: p.id, rev: p.entry.rev, ...(p.entry.frontmatter.origin !== undefined ? { marked: true } : {}) }));
1984
+ if (remaining.length > 0) {
1985
+ try {
1986
+ stageLineagePending(this.controlDir, txnId, lineageSessionId, remaining, this.now);
1987
+ }
1988
+ catch (err) {
1989
+ report.ok = false;
1990
+ report.incident = { kind: "sidecar_corrupt", detail: `memory lineage restage refused after a withheld projection-debt seat: ${err instanceof Error ? err.message : String(err)}` };
1991
+ return report;
1992
+ }
1993
+ }
1994
+ else {
1995
+ try {
1996
+ discardLineagePending(this.controlDir, txnId);
1997
+ }
1998
+ catch (err) {
1999
+ report.warnings.push(`memory lineage stage for withheld entries could not be discarded — their ids stay latched until the host adjudicates pending transaction ${txnId}: ${err instanceof Error ? err.message : String(err)}`);
2000
+ }
2001
+ lineageArmed = false;
2002
+ }
2003
+ }
2004
+ }
2005
+ }
1872
2006
  try {
1873
2007
  patchReport = await this.backend.applyPatches(patches);
1874
2008
  }
@@ -1938,18 +2072,44 @@ export class MemoryEngine {
1938
2072
  }
1939
2073
  const appliedIds = new Set(patchReport.applied.filter((a) => a.op !== "delete").map((a) => a.id));
1940
2074
  for (const p of pendingProjections) {
2075
+ const pRel = relative(handle.memoryDir, p.path);
1941
2076
  if (appliedIds.has(p.entry.id)) {
1942
- this.writeBackProjection(p.path, p.entry, p.needed);
2077
+ const wrote = this.writeBackProjection(p.path, p.entry, p.needed);
2078
+ if (p.idCompletion === true) {
2079
+ if (wrote)
2080
+ debtClears.push({ relPath: pRel, entryId: p.entry.id });
2081
+ else {
2082
+ const landed = patchReport.applied.find((a) => a.id === p.entry.id)?.slug;
2083
+ const suffixNote = landed !== undefined && landed !== p.entry.slug ? ` NOTE: the entry landed at slug ${JSON.stringify(landed)} (collision suffix) — the seat claim will not re-bind; host reconciliation may be needed.` : "";
2084
+ report.warnings.push(`${pRel}: the entry committed but its id write-back FAILED — the projection-debt row stands, so the file re-binds to its committed id at the next harvest (and the next materialize repairs the plane); it is never re-admitted as a duplicate.${suffixNote}`);
2085
+ }
2086
+ }
1943
2087
  continue;
1944
2088
  }
1945
- const committed = await this.committedContentFor(p.entry.id);
2089
+ const committedRead = await this.committedContentOrFault(p.entry.id);
2090
+ const committed = committedRead.content;
2091
+ let planeHoldsCommitted = false;
1946
2092
  if (committed !== undefined && readNoFollowSafe(p.path) !== committed) {
1947
2093
  try {
1948
2094
  writeFileNoFollow(p.path, committed);
2095
+ planeHoldsCommitted = true;
1949
2096
  }
1950
2097
  catch {
1951
2098
  }
1952
2099
  }
2100
+ else if (committed !== undefined) {
2101
+ planeHoldsCommitted = true;
2102
+ }
2103
+ if (p.idCompletion === true && ((committed === undefined && !committedRead.fault) || planeHoldsCommitted))
2104
+ debtClears.push({ relPath: pRel, entryId: p.entry.id });
2105
+ }
2106
+ if (debtClears.length > 0) {
2107
+ try {
2108
+ settleProjectionDebts(this.controlDir, debtClears);
2109
+ }
2110
+ catch (err) {
2111
+ report.warnings.push(`memory projection-debt settlement failed — settled rows stay on the ledger (harmless: every consumption re-validates a row against the committed state before binding): ${err instanceof Error ? err.message : String(err)}`);
2112
+ }
1953
2113
  }
1954
2114
  try {
1955
2115
  clearScanFuse(this.controlDir, pendingProjections.filter((p) => appliedIds.has(p.entry.id)).map((p) => p.path));
@@ -2074,7 +2234,7 @@ export class MemoryEngine {
2074
2234
  if (committedOrigin !== undefined)
2075
2235
  fm.origin = committedOrigin;
2076
2236
  else if (opts.valve)
2077
- fm.origin = { taint: "external", cause: "static", at: this.now() };
2237
+ fm.origin = { taint: "external", cause: "static", at: row.capturedAt };
2078
2238
  }
2079
2239
  if (fm.name === undefined)
2080
2240
  fm.name = row.slug.split("/").pop();
@@ -2140,7 +2300,9 @@ export class MemoryEngine {
2140
2300
  report.warnings.push(`released hold ${row.relPath}: lineage settlement failed after commit — the entry stays latched until a later harvest reconciles: ${err instanceof Error ? err.message : String(err)}`);
2141
2301
  }
2142
2302
  markHoldReleased(this.controlDir, { holdId: row.holdId, now: this.now });
2143
- const abs = join(this.memoryDir, row.relPath);
2303
+ const landedSlug = patchReport.applied.find((a) => a.id === patch.id)?.slug ?? row.slug;
2304
+ const landedRelPath = landedSlug === row.slug ? row.relPath : join(dirname(row.relPath), `${landedSlug.split("/").pop()}.md`);
2305
+ const abs = join(this.memoryDir, landedRelPath);
2144
2306
  if (existsSync(abs)) {
2145
2307
  const canonical = canonicalize(abs);
2146
2308
  const text = readSafe(canonical);
@@ -2149,7 +2311,7 @@ export class MemoryEngine {
2149
2311
  handle.baseRevs.set(canonical, computeEntryRev({ id: patch.id, frontmatter: parseEntryFile(text).frontmatter, body: parseEntryFile(text).body }));
2150
2312
  handle.fileToScope.set(canonical, row.scope);
2151
2313
  if (!handle.materialized.some((m) => m.path === canonical)) {
2152
- handle.materialized.push({ path: canonical, relPath: row.relPath, scope: row.scope, id: patch.id, slug: row.slug, rev: handle.baseRevs.get(canonical), stub: false, readonly: false });
2314
+ handle.materialized.push({ path: canonical, relPath: landedRelPath, scope: row.scope, id: patch.id, slug: landedSlug, rev: handle.baseRevs.get(canonical), stub: false, readonly: false });
2153
2315
  }
2154
2316
  }
2155
2317
  }
@@ -2576,20 +2738,28 @@ export class MemoryEngine {
2576
2738
  return text;
2577
2739
  }
2578
2740
  async committedContentFor(id) {
2741
+ return (await this.committedContentOrFault(id)).content;
2742
+ }
2743
+ async committedContentOrFault(id) {
2744
+ let fault = false;
2579
2745
  const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
2580
2746
  if (!zeroCopy) {
2581
2747
  try {
2582
2748
  const [entry] = await this.backend.getByIds([id]);
2583
2749
  if (entry !== undefined && entry.id === id)
2584
- return serializeEntryFile(entry);
2750
+ return { content: serializeEntryFile(entry), fault: false };
2585
2751
  }
2586
2752
  catch {
2753
+ fault = true;
2587
2754
  }
2588
2755
  }
2589
2756
  const viaBackend = this.backend.readCommittedShadow?.(id);
2590
2757
  if (viaBackend !== undefined)
2591
- return viaBackend;
2592
- return readSafe(join(this.controlDir, "shadow", `${id}.md`));
2758
+ return { content: viaBackend, fault: false };
2759
+ const viaShadow = readSafe(join(this.controlDir, "shadow", `${id}.md`));
2760
+ if (viaShadow !== undefined)
2761
+ return { content: viaShadow, fault: false };
2762
+ return { fault };
2593
2763
  }
2594
2764
  async committedFrontmatterFor(id) {
2595
2765
  const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
@@ -2682,14 +2852,45 @@ export class MemoryEngine {
2682
2852
  }
2683
2853
  writeBackProjection(path, entry, needed) {
2684
2854
  if (!needed)
2685
- return;
2855
+ return true;
2686
2856
  const text = serializeEntryFile(entry);
2687
2857
  if (readNoFollowSafe(path) === text)
2688
- return;
2858
+ return true;
2689
2859
  try {
2690
2860
  writeFileNoFollow(path, text);
2861
+ return true;
2862
+ }
2863
+ catch {
2864
+ return false;
2865
+ }
2866
+ }
2867
+ async repairProjectionSeat(rel, canonical, entryId, debtClears) {
2868
+ const committed = await this.committedContentFor(entryId);
2869
+ if (committed === undefined)
2870
+ return;
2871
+ if (readNoFollowSafe(canonical) !== committed) {
2872
+ try {
2873
+ writeFileNoFollow(canonical, committed);
2874
+ }
2875
+ catch {
2876
+ return;
2877
+ }
2878
+ }
2879
+ debtClears.push({ relPath: rel, entryId });
2880
+ }
2881
+ async debtCommittedProjection(entryId) {
2882
+ const zeroCopy = this.backendPinnedRoot !== undefined && canonicalize(this.backendPinnedRoot) === canonicalize(this.memoryDir);
2883
+ if (zeroCopy)
2884
+ return { state: "stale" };
2885
+ const face = this.backend.retrievalView?.() ?? this.backend;
2886
+ try {
2887
+ const [entry] = await face.getByIds([entryId]);
2888
+ if (entry !== undefined && entry.id === entryId)
2889
+ return { state: "valid", scope: entry.scope, slug: entry.slug };
2890
+ return { state: "stale" };
2691
2891
  }
2692
2892
  catch {
2893
+ return { state: "unknown" };
2693
2894
  }
2694
2895
  }
2695
2896
  siblingScopeDirNames(dir, scope) {
@@ -721,6 +721,49 @@ export declare function recordChallengedHistory(controlDir: string, rows: Readon
721
721
  }>, now: () => number): void;
722
722
  /** Journal-aware read (observability/tests only — no engine consumer exists, on purpose). */
723
723
  export declare function readChallengedHistory(controlDir: string): Record<string, ChallengedHistoryRow>;
724
+ export declare const PROJECTION_DEBTS_FILE = "projection-debts.json";
725
+ /** One standing debt: the plane file at `relPath` (memory-dir-relative, canonical base) belongs to
726
+ * committed entry `entryId`, whose id write-back has not landed; `rev` is the rev the projection
727
+ * was staged against (the CAS baseline a reconciling harvest hands its update). */
728
+ export interface ProjectionDebtRow {
729
+ relPath: string;
730
+ entryId: string;
731
+ rev: string;
732
+ at: number;
733
+ }
734
+ /** WRITE-AHEAD staging. Upsert discipline (adversarial round 2): a stage lands only when the seat
735
+ * has NO standing row, the standing row is the stager's OWN entry (a rev refresh), or the
736
+ * standing row is the entry the stager itself just judged stale (`replaces` — the same-harvest
737
+ * stale-then-remint lane). It never blindly replaces ANOTHER writer's protection: a lagging
738
+ * process that validated an old row before pausing must not overwrite the row a faster sibling
739
+ * staged at the same path (its own commit then CAS-conflicts and its tuple-keyed clear misses
740
+ * the survivor — the account converges instead of emptying). Called before the backend
741
+ * transaction; the caller degrades LOUDLY (report warning) on a refused stage rather than
742
+ * refusing the harvest — the ledger is duplicate-admission protection, not the commit's
743
+ * integrity. */
744
+ export declare function stageProjectionDebts(controlDir: string, rows: ReadonlyArray<{
745
+ relPath: string;
746
+ entryId: string;
747
+ rev: string;
748
+ replaces?: string;
749
+ }>, now: () => number): {
750
+ refused: Array<{
751
+ relPath: string;
752
+ entryId: string;
753
+ }>;
754
+ };
755
+ /** Settle (remove) rows by ROW IDENTITY — (relPath, entryId), never the bare path: the write-back
756
+ * landed, the staged claim turned out stale, or a materialize re-projected the seat. Identity
757
+ * matters (adversarial review F1): a stale-clear judged against an OLD row must not delete the
758
+ * NEWER row a later staging upserted at the same path (same harvest: stale X cleared while fresh
759
+ * Y's write-back failed — a path-keyed drop would erase Y and re-open the duplicate window; same
760
+ * shape across processes for a lagging sibling's clear). Missing rows are a no-op (idempotent). */
761
+ export declare function settleProjectionDebts(controlDir: string, rows: ReadonlyArray<{
762
+ relPath: string;
763
+ entryId: string;
764
+ }>): void;
765
+ /** Strict read (ENOENT ⇒ empty; corrupt ⇒ throws — the caller's fail-closed arm owns the refusal). */
766
+ export declare function readProjectionDebts(controlDir: string): ProjectionDebtRow[];
724
767
  /**
725
768
  * REF-C6 — write EVERY byte of `data` to `fd`, looping until the OS has taken all of them.
726
769
  *