@sema-agent/core 7.0.1 → 7.1.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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/agents/repair-loop.d.ts +8 -7
  3. package/dist/agents/roster-store.d.ts +7 -2
  4. package/dist/agents/subagent.js +29 -5
  5. package/dist/brain/errors.d.ts +18 -0
  6. package/dist/brain/errors.js +3 -0
  7. package/dist/brain/stream-engine.js +6 -4
  8. package/dist/core/context-edit.d.ts +3 -0
  9. package/dist/core/governance-codes.d.ts +1 -1
  10. package/dist/core/governance-codes.js +4 -0
  11. package/dist/core/hooks.d.ts +26 -6
  12. package/dist/core/hooks.js +8 -5
  13. package/dist/core/image-downsample.d.ts +4 -3
  14. package/dist/core/memory-engine/engine.d.ts +62 -5
  15. package/dist/core/memory-engine/engine.js +90 -19
  16. package/dist/core/memory-engine/index.d.ts +1 -1
  17. package/dist/core/memory-engine/layout.d.ts +11 -3
  18. package/dist/core/roles.d.ts +36 -9
  19. package/dist/core/roles.js +19 -6
  20. package/dist/core/runner/prepare-memory.js +29 -22
  21. package/dist/core/runner/prepare-task.d.ts +5 -3
  22. package/dist/core/runner/prepare-task.js +88 -47
  23. package/dist/core/runner/runtask.d.ts +12 -3
  24. package/dist/core/runner/runtask.js +32 -4
  25. package/dist/core/safety-axis-vocab.d.ts +1 -1
  26. package/dist/core/strategy-store.d.ts +4 -1
  27. package/dist/core/task-registry-shared.d.ts +7 -3
  28. package/dist/core/tool-errors.d.ts +1 -1
  29. package/dist/core/tool-policy.d.ts +40 -7
  30. package/dist/core/tool-policy.js +63 -9
  31. package/dist/core/types.d.ts +97 -11
  32. package/dist/engine/compaction/compaction.js +6 -2
  33. package/dist/engine/harness/agent-harness.d.ts +28 -6
  34. package/dist/engine/harness/agent-harness.js +34 -2
  35. package/dist/engine/harness/messages.js +4 -0
  36. package/dist/engine/harness/types.d.ts +37 -0
  37. package/dist/engine/harness/types.js +5 -0
  38. package/dist/engine/session/session.js +3 -2
  39. package/dist/index.d.ts +1 -1
  40. package/dist/internal/harness.d.ts +1 -0
  41. package/dist/internal/harness.js +1 -0
  42. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  43. package/dist/orchestration/run-workflow-tool.d.ts +9 -1
  44. package/dist/orchestration/run-workflow-tool.js +18 -8
  45. package/dist/orchestration/workflow-governance.js +1 -1
  46. package/dist/orchestration/workflow-types.d.ts +1 -0
  47. package/dist/orchestration/workflow.d.ts +9 -1
  48. package/dist/orchestration/workflow.js +5 -5
  49. package/dist/stores/file/mailbox-store.d.ts +2 -1
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +3 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.1.0 — 2026-09-01
4
+
5
+ ### BREAKING (timing) — design/384 slice 1 (#513): turn interrupts reach the ask waiting side
6
+ - **`resolveAsk` no longer waits for the approver to settle once the abort signal fires.** The onAsk await is raced against the composed `AbortSignal.any([run, call])`; the wait settles as the abort-family deny on its own. The prior guarantee "no return before onAsk settles" is REMOVED. Detached discipline: the still-pending approver promise is swallow-guarded; a late APPROVE becomes the `task.late_approval` engine notice; a late REJECT reaches `onError(phase:"hook")` — disclosed, never silent. This also closes the pre-existing run-abort deaf-approver exposure (a walltime/cancel end can no longer be held open by an approver ignoring its signal).
7
+ - **Signal thread (additive seats)**: the loop's turn-scoped signal rides the `tool_call` hook event as an optional `signal` seat; `ToolGateInput.callSignal` (optional) hands it to the three synchronous wait seats (`adjudicate`/`resolveAsk`/`resolveContentAsk`) as a new optional trailing argument — same-arity guarantee when absent (conditional invocation, no explicit trailing `undefined`).
8
+ - **`ENGINE_NOTICE_CODES` closed-set +2** (downstream full-set pins go red): `task.halt_unconsumed` (halt verb answered while the run's ending was already owned by its own abort — nothing cut, `haltedByUser` unsigned, this notice is the halt's only trace) and `task.late_approval` (the detached approver's late yes ran nothing; NON-CONSUMPTION semantics — the engine claims neither who decided nor which came first). Both audience `user`.
9
+ - **`task_aborted` widened by DEFINITION, zero closed-set additions**: the deny resolution now names the run's OR turn's abort signal; the deny message widens to `approval aborted for X (run or turn interrupted)` at all four family sites (probes pinning the old `(task ended)` bytes re-pinned).
10
+ - **Ledger narrowing (named)**: the humanReview gates ledger stops booking abort-family denies (`task_aborted` joins the `approverUnavailable` skip — interrupts are not denials; also covers the pre-existing run-abort booking distortion).
11
+ - **steer `priority:"now"` revokes a pending card and the run continues** (CC-aligned; same seat as `interruptTurn`, pinned e2e).
12
+
13
+ ### Added
14
+ - **Terminal API failure now persists in the transcript** (#506㋐, CC 2.1.250 re-anchor): the harness mints a SEPARATE assistant record (reserved `<synthetic>` model sentinel + `isApiErrorMessage` + one bounded `API Error:` text block ≤2000 chars, `apiErrorStatus`/`requestId` twins, honest `"error"` stop + `usageMissing` — deliberately NOT copying CC's lying `stop_sequence`/zero-usage fields). `convertToLlm` drops every such record from every request view (the invalid-sequence class stays closed), with the exclusion carried at four further seats: auto-mode classifier window, prompt-suggestions grounding tail, compaction cut points, session derived-model tracking. ABORT arm untouched.
15
+ - **`BrainStatus.errorStatus`** (#506㋑): the HTTP status that named a retry's failure now rides the status channel in `api_retry` context (single `namesTheFailure` predicate governs both this seat and `apiErrorStatus`; 0-sentinel and 2xx never publish). WIRE EXTENSION — server/cli consumption named in the ship post.
16
+
17
+ ### Fixed
18
+ - **#512 model-vocabulary batch** (six items): run_workflow card stops teaching a tier-word claim false on tiers-configured deployments (real two-part gate = allowlist ∩ catalog); `parseModelMention` JSDoc recipe corrected to the EXPANDED table; four prototype-chain reads own-gated (live leak: `resolveModelDisplayLabel` returned Object.prototype functions for "toString" onto `agent_start.model`; `__proto__` writes now `Object.defineProperty` own data properties — a codex-caught setter regression); `suggestNextPrompts` bare catch now announces through `onError(phase:"suggestions")` on an explicit unresolvable `role`; roster/durable-registry `model` re-documented as the RECORD KEY; `agent_end.modelResolved` added to the WorkflowEvent union (additive — emitter always wrote it).
19
+ - **`scripts/verify-debts.mjs` two-evidence gate told the truth** (ledger-audit A-03): CLOSED rows now require two DISTINCT addressable coordinates; the old gate passed on a single commit OR post number.
20
+
21
+ ### Docs
22
+ - Self-scan doc-rot batch (33 findings triaged, 13 confirmed + extensions): every rotted `file.ts:NNN` cross-reference in src re-spelled by symbol (line-anchor ratchet banks the cleanup); six orphaned JSDoc blocks reattached to their symbols (incl. the public `Runner` class and `expandTiers`); the stale `agent-harness` abort-clears-queues contract sentence corrected to the #389 truth; release-docs gate register drift fixed in seven places; REVIEW-BACKLOG stale status cells refreshed against the tree.
23
+
24
+
25
+ ## 7.0.2 — 2026-08-30
26
+
27
+ ### Added
28
+ - **`MemoryEngine.sessionMemoryStatus(sessionId)`** (#511①, design/383 §S-7 — the signed-design read face missed at build): `{captureOptedOut?, committedCount?, foldedCount?, optOutSource?, lastCaptureAt?}`. Every key optional — absence means that fact's source was unreadable, never a coined false/0 stand-in; a store fault answers `optOutSource:"fault"` with `captureOptedOut` ABSENT (the indeterminate axis projected); `foldedCount` counts evidence-only input rows over the full product blocks; the face never throws. New exported type `SessionMemoryStatus` (export surface 1841→1842). Server projects the wire endpoint as a pure derivation ([5786]).
29
+ - **`SessionCaptureRecordStore` dual form** (#511②): the three legs widen to `T | Promise<T>` — a SQL/async backend plugs in with Promise signatures while every existing sync implementation compiles untouched (the additive mechanical proof). The dual form propagates: sync deployments stay sync-transparent end to end; the sync-facing public seats (`MemoryEngineSession.captureOptOut.*`, `ToolExecuteContext.memoryCaptureOptedOut/…Indeterminate`, `parentMemoryCaptureState`) go dual with the await law documented — per-leg failure mapping preserved (mark rejection ⇒ unpersisted, read rejection ⇒ the indeterminate fault axis).
30
+ - **`best` model alias** (#511③, CC 250 $L parity): with `RunnerDeps.tiers` configured, `"best"` resolves to the first DIRECTLY bound tier walking `ultra→max→pro→flash→lite` from the head ("best available", deliberately not a fixed alias-table row). Explicit spellings win over the dynamic walk: a deployment catalog key named `best`, or an explicit `tiers.best` binding. No tiers ⇒ the word stays an unknown ref (refused, never coined).
31
+
32
+ ### Changed — narrowing (named)
33
+ - **Agent tool `model` argument: non-string values on the schema-外 direct-call lane refuse typed** (#511④, test [5781] codex observation): a number/array/boolean/garbage-object now refuses `model.invalid` (closed-set ADDITION beside `model.empty`/`model.unknown` — downstream pinning the details.error set gains a row) instead of crashing or silently no-oping; a legal Model-shape object (string id+api) passes through as the per-call model; strings keep every existing arm byte-identical.
34
+ - **`modelNote` fork-ignored and roster-less arms defuse the model-authored word** (#511⑤, census P2): both arms now run the same `inlineUntrusted` fold (newline fold + fence/envelope-tag neutralization + 80 cap) the family's other three arms already ran — a hostile `model` word can no longer carry raw newlines or fence sentinels into a top-level report line. Clean short words render byte-identical.
35
+
3
36
  ## 7.0.1 — 2026-08-30
4
37
 
5
38
  ### Added
@@ -13,7 +13,7 @@ import { type OracleIsolationVerdict } from "../core/oracle-isolation.js";
13
13
  * `oracleIsolation` boundary that this slice deliberately does not build — SAFE-tier escalates to a human
14
14
  * (`needs_human_oracle`) or surfaces a candidate, but never clears its own work.
15
15
  *
16
- * Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts:18-21 posture)
16
+ * Like {@link verifyCompleted}, this is a **thin composition** over `runner.runTask` (verify.ts's DEPLOYMENT POSTURE section)
17
17
  * — it adds no Runner-core surface, touches no vendored code, and is called by a leader/profile (the sibling
18
18
  * of {@link runWithVerification}). The oracle internals (which gate/judge/property-harness to compose) live
19
19
  * in the PROFILE-injected {@link RepairOracle} closure; core only fixes the {@link OracleResult} shape and the
@@ -75,12 +75,13 @@ export interface OracleResult {
75
75
  }
76
76
  /**
77
77
  * The PROFILE-injected oracle (§1 裁决①). The profile composes the actual grading inside this closure —
78
- * `runExecGate(graderEnv, steps)` (exec-gate.ts:185, env is the FIRST param = L2 provenance bound to the
79
- * grader) → `verifyCompleted` (verify.ts:212, the read-only L3 judge) → `checkInvariants`
80
- * (property-harness.ts:217, explore-only) — and maps the composite to an {@link OracleResult}. Core never
78
+ * `runExecGate(graderEnv, steps)` (core/exec-gate.ts, env is the FIRST param = L2 provenance bound to the
79
+ * grader) → `verifyCompleted` (agents/verify.ts, the read-only L3 judge) → `checkInvariants`
80
+ * (core/property-harness.ts, explore-only) — and maps the composite to an {@link OracleResult}. Core never
81
81
  * composes these internals (so the oracle type can't grow a long discriminated union); it only fixes the
82
82
  * `OracleResult` shape and the read-only/identity contract. `graderEnv` is the isolated grader env; `evidence`
83
- * is the diff/results to judge (recompute it from the post-resume working tree on a resume — verify.ts:328 BUG5).
83
+ * is the diff/results to judge (recompute it from the post-resume working tree on a resume — threat BUG5, the
84
+ * same freshness duty verify.ts states on its own `evidence` config field).
84
85
  */
85
86
  export type RepairOracle = (graderEnv: ExecutionEnv, evidence: string | undefined) => Promise<OracleResult>;
86
87
  /**
@@ -166,12 +167,12 @@ export interface RepairLoopConfig {
166
167
  */
167
168
  resumeBundle?: RepairBundle;
168
169
  /**
169
- * Stop the loop once cumulative cost reaches this (verify.ts:278 backstop). HRD-AGO-6 — "cumulative" is
170
+ * Stop the loop once cumulative cost reaches this (verify.ts's `costCeilingMicroUsd` backstop). HRD-AGO-6 — "cumulative" is
170
171
  * every leg the loop pays for: each attempt's own + nested `runTask` cost PLUS each verdict's
171
172
  * {@link OracleResult.costMicroUsd} (an LLM-judge oracle is charged per attempt too).
172
173
  */
173
174
  costCeilingMicroUsd?: number;
174
- /** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts:277 backstop). F1: both ceilings
175
+ /** Overall ACTIVE wall-clock ceiling for the whole loop (verify.ts's `totalTimeoutMs` backstop). F1: both ceilings
175
176
  * are CROSS-RESUME — the carried {@link RepairBundle.spentMicroUsd}/{@link RepairBundle.activeElapsedMs}
176
177
  * re-seed the accounts, so a durable resume continues the budget rather than restarting it. */
177
178
  totalTimeoutMs?: number;
@@ -12,8 +12,13 @@ export interface RosterEntry {
12
12
  * key; equals the spawner's session at depth 1). Stored verbatim; no predicate arm consumes it
13
13
  * yet (enumeration/recovery is the reader). */
14
14
  rootSessionId?: string;
15
- /** design/151 S3b — the RESOLVED model id at spawn (revival lookup key; display/model-routing
16
- * hints only, never a serialized spec). */
15
+ /** design/151 S3b — the model RECORD KEY at spawn (revival lookup key; display/model-routing hints
16
+ * only, never a serialized spec). NOT the resolved model id: a spawn that named a model in WORDS
17
+ * records the caller's spelling VERBATIM (a catalog key, tier word or CC alias), because a revival
18
+ * re-resolves this value against the catalog in force at WAKE time and a resolved id would only
19
+ * ever resolve again on deployments whose catalog keys happen to equal model ids. A spawn that
20
+ * carried a Model OBJECT (definition/tool-level/inherited ref) records that object's id, which on
21
+ * such a deployment is not a catalog key and degrades softly on the revival lane. */
17
22
  model?: string;
18
23
  /** Ruled 2026-08-05: the spawn requested a model word that did not bind — the row runs on its
19
24
  * inherited default. Closed set, single member today; absent = bound normally (or no word). */
@@ -1590,6 +1590,19 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1590
1590
  };
1591
1591
  }
1592
1592
  }
1593
+ const modelObjectForm = typeof a.model === "object" &&
1594
+ a.model !== null &&
1595
+ !Array.isArray(a.model) &&
1596
+ typeof a.model.id === "string" &&
1597
+ typeof a.model.api === "string";
1598
+ if (a.model !== undefined && typeof a.model !== "string" && !modelObjectForm) {
1599
+ const typeDesc = Array.isArray(a.model) ? "an array" : a.model === null ? "null" : typeof a.model === "object" ? "an object" : `a ${typeof a.model}`;
1600
+ return {
1601
+ isError: true,
1602
+ content: `Sub-agent not started: \`model\` must be a model name (string) from this deployment's catalog — got ${typeDesc}. Omit the parameter to run the agent type's own model.`,
1603
+ details: { error: "model.invalid" },
1604
+ };
1605
+ }
1593
1606
  if (typeof a.model === "string" && a.model.trim() === "" && !wantsFork && reviveClaim === undefined) {
1594
1607
  return {
1595
1608
  isError: true,
@@ -1602,6 +1615,14 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1602
1615
  let modelNote;
1603
1616
  const judgedModels = activeModels();
1604
1617
  const rosterKeys = judgedModels !== undefined ? Object.keys(judgedModels) : [];
1618
+ if (modelObjectForm) {
1619
+ if (wantsFork) {
1620
+ modelNote = `note: model "${inlineUntrusted(a.model.id, 80)}" was ignored — a fork always runs on the caller's model.`;
1621
+ }
1622
+ else {
1623
+ perCallModel = a.model;
1624
+ }
1625
+ }
1605
1626
  if (typeof a.model === "string" && requestedModel === undefined) {
1606
1627
  modelNote = wantsFork
1607
1628
  ? `note: model "" was ignored — a fork always runs on the caller's model.`
@@ -1609,10 +1630,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1609
1630
  }
1610
1631
  else if (requestedModel !== undefined) {
1611
1632
  if (wantsFork) {
1612
- modelNote = `note: model "${requestedModel}" was ignored — a fork always runs on the caller's model.`;
1633
+ modelNote = `note: model "${inlineUntrusted(requestedModel, 80)}" was ignored — a fork always runs on the caller's model.`;
1613
1634
  }
1614
1635
  else if (rosterKeys.length === 0) {
1615
- modelNote = `note: model "${requestedModel}" was NOT applied — this delegation tool has no model roster configured; the sub-agent ran on its default (inherited) model.`;
1636
+ modelNote = `note: model "${inlineUntrusted(requestedModel, 80)}" was NOT applied — this delegation tool has no model roster configured; the sub-agent ran on its default (inherited) model.`;
1616
1637
  }
1617
1638
  else {
1618
1639
  try {
@@ -1783,7 +1804,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1783
1804
  }
1784
1805
  })()
1785
1806
  : childModel?.id;
1786
- const rowModelKey = perCallModel !== undefined ? requestedModel : typeof childModel === "string" ? childModel : childModel?.id;
1807
+ const rowModelKey = perCallModel !== undefined ? requestedModel ?? perCallModel.id : typeof childModel === "string" ? childModel : childModel?.id;
1787
1808
  const childSystemPrompt = def?.systemPrompt ?? opts.systemPrompt;
1788
1809
  const childDefaultPersona = childSystemPrompt === undefined && !wantsFork ? SUBAGENT_PROMPT : undefined;
1789
1810
  const defMaxTurns = typeof def?.maxTurns === "number" && Number.isFinite(def.maxTurns) && def.maxTurns > 0 ? def.maxTurns : undefined;
@@ -2027,6 +2048,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2027
2048
  const provenanceRequest = ctx.delegationProvenanceForChildren?.();
2028
2049
  const childProvenanceRef = provenanceRequest !== undefined ? { current: newDelegationProvenanceAggregate() } : undefined;
2029
2050
  const childAttestation = (status) => childProvenanceRef !== undefined ? reduceDelegationAttestation(childProvenanceRef.current, { completed: status === "completed" }) : undefined;
2051
+ const liveCaptureFloor = reviveClaim === undefined
2052
+ ? { optedOut: (await ctx.memoryCaptureOptedOut) === true, indeterminate: (await ctx.memoryCaptureIndeterminate) === true }
2053
+ : undefined;
2030
2054
  const childInternals = {
2031
2055
  ...(inheritedManifestScope ? { inheritedManifestScope } : {}),
2032
2056
  ...(childProvenanceRef !== undefined && provenanceRequest !== undefined ? { delegationProvenance: { ref: childProvenanceRef, contentSafety: provenanceRequest } } : {}),
@@ -2045,8 +2069,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2045
2069
  ...(ctx.interactionPosture !== undefined ? { parentInteractionPosture: ctx.interactionPosture } : {}),
2046
2070
  ...(reviveClaim === undefined
2047
2071
  ? {
2048
- ...(ctx.memoryCaptureOptedOut === true ? { memoryCaptureFloor: true } : {}),
2049
- ...(ctx.memoryCaptureOptedOut !== true && ctx.memoryCaptureIndeterminate === true ? { memoryCaptureFloorIndeterminate: true } : {}),
2072
+ ...(liveCaptureFloor?.optedOut === true ? { memoryCaptureFloor: true } : {}),
2073
+ ...(liveCaptureFloor?.optedOut !== true && liveCaptureFloor?.indeterminate === true ? { memoryCaptureFloorIndeterminate: true } : {}),
2050
2074
  ...(ctx.memoryCaptureControlDir !== undefined ? { memoryCaptureQueryDir: ctx.memoryCaptureControlDir } : {}),
2051
2075
  ...(ctx.memoryCaptureAncestors !== undefined ? { memoryCaptureAncestors: ctx.memoryCaptureAncestors } : {}),
2052
2076
  }
@@ -82,6 +82,24 @@ export declare function readProviderRequestId(res: {
82
82
  get?: (name: string) => string | null;
83
83
  };
84
84
  } | undefined): string | undefined;
85
+ /**
86
+ * Does this HTTP status NAME the failure it is being attached to? The one predicate governing every
87
+ * seat on which this engine publishes a provider status — the terminal assistant frame's
88
+ * `apiErrorStatus` and the retry-wait status frame's `errorStatus` (#506 ㋑). One helper on purpose:
89
+ * both seats promise the same three-state discipline, and two copies of the rule would drift.
90
+ *
91
+ * Two distinct shapes are refused, and both are reachable:
92
+ * · The engine's "no status" SENTINEL, 0 — the terminal HTTP throw carries `r?.status ?? 0` when a
93
+ * transport hands back neither a response nor a throw. Publishing it would be absence rendered as a
94
+ * number a consumer may format, which is worse than silence.
95
+ * · A SUCCESS status. The connect loop only streams a response that is `ok` AND has a body, so an
96
+ * `ok` response with a NULL body (an empty 200 from a proxy, a 204) reaches the terminal throw
97
+ * carrying 200 — and a provider's own `x-should-retry` verdict can send that same 2xx into a RETRY
98
+ * wait. The boundary really did fail, but the status is not what failed, and a consumer routing or
99
+ * rendering by a 2xx is exactly the misreading these seats exist to prevent.
100
+ * Stalls, connect failures and in-band error frames inside a 200 leave the seat absent by the same rule.
101
+ */
102
+ export declare function namesTheFailure(status: number | undefined): status is number;
85
103
  /** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */
86
104
  export declare function classifyHttp(status: number): BrainErrorCode;
87
105
  /** Lift the machine-readable code back out of a `[code] …` prefixed `errorMessage` (the single shared
@@ -45,6 +45,9 @@ export function readProviderRequestId(res) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
+ export function namesTheFailure(status) {
49
+ return status !== undefined && status >= 100 && !(status >= 200 && status < 300);
50
+ }
48
51
  export function classifyHttp(status) {
49
52
  if (status === 401 || status === 403)
50
53
  return "auth";
@@ -1,6 +1,6 @@
1
1
  import { createAssistantMessageEventStream, } from "../internal/llm.js";
2
2
  import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
3
- import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, readProviderRequestId } from "./errors.js";
3
+ import { BrainError, classifyConnectFailure, classifyHttp, describeNetworkError, namesTheFailure, readProviderRequestId } from "./errors.js";
4
4
  import { classifyInputTooLong } from "./input-too-long.js";
5
5
  import { FAST_MAX_BACKOFF_MS, providerWaitHint, retryBackoffMs } from "./retry.js";
6
6
  import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
@@ -267,9 +267,8 @@ export function runStreamingBrain(args) {
267
267
  errorMsg.inputTooLongRuledOut = true;
268
268
  if (err.apiError === true) {
269
269
  errorMsg.isApiErrorMessage = true;
270
- const st = err.status;
271
- if (st !== undefined && st >= 100 && !(st >= 200 && st < 300))
272
- errorMsg.apiErrorStatus = st;
270
+ if (namesTheFailure(err.status))
271
+ errorMsg.apiErrorStatus = err.status;
273
272
  if (requestIdSeen !== undefined)
274
273
  errorMsg.requestId = requestIdSeen;
275
274
  }
@@ -376,6 +375,7 @@ export function runStreamingBrain(args) {
376
375
  attempt: attempt + 1,
377
376
  maxRetries,
378
377
  errClass: "output_cap",
378
+ ...(namesTheFailure(r?.status) ? { errorStatus: r.status } : {}),
379
379
  });
380
380
  continue;
381
381
  }
@@ -428,6 +428,7 @@ export function runStreamingBrain(args) {
428
428
  discardResponseBody(r);
429
429
  cc.abort();
430
430
  cc.dispose();
431
+ const retryStatus = namesTheFailure(r?.status) ? r.status : undefined;
431
432
  await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
432
433
  phase: statusPhase,
433
434
  detail: statusPhase === "rate_limited"
@@ -440,6 +441,7 @@ export function runStreamingBrain(args) {
440
441
  attempt: attempt + 1,
441
442
  maxRetries: laneMaxRetries,
442
443
  errClass,
444
+ ...(retryStatus !== undefined ? { errorStatus: retryStatus } : {}),
443
445
  }));
444
446
  continue;
445
447
  }
@@ -257,6 +257,9 @@ export interface ContextEditOptions {
257
257
  * append-only sessions cannot be rewritten. Dropping the junk from the REQUEST VIEW restores a legal
258
258
  * transcript (TR3 rejoins its batch). Deterministic ⇒ byte-stable per request ⇒ prefix-cache safe.
259
259
  * New sessions never contain these (the loop exits cleanly on abort; the harness skips persisting them).
260
+ * #506 ㋐ did NOT re-open this class: the API-failure record the harness now writes carries `API Error: …`
261
+ * TEXT, so it is not an empty failure assistant and this heal correctly leaves it alone. That record is
262
+ * excluded one layer later, at `convertToLlm`, by its own three-key identity (`isSyntheticApiErrorMessage`).
260
263
  */
261
264
  export declare function dropEmptyFailureAssistants(messages: AgentMessage[]): AgentMessage[];
262
265
  /**
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -124,6 +124,8 @@ export const ENGINE_NOTICE_CODES = [
124
124
  "task.user_followup_undrained",
125
125
  "steering.parked_input_blocked",
126
126
  "task.turn_interrupted",
127
+ "task.halt_unconsumed",
128
+ "task.late_approval",
127
129
  "memory.capture_opted_out",
128
130
  "memory.capture_optout_unpersisted",
129
131
  "tool_result.offload_put_failed",
@@ -139,6 +141,8 @@ const NOTICE_AUDIENCE_TABLE = {
139
141
  "task.user_followup_undrained": "user",
140
142
  "task.turn_interrupted": "user",
141
143
  "steering.parked_input_blocked": "user",
144
+ "task.halt_unconsumed": "user",
145
+ "task.late_approval": "user",
142
146
  "memory.capture_opted_out": "user",
143
147
  "memory.capture_optout_unpersisted": "user",
144
148
  "memory.consolidation_withheld": "user",
@@ -1169,7 +1169,10 @@ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
1169
1169
  unreadable?: true;
1170
1170
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
1171
1171
  };
1172
- /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
1172
+ /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
1173
+ * signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
1174
+ * additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
1175
+ * interrupt releases a pending ask instead of leaving it hanging on the run's lifetime alone. */
1173
1176
  export interface ToolGateInput {
1174
1177
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
1175
1178
  * isolation scope (onHookError itself, the deny observer, the durable-park observer). The runner
@@ -1214,13 +1217,28 @@ export interface ToolGateInput {
1214
1217
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
1215
1218
  * passed through unchanged; omitted when the deployment wired no execution environment. */
1216
1219
  hookEnv?: HookEnvCapabilities;
1217
- /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow). */
1218
- adjudicate?: (req: ToolCallRequest) => Promise<PermissionResult>;
1220
+ /**
1221
+ * design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
1222
+ * own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
1223
+ * gate itself never awaits on it — it hands the signal verbatim to the three synchronous-wait
1224
+ * seats below (`adjudicate`, `resolveAsk`, `resolveContentAsk`) as their optional trailing
1225
+ * argument, and the Runner's closures compose it with the run signal so a turn interrupt (bare
1226
+ * halt / steer-now) releases the wait. Absent (a host driving the gate directly, or an unthreaded
1227
+ * emit) ⇒ every seat is invoked exactly as before — same arity, no trailing `undefined` (the
1228
+ * threading is a conditional invocation) — and waits stay bound to the run signal alone: the
1229
+ * historical shape, byte-identical.
1230
+ */
1231
+ callSignal?: AbortSignal;
1232
+ /** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow).
1233
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1234
+ * through verbatim; an implementation that ignores it keeps the run-level binding. */
1235
+ adjudicate?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<PermissionResult>;
1219
1236
  /** Resolve an `ask` to allow/deny via `onAsk` (abort-bound). Required iff a decision can be `ask`.
1220
1237
  * G1 three-value: the result may carry `approverUnavailable` — the live approver affirmed no
1221
1238
  * operator is reachable for THIS ask; the gate then re-routes onto the durable park leg (below),
1222
- * keeping the carried fail-closed deny when no park is armed. */
1223
- resolveAsk: (decision: PermissionResult, req: ToolCallRequest) => Promise<ResolvedAsk>;
1239
+ * keeping the carried fail-closed deny when no park is armed. The optional trailing `callSignal`
1240
+ * (design/384) is {@link ToolGateInput.callSignal}, passed through verbatim. */
1241
+ resolveAsk: (decision: PermissionResult, req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ResolvedAsk>;
1224
1242
  /**
1225
1243
  * design/45: route a policy `ask` to a **durable suspension** instead of the synchronous `resolveAsk`
1226
1244
  * (onAsk) path. Called in the `ask` branch with the FINAL post-hook args; if it returns a suspend
@@ -1309,8 +1327,10 @@ export interface ToolGateInput {
1309
1327
  * from "nobody home" — the two have opposite re-routing rules). Passed UNCONDITIONALLY by the runner,
1310
1328
  * including on runs with no live face: it then answers `unavailable` without contacting anything, which
1311
1329
  * is exactly the pre-174 park/refusal behavior. Absent entirely ⇒ the pre-174 path verbatim.
1330
+ * The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
1331
+ * through verbatim; an implementation that ignores it keeps the run-level binding.
1312
1332
  */
1313
- resolveContentAsk?: (req: ToolCallRequest) => Promise<ContentAskOutcome>;
1333
+ resolveContentAsk?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ContentAskOutcome>;
1314
1334
  /**
1315
1335
  * design/70: the called tool is egress-marked (`ToolSpec.egress` — an external write: open a PR,
1316
1336
  * push, send). The gate tightens a surviving `allow` to `ask` so an egress tool is NEVER
@@ -426,6 +426,7 @@ export function persistedRuleMandateOf(marks) {
426
426
  export async function runToolGate(input) {
427
427
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
428
428
  const { toolCallId, toolName } = event;
429
+ const callSignal = input.callSignal;
429
430
  const hookCtx = (seatSignal) => ({
430
431
  toolCallId,
431
432
  toolName,
@@ -506,7 +507,7 @@ export async function runToolGate(input) {
506
507
  }
507
508
  }
508
509
  const req = { toolName, args: currentInput, toolCallId };
509
- let decision = adjudicate ? await adjudicate(req) : { action: "allow" };
510
+ let decision = adjudicate ? await (callSignal !== undefined ? adjudicate(req, callSignal) : adjudicate(req)) : { action: "allow" };
510
511
  let denySource = "policy";
511
512
  let policyRewrite;
512
513
  if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
@@ -867,7 +868,7 @@ export async function runToolGate(input) {
867
868
  }
868
869
  }
869
870
  if (decision.action === "ask" && req.toolName === ASK_USER_QUESTION_TOOL_NAME) {
870
- const outcome = input.resolveContentAsk !== undefined ? await input.resolveContentAsk(req) : { kind: "unavailable", parkDeclined: false };
871
+ const outcome = input.resolveContentAsk !== undefined ? await (callSignal !== undefined ? input.resolveContentAsk(req, callSignal) : input.resolveContentAsk(req)) : { kind: "unavailable", parkDeclined: false };
871
872
  if (outcome.kind === "answered") {
872
873
  decision = { action: "allow", updatedInput: outcome.presentedInput };
873
874
  }
@@ -902,7 +903,7 @@ export async function runToolGate(input) {
902
903
  }
903
904
  if (decision.action === "ask") {
904
905
  const askBeforeResolve = decision;
905
- const resolved = await resolveAsk(decision, req);
906
+ const resolved = await (callSignal !== undefined ? resolveAsk(decision, req, callSignal) : resolveAsk(decision, req));
906
907
  if (resolved.action !== "ask" && resolved.approver !== undefined)
907
908
  resolvedApprover = resolved.approver;
908
909
  if (resolved.action === "deny" && isAskDenyResolution(resolved.resolution))
@@ -978,7 +979,7 @@ export async function runToolGate(input) {
978
979
  let recheck = { action: "allow" };
979
980
  if (adjudicate) {
980
981
  try {
981
- recheck = await adjudicate({ toolName, args: editArgs, toolCallId });
982
+ recheck = await (callSignal !== undefined ? adjudicate({ toolName, args: editArgs, toolCallId }, callSignal) : adjudicate({ toolName, args: editArgs, toolCallId }));
982
983
  }
983
984
  catch (err) {
984
985
  recheck = {
@@ -1014,7 +1015,9 @@ export async function runToolGate(input) {
1014
1015
  editArgs = recheck.updatedInput;
1015
1016
  editRewrittenSinceHuman = true;
1016
1017
  }
1017
- const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
1018
+ const editAskDecision = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
1019
+ const editAskReq = { toolName, args: editArgs, toolCallId };
1020
+ const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
1018
1021
  resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
1019
1022
  if (rr.action !== "allow") {
1020
1023
  if (rr.action === "deny" && isAskDenyResolution(rr.resolution))
@@ -15,9 +15,10 @@
15
15
  export declare const MCP_IMAGE_MAX_BASE64: number;
16
16
  /**
17
17
  * 批③ image-pipeline — CC 2.1.x `constants/apiLimits.ts` values, shared by the MCP inline-image bound
18
- * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). See also
19
- * `tools/fs/index.ts:40-41` ("one limit, two consumers, no drift") — the other consumer of this same
20
- * yardstick, which is where this invariant was originally called out.
18
+ * AND the Read tool's image branch (tools/fs — internal consistency: ONE yardstick per limit). That other
19
+ * consumers are the file tools (`tools/fs/fs-read.ts`'s image branch, plus `fs-pdf`/`fs-bash`), which IMPORT
20
+ * {@link MCP_IMAGE_MAX_BASE64} / {@link IMAGE_TARGET_RAW_SIZE} from here (through core/mcp.js's re-export)
21
+ * rather than re-deriving them — one limit, every consumer reading it, no drift.
21
22
  * - `IMAGE_TARGET_RAW_SIZE` (apiLimits.ts:29): raw-byte target that guarantees the base64 encoding stays
22
23
  * under {@link MCP_IMAGE_MAX_BASE64} (raw × 4/3 = base64 → 3.75MB raw = 5MB base64).
23
24
  * - `IMAGE_MAX_WIDTH/HEIGHT` (apiLimits.ts:42-43): client-side resize box. The API internally resizes
@@ -796,6 +796,27 @@ export declare function memoryConsolidationRefusedNotice(input: {
796
796
  reason?: string;
797
797
  occurrenceId?: string;
798
798
  }): EngineNotice;
799
+ /**
800
+ * design/383 §S-7 (#511 件1) — {@link MemoryEngine.sessionMemoryStatus}'s answer. Every key is
801
+ * optional; ABSENCE means the fact's source could not be read (a fault never coins a `false`/`0`
802
+ * stand-in) — see the method doc for each key's exact absence law.
803
+ */
804
+ export interface SessionMemoryStatus {
805
+ /** TRUE = a standing capture opt-out record; FALSE = store readable, no record (capture on).
806
+ * ABSENT = the record store faulted — indeterminate (`optOutSource: "fault"` accompanies). */
807
+ captureOptedOut?: boolean;
808
+ /** Committed entries carrying this session's lineage contribution. Absent = ledger unreadable. */
809
+ committedCount?: number;
810
+ /** Of those, entries already folded into consolidation products (lineage × `distilled.inputs`).
811
+ * Absent = ledger / scope enumeration / product read unreadable. */
812
+ foldedCount?: number;
813
+ /** WHY `captureOptedOut` reads as it does: `"record"` = a standing one-way record;
814
+ * `"fault"` = the store faulted and the capture state is INDETERMINATE (no boolean is coined). */
815
+ optOutSource?: "record" | "fault";
816
+ /** Newest lineage `lastAt` for this session (ms epoch) — rides the same single ledger read as
817
+ * `committedCount`. Absent = ledger unreadable, or no committed contribution exists at all. */
818
+ lastCaptureAt?: number;
819
+ }
799
820
  export declare class MemoryEngine {
800
821
  private readonly backend;
801
822
  private readonly memoryDir;
@@ -910,13 +931,17 @@ export declare class MemoryEngine {
910
931
  * first cross-process resume, and "私密 only until the next resume" is a promise this engine
911
932
  * refuses to imply — the deliberate divergence from the pollution marker's best-effort arm.
912
933
  */
913
- markSessionCaptureOptOut(sessionId: string, reason: string): SessionCaptureOptOutMarkOutcome;
934
+ markSessionCaptureOptOut(sessionId: string, reason: string): SessionCaptureOptOutMarkOutcome | Promise<SessionCaptureOptOutMarkOutcome>;
914
935
  /** The session's capture opt-out record (in-process first, then the durable store) — undefined =
915
936
  * capture is on. Side-effect-free OBSERVER face; a THROWING store read degrades to the
916
937
  * in-process answer here. Every consumer whose answer decides whether bytes COMMIT must use
917
938
  * {@link sessionCaptureOptOutOrFault} instead — this face cannot distinguish "no record" from
918
- * "store outage", and on that distinction the fail direction flips. */
919
- sessionCaptureOptOut(sessionId: string): SessionCaptureOptOutRecord | undefined;
939
+ * "store outage", and on that distinction the fail direction flips.
940
+ * DUAL FORM (#511 件2, the whole capture-face family — this one, `sessionCaptureOptOutOrFault`,
941
+ * `markSessionCaptureOptOut`, `listCaptureOptOutSessions`): over a sync store the answer is the
942
+ * same synchronous value as always; over a Promise-form {@link SessionCaptureRecordStore} the
943
+ * face answers a Promise of the identical shape. `await` is always correct on either arm. */
944
+ sessionCaptureOptOut(sessionId: string): SessionCaptureOptOutRecord | undefined | Promise<SessionCaptureOptOutRecord | undefined>;
920
945
  /** {@link sessionCaptureOptOut} with the FAULT axis preserved (codex review, 亲核 adopted):
921
946
  * `fault: true` ⇔ the durable store THREW — the record state is INDETERMINATE, which the
922
947
  * commit-deciding consumers (harvest opening read, pre-commit re-read, the runner's read
@@ -926,11 +951,14 @@ export declare class MemoryEngine {
926
951
  sessionCaptureOptOutOrFault(sessionId: string): {
927
952
  record?: SessionCaptureOptOutRecord;
928
953
  fault: boolean;
929
- };
954
+ } | Promise<{
955
+ record?: SessionCaptureOptOutRecord;
956
+ fault: boolean;
957
+ }>;
930
958
  /** Every capture-opted-out session id (durable roster ∪ in-process marks). THROWS on an
931
959
  * enumeration failure — the one consumer (the consolidation eligibility arm, §2.4) is
932
960
  * fail-closed by design: "roster unknown" must refuse the run, never read as "no one opted out". */
933
- listCaptureOptOutSessions(): Set<string>;
961
+ listCaptureOptOutSessions(): Set<string> | Promise<Set<string>>;
934
962
  /**
935
963
  * design/383 §2.3 — the mid-session flip's BOUNDARY-ISOLATION SWEEP over the write plane: files
936
964
  * under the writable root that this session window added or changed (vs the materialize baseline)
@@ -1445,6 +1473,35 @@ export declare class MemoryEngine {
1445
1473
  * registry THROWS fail-closed — the same reason an unsupported one never reads as empty.
1446
1474
  */
1447
1475
  listMemoryScopes(): Promise<MemoryScopeEnumeration>;
1476
+ /**
1477
+ * design/383 §S-7 (#511 件1) — the HOST's per-session memory-status read face: the data supply
1478
+ * for the "memory capture is off / N committed, M folded" disclosure family and the
1479
+ * resume-visibility answer (state face, not event face — notice dedup semantics are untouched).
1480
+ * A server projects it onto a wire endpoint as a PURE derivation of these keys.
1481
+ *
1482
+ * EVERY key is optional and its ABSENCE means "that fact's source could not be read" — a fault
1483
+ * never coins a `false`/`0` stand-in (the §3.1 fault-transit law):
1484
+ * - `captureOptedOut` — the capture opt-out state. Present `true` (a standing record — with
1485
+ * `optOutSource: "record"`) or present `false` (the store answered and no one-way record
1486
+ * exists — genuine "capture on"). ABSENT ⇔ the record store faulted: the capture state is
1487
+ * INDETERMINATE and `optOutSource: "fault"` says so (the 383 片2/3 `captureIndeterminate`
1488
+ * axis, projected — never a boolean).
1489
+ * - `committedCount` — how many committed entries carry this session's lineage contribution
1490
+ * (the lineage ledger's committed set). Absent ⇔ the ledger is unreadable.
1491
+ * - `foldedCount` — of those, how many appear among the `distilled.inputs` of committed
1492
+ * consolidation products (the lineage × distilled.inputs intersection — "already folded into
1493
+ * long-term memory"). Absent ⇔ the ledger, the scope enumeration (a backend without
1494
+ * `listScopes` cannot name the header universe), or the product read is unreadable.
1495
+ * - `lastCaptureAt` — the newest `lastAt` over this session's lineage contributions (ms epoch).
1496
+ * CHEAP by construction — it rides the SAME single ledger read as `committedCount`, so it is
1497
+ * carried rather than dropped; absent ⇔ the ledger is unreadable OR the session has no
1498
+ * committed contribution at all (there is no such moment to name — the one key whose absence
1499
+ * also covers "no fact exists", stated here so consumers need not guess).
1500
+ *
1501
+ * Side-effect-free committed reads throughout (capture face + lineage sidecar + audit-face
1502
+ * headers/getByIds); never throws — an unreadable source is an absent key, which IS the answer.
1503
+ */
1504
+ sessionMemoryStatus(sessionId: string): Promise<SessionMemoryStatus>;
1448
1505
  /** The committed, side-effect-free audit read: the adoption-restricted committed view when the
1449
1506
  * backend offers one (zero-copy File — ledger+shadow, no disk adoption), else the non-adopting
1450
1507
  * retrieval view (copy-out File), else the backend itself (Pg/TiDB shapes — naturally committed