@sema-agent/core 5.49.0 → 5.50.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.
@@ -10,28 +10,52 @@ const RANK = {
10
10
  export function isThinkingLevel(v) {
11
11
  return typeof v === "string" && Object.prototype.hasOwnProperty.call(RANK, v);
12
12
  }
13
+ export function reasoningRequestCarried(model, reasoning) {
14
+ return !!model.reasoning && isThinkingLevel(reasoning) && reasoning !== "off";
15
+ }
13
16
  export function rankOf(level) {
14
17
  return RANK[level];
15
18
  }
16
19
  export const DEFAULT_EFFORT_LEVELS = ["minimal", "low", "medium", "high"];
17
20
  const BINARY_FORMATS = new Set(["qwen", "zai", "qwen-chat-template"]);
18
21
  export const RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses", "openai-chatgpt-responses"]);
19
- export function resolveReasoning(requested, model) {
20
- const resolved = dispatchReasoning(requested, model);
21
- if (requested !== "off" && !model.reasoning) {
22
+ export const MIN_THINKING_TOKENS = 1024;
23
+ export function budgetCapSkipsThinking(outputCapTokens, hardCap) {
24
+ return hardCap && outputCapTokens < MIN_THINKING_TOKENS * 2;
25
+ }
26
+ export function declaredEffortLevels(v) {
27
+ return Array.isArray(v) && v.length > 0 ? v : undefined;
28
+ }
29
+ export function mintEffortWireValue(requested, model, allowed) {
30
+ const resolution = resolveEffort(requested, allowed ?? DEFAULT_EFFORT_LEVELS);
31
+ let wireValue = resolution.effective;
32
+ const mapped = model.thinkingLevelMap?.[resolution.effective];
33
+ if (mapped === null)
34
+ wireValue = undefined;
35
+ else if (mapped !== undefined)
36
+ wireValue = mapped;
37
+ return { resolution, wireValue };
38
+ }
39
+ export function resolveReasoning(requested, model, facts) {
40
+ const resolved = dispatchReasoning(requested, model, facts);
41
+ if (requested !== "off" && !reasoningRequestCarried(model, requested)) {
22
42
  return { requested, effective: "off", graded: false, clamped: true, format: resolved.format, endpoint: resolved.endpoint, dropped: true };
23
43
  }
24
44
  return resolved;
25
45
  }
26
- function effortTierUnmapped(requested, effective, model) {
27
- return requested !== "off" && model.thinkingLevelMap?.[effective] === null;
28
- }
29
- function dispatchReasoning(requested, model) {
46
+ function dispatchReasoning(requested, model, facts) {
30
47
  const endpoint = model.api ?? "unknown";
31
48
  const compat = (model.compat ?? {});
32
49
  if (model.api === "anthropic-messages") {
33
- if (compat.effortLevels && compat.effortLevels.length > 0) {
34
- return { ...resolveEffort(requested, compat.effortLevels), format: "effort", endpoint };
50
+ const declaredAnthropic = declaredEffortLevels(compat.effortLevels);
51
+ if (declaredAnthropic !== undefined) {
52
+ return { ...resolveEffort(requested, declaredAnthropic), format: "effort", endpoint };
53
+ }
54
+ if (requested !== "off" &&
55
+ compat.thinkingMode !== "adaptive" &&
56
+ facts !== undefined &&
57
+ budgetCapSkipsThinking(facts.outputCapTokens, facts.hardOutputCap)) {
58
+ return { requested, effective: "off", graded: false, clamped: true, format: "budget", endpoint, dropped: true };
35
59
  }
36
60
  return { requested, effective: requested, graded: true, clamped: false, format: "budget", endpoint };
37
61
  }
@@ -39,11 +63,11 @@ function dispatchReasoning(requested, model) {
39
63
  if (compat.supportsReasoningEffort === false) {
40
64
  return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
41
65
  }
42
- const responsesResolved = resolveEffort(requested, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS);
43
- if (effortTierUnmapped(requested, responsesResolved.effective, model)) {
66
+ const responsesMint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
67
+ if (requested !== "off" && responsesMint.wireValue === undefined) {
44
68
  return { requested, effective: requested, graded: false, clamped: false, format: "responses", endpoint };
45
69
  }
46
- return { ...responsesResolved, format: "responses", endpoint };
70
+ return { ...responsesMint.resolution, format: "responses", endpoint };
47
71
  }
48
72
  const format = compat.thinkingFormat ?? "openai";
49
73
  if (BINARY_FORMATS.has(format)) {
@@ -53,11 +77,11 @@ function dispatchReasoning(requested, model) {
53
77
  if (!supportsEffort && format !== "openrouter") {
54
78
  return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
55
79
  }
56
- const resolved = resolveEffort(requested, compat.reasoningEffortLevels ?? DEFAULT_EFFORT_LEVELS);
57
- if (effortTierUnmapped(requested, resolved.effective, model)) {
80
+ const mint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
81
+ if (requested !== "off" && mint.wireValue === undefined) {
58
82
  return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
59
83
  }
60
- return { ...resolved, format, endpoint };
84
+ return { ...mint.resolution, format, endpoint };
61
85
  }
62
86
  export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
63
87
  const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
@@ -23,7 +23,7 @@ export declare function reservedFor(api: string): ReadonlySet<string>;
23
23
  */
24
24
  export declare function applyExtraBody(body: Record<string, unknown>, extraBody: Record<string, unknown> | undefined, reserved: ReadonlySet<string>): Record<string, unknown>;
25
25
  /**
26
- * [1282] per-call auth REPLACES construction-time auth: drop every auth-bearing header
26
+ * Per-call auth REPLACES construction-time auth: drop every auth-bearing header
27
27
  * (case-insensitive `authorization` / `x-api-key`) from an already-merged header bag. Called by a
28
28
  * brain's buildRequest ONLY when a per-call `options.apiKey` is present — the brain then re-emits
29
29
  * the credential in its own wire posture (anthropic `x-api-key`, openai `Bearer`), making the
@@ -33,6 +33,42 @@ export declare function applyExtraBody(body: Record<string, unknown>, extraBody:
33
33
  * folds duplicates into one comma-joined value — broken auth both ways).
34
34
  */
35
35
  export declare function stripAuthHeaders(headers: Record<string, string>): void;
36
+ /**
37
+ * #343 — the shared USER-HEADER merge layer (`model.headers` → construction `config.headers` →
38
+ * per-call `options.headers`, later bag wins), CASE-FOLD deduplicated: HTTP header field names are
39
+ * case-insensitive (RFC 9110), but the plain-object spread the three brains used
40
+ * (`{...model.headers, ...config.headers, ...options.headers}`) keyed by exact spelling — a
41
+ * `X-Tenant` in one bag and `x-tenant` in another BOTH survived and both went on the wire, where
42
+ * fetch's Headers folds them into one comma-joined value ("a, b"): neither writer's value, and the
43
+ * later layer's documented override silently defeated. Now a later bag's entry replaces an earlier
44
+ * case-variant; the WINNER'S spelling and value survive (a single-spelling config — every existing
45
+ * deployment — is byte-identical on the wire).
46
+ *
47
+ * EXEMPT: the auth carriers (`authorization` / `x-api-key`, any case) pass through with the exact
48
+ * legacy spread semantics (same-spelling override only, no case-fold dedup) — their case handling is
49
+ * {@link stripAuthHeaders}' pinned jurisdiction (the per-call-replaces flow and the
50
+ * header-only ANTHROPIC_AUTH_TOKEN shape, which must survive under its own capital-A spelling), and
51
+ * this layer must not become a second, subtly different auth authority.
52
+ */
53
+ /**
54
+ * #343 (review r4) — assign a STRUCTURAL locked header under its canonical lowercase name, deleting
55
+ * every case-variant spelling first. The brains hard-lock `content-type` / `anthropic-version` AFTER
56
+ * the user-bag merge precisely so they "can NEVER be overridden" (council design/40) — but a valid
57
+ * user bag carrying `Content-Type: text/plain` survived BESIDE the lowercase lock, and the platform
58
+ * `Headers` fold turns the pair into `text/plain, application/json` on the wire: the lock decided
59
+ * nothing. Auth carriers are deliberately NOT routed through here (per-call replacement + the
60
+ * header-only boot flow are {@link stripAuthHeaders}' pinned jurisdiction).
61
+ */
62
+ export declare function lockHeader(headers: Record<string, string>, lowerName: string, value: string): void;
63
+ /**
64
+ * #343 (review r4) — case-fold READ-AND-CLAIM for an AUGMENTABLE structural header (`anthropic-beta`):
65
+ * returns the current value under whatever spelling the user bag carried and deletes that spelling,
66
+ * so the caller's canonical lowercase write REPLACES it instead of duplicating beside it (the
67
+ * read-modify-write used to key the read by the exact lowercase name and miss `Anthropic-Beta`,
68
+ * losing the user's betas from the merge AND double-sending the header).
69
+ */
70
+ export declare function takeHeaderCasefold(headers: Record<string, string>, lowerName: string): string | undefined;
71
+ export declare function mergeHeaders(...bags: Array<Record<string, string> | undefined>): Record<string, string>;
36
72
  /**
37
73
  * The output-cap key(s) each lane's wire form uses. A lane's set is exactly the keys THAT lane's
38
74
  * endpoint reads — a stray cap key belonging to another wire form is inert there and must not be
@@ -54,13 +54,51 @@ export function applyExtraBody(body, extraBody, reserved) {
54
54
  }
55
55
  return { ...passthrough, ...body };
56
56
  }
57
+ const AUTH_CARRIER_NAMES = new Set(["authorization", "x-api-key"]);
57
58
  export function stripAuthHeaders(headers) {
58
59
  for (const k of Object.keys(headers)) {
59
- const lower = k.toLowerCase();
60
- if (lower === "authorization" || lower === "x-api-key")
60
+ if (AUTH_CARRIER_NAMES.has(k.toLowerCase()))
61
61
  delete headers[k];
62
62
  }
63
63
  }
64
+ export function lockHeader(headers, lowerName, value) {
65
+ for (const k of Object.keys(headers)) {
66
+ if (k !== lowerName && k.toLowerCase() === lowerName)
67
+ delete headers[k];
68
+ }
69
+ headers[lowerName] = value;
70
+ }
71
+ export function takeHeaderCasefold(headers, lowerName) {
72
+ for (const k of Object.keys(headers)) {
73
+ if (k.toLowerCase() === lowerName) {
74
+ const v = headers[k];
75
+ delete headers[k];
76
+ return v;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+ export function mergeHeaders(...bags) {
82
+ const out = {};
83
+ const spellingByFold = new Map();
84
+ for (const bag of bags) {
85
+ if (!bag)
86
+ continue;
87
+ for (const [name, value] of Object.entries(bag)) {
88
+ const fold = name.toLowerCase();
89
+ if (AUTH_CARRIER_NAMES.has(fold)) {
90
+ out[name] = value;
91
+ continue;
92
+ }
93
+ const prior = spellingByFold.get(fold);
94
+ if (prior !== undefined && prior !== name)
95
+ delete out[prior];
96
+ spellingByFold.set(fold, name);
97
+ out[name] = value;
98
+ }
99
+ }
100
+ return out;
101
+ }
64
102
  export const OUTPUT_CAP_KEYS = {
65
103
  openai: ["max_tokens", "max_completion_tokens"],
66
104
  anthropic: ["max_tokens"],
@@ -193,7 +193,10 @@ export interface McpRefreshResult {
193
193
  * DOMAIN for the swap (review F4: a prefix domain is self-healing and decoupled from the diff
194
194
  * baseline, which advances even when a consumer skips a swap). Present on every entry. */
195
195
  prefix: string;
196
- status: "refreshed" | "not_connected" | "failed";
196
+ /** `"revoked"` (design/338): the host ledger marks this server revoked — the refresh did NOT
197
+ * contact it (no tools/list round trip; the engine must not hand a severed server a request
198
+ * channel or splice its post-revocation text into the model catalog). */
199
+ status: "refreshed" | "not_connected" | "failed" | "revoked";
197
200
  toolCount: number;
198
201
  added: string[];
199
202
  removed: string[];
@@ -467,6 +470,9 @@ export declare function materializeMcpTools(specs: McpServerSpec[], principal?:
467
470
  reminderDisclosure?: {
468
471
  reminderMark?: string;
469
472
  counts?: ReminderDisclosureCounts;
473
+ }, mcpRevocations?: {
474
+ isRevoked(serverName: string): boolean;
475
+ onProbeFailure?: (error: unknown) => void;
470
476
  }): Promise<MaterializedMcp>;
471
477
  /**
472
478
  * Fold the caller's AUTHORITATIVE per-tool override (design F: caller = trust root) over the server-hint axis.
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;