@sema-agent/server 7.50.0 → 7.52.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 (222) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +83 -20
  4. package/dist/adoption/plan.d.ts +4 -4
  5. package/dist/adoption/plan.js +11 -0
  6. package/dist/approval-card.d.ts +26 -2
  7. package/dist/approval-card.js +9 -0
  8. package/dist/approval-hmac.d.ts +1 -1
  9. package/dist/approval-reconciler.d.ts +2 -2
  10. package/dist/approval-reconciler.js +1 -1
  11. package/dist/bench/l8/artifact.d.ts +1 -1
  12. package/dist/bench/s1/arms.d.ts +1 -1
  13. package/dist/bench/s1/live-deps.d.ts +1 -1
  14. package/dist/bench/s1/oracle.d.ts +1 -1
  15. package/dist/bench/s1/row.d.ts +1 -1
  16. package/dist/boot/budget-tracing.d.ts +1 -1
  17. package/dist/boot/budget-tracing.js +2 -5
  18. package/dist/boot/coordinators.js +3 -1
  19. package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
  20. package/dist/boot/device-lane.d.ts +79 -0
  21. package/dist/boot/device-lane.js +63 -0
  22. package/dist/boot/engine-lease.d.ts +1 -1
  23. package/dist/boot/execution-env.d.ts +48 -2
  24. package/dist/boot/execution-env.js +57 -5
  25. package/dist/boot/governance-seams.d.ts +2 -2
  26. package/dist/boot/leader.d.ts +1 -1
  27. package/dist/boot/memory-boundary.d.ts +2 -2
  28. package/dist/boot/memory-consolidation.d.ts +6 -6
  29. package/dist/boot/org-memory.d.ts +1 -1
  30. package/dist/boot/parked-revive-gate.d.ts +3 -3
  31. package/dist/boot/reapers.d.ts +2 -2
  32. package/dist/boot/reapers.js +17 -4
  33. package/dist/boot/resolve-spec.d.ts +15 -0
  34. package/dist/boot/resolve-spec.js +40 -15
  35. package/dist/boot/retention-lane.d.ts +2 -2
  36. package/dist/boot/runner-deps.d.ts +17 -7
  37. package/dist/boot/runner-deps.js +4 -2
  38. package/dist/boot/runtime-caps.d.ts +23 -0
  39. package/dist/boot/runtime-caps.js +24 -2
  40. package/dist/boot/session-faces.d.ts +6 -1
  41. package/dist/boot/session-faces.js +17 -3
  42. package/dist/boot/shutdown.d.ts +8 -0
  43. package/dist/boot/shutdown.js +3 -1
  44. package/dist/boot/stores.js +40 -1
  45. package/dist/boot/task-list-lane.d.ts +2 -2
  46. package/dist/brain.js +51 -31
  47. package/dist/budget.d.ts +2 -2
  48. package/dist/capabilities/center-plugins.d.ts +1 -1
  49. package/dist/capabilities/collab-workflows.d.ts +1 -1
  50. package/dist/capabilities/hands-lane.d.ts +3 -3
  51. package/dist/capabilities/scenarios.d.ts +3 -3
  52. package/dist/capabilities/skills.d.ts +1 -1
  53. package/dist/config-center/hot-keys-registry.js +5 -0
  54. package/dist/config-center/http-client.d.ts +2 -2
  55. package/dist/config-center/read-face.d.ts +1 -1
  56. package/dist/config-center/restart-signal.d.ts +1 -1
  57. package/dist/config-center/types.d.ts +10 -3
  58. package/dist/config-invariants.d.ts +2 -2
  59. package/dist/config-invariants.js +18 -0
  60. package/dist/config-types.d.ts +90 -18
  61. package/dist/config.js +91 -19
  62. package/dist/deployment-governance.d.ts +1 -1
  63. package/dist/device-enrollment.d.ts +125 -0
  64. package/dist/device-enrollment.js +156 -0
  65. package/dist/device-store.d.ts +385 -0
  66. package/dist/device-store.js +407 -0
  67. package/dist/device-ws-hub.d.ts +182 -0
  68. package/dist/device-ws-hub.js +1012 -0
  69. package/dist/device-ws-protocol.d.ts +429 -0
  70. package/dist/device-ws-protocol.js +464 -0
  71. package/dist/env-facts.d.ts +5 -2
  72. package/dist/env-facts.js +1 -0
  73. package/dist/execution-lane-caps.d.ts +152 -0
  74. package/dist/execution-lane-caps.js +166 -0
  75. package/dist/fleet/fleet-bus.d.ts +1 -1
  76. package/dist/fleet/fleet-reconciler.d.ts +1 -1
  77. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  78. package/dist/http/active-run-conflict.d.ts +2 -2
  79. package/dist/http/principal-gate.d.ts +1 -1
  80. package/dist/http/route-ctx.d.ts +24 -4
  81. package/dist/http/routes/admin-memory-optout.d.ts +48 -0
  82. package/dist/http/routes/admin-memory-optout.js +75 -0
  83. package/dist/http/routes/agents-roster.d.ts +1 -1
  84. package/dist/http/routes/approvals-assistant.js +21 -21
  85. package/dist/http/routes/capabilities.js +27 -11
  86. package/dist/http/routes/diagnostics.d.ts +1 -1
  87. package/dist/http/routes/leader.d.ts +1 -1
  88. package/dist/http/routes/memory-bundle.d.ts +1 -1
  89. package/dist/http/routes/memory-bundle.js +3 -1
  90. package/dist/http/routes/memory-compliance.d.ts +4 -4
  91. package/dist/http/routes/memory-origin.d.ts +4 -4
  92. package/dist/http/routes/memory-origin.js +3 -1
  93. package/dist/http/routes/memory-policy.js +6 -2
  94. package/dist/http/routes/retention-ops.d.ts +1 -1
  95. package/dist/http/routes/rules.d.ts +5 -2
  96. package/dist/http/routes/rules.js +84 -5
  97. package/dist/http/routes/run-memory-capture.d.ts +34 -0
  98. package/dist/http/routes/run-memory-capture.js +97 -0
  99. package/dist/http/routes/runs.d.ts +1 -0
  100. package/dist/http/routes/runs.js +212 -6
  101. package/dist/http/routes/session-sync.d.ts +4 -6
  102. package/dist/http/routes/session-sync.js +60 -47
  103. package/dist/http/routes/sessions.js +61 -188
  104. package/dist/http/routes/shared-memory.d.ts +1 -1
  105. package/dist/http/routes/tasks.js +1 -1
  106. package/dist/http/routes/workflows.js +19 -0
  107. package/dist/http/server.d.ts +49 -13
  108. package/dist/http/server.js +109 -13
  109. package/dist/http/verify-rounds.d.ts +1 -1
  110. package/dist/http/wire-types.d.ts +41 -8
  111. package/dist/leader/endpoint.d.ts +1 -1
  112. package/dist/leader/leader.d.ts +1 -1
  113. package/dist/leader/repair-oracle.d.ts +2 -2
  114. package/dist/leader/repair-oracle.js +1 -1
  115. package/dist/leader/wire.js +4 -2
  116. package/dist/main.js +23 -9
  117. package/dist/memory-bundle-engine.d.ts +1 -1
  118. package/dist/memory-operator-faces.d.ts +5 -5
  119. package/dist/memory-origin-fence.d.ts +50 -0
  120. package/dist/memory-origin-fence.js +15 -0
  121. package/dist/memory-posture.d.ts +13 -1
  122. package/dist/memory-posture.js +1 -0
  123. package/dist/memory-scope.d.ts +6 -1
  124. package/dist/memory-scope.js +3 -2
  125. package/dist/model-select.d.ts +2 -2
  126. package/dist/observability/fail-open.d.ts +17 -5
  127. package/dist/observability/fail-open.js +17 -5
  128. package/dist/orchestration/hardened-vm-runner.d.ts +9 -2
  129. package/dist/orchestration/hardened-vm-runner.js +11 -1
  130. package/dist/orchestration/hardened-vm-worker-runner.js +2 -2
  131. package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
  132. package/dist/plugins/background-shell-support.d.ts +2 -2
  133. package/dist/plugins/blob-backend.d.ts +7 -2
  134. package/dist/plugins/blob-backend.js +22 -18
  135. package/dist/plugins/caching-session-store.d.ts +7 -3
  136. package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
  137. package/dist/plugins/device-store-sql.d.ts +130 -0
  138. package/dist/plugins/device-store-sql.js +574 -0
  139. package/dist/plugins/file-history-store-sql.d.ts +170 -0
  140. package/dist/plugins/file-history-store-sql.js +827 -0
  141. package/dist/plugins/file-run-store.d.ts +44 -5
  142. package/dist/plugins/file-run-store.js +85 -13
  143. package/dist/plugins/fork-routing-session-store.d.ts +1 -1
  144. package/dist/plugins/fork-routing-session-store.js +2 -2
  145. package/dist/plugins/leader-run-store-sql.d.ts +1 -1
  146. package/dist/plugins/local-session-store.d.ts +9 -1
  147. package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
  148. package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
  149. package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
  150. package/dist/plugins/memory-origin-law.d.ts +2 -2
  151. package/dist/plugins/memory-run-store.d.ts +4 -3
  152. package/dist/plugins/memory-run-store.js +2 -1
  153. package/dist/plugins/permission-rule-store-file.d.ts +16 -6
  154. package/dist/plugins/permission-rule-store-file.js +77 -13
  155. package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
  156. package/dist/plugins/permission-rule-store-sql.js +123 -21
  157. package/dist/plugins/pg-pool.js +59 -0
  158. package/dist/plugins/pg-safe-json.d.ts +1 -1
  159. package/dist/plugins/remote-env-device.d.ts +271 -0
  160. package/dist/plugins/remote-env-device.js +727 -0
  161. package/dist/plugins/remote-env-k8s.d.ts +1 -1
  162. package/dist/plugins/remote-scratchpad.js +1 -1
  163. package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
  164. package/dist/plugins/retention-store-sql.d.ts +7 -7
  165. package/dist/plugins/retention-store-sql.js +6 -0
  166. package/dist/plugins/run-store-sql.d.ts +9 -3
  167. package/dist/plugins/run-store-sql.js +2 -2
  168. package/dist/plugins/send-user-file.d.ts +1 -1
  169. package/dist/plugins/session-placement.d.ts +1 -1
  170. package/dist/plugins/sql-errors.d.ts +1 -1
  171. package/dist/plugins/store-backend.d.ts +68 -29
  172. package/dist/plugins/store-backend.js +78 -15
  173. package/dist/plugins/store-contracts.d.ts +5 -3
  174. package/dist/plugins/task-list-store-sql.d.ts +1 -1
  175. package/dist/plugins/tidb-pool.js +59 -0
  176. package/dist/plugins/web-search.d.ts +1 -1
  177. package/dist/prompts-domain-validate.js +1 -1
  178. package/dist/resource-suspend.d.ts +1 -1
  179. package/dist/resource-window.d.ts +3 -3
  180. package/dist/rules-consent.d.ts +231 -9
  181. package/dist/rules-consent.js +411 -13
  182. package/dist/run-local.js +1 -0
  183. package/dist/runs.d.ts +15 -1
  184. package/dist/runs.js +19 -0
  185. package/dist/runtime-caps-resolver.d.ts +36 -1
  186. package/dist/runtime-caps-resolver.js +25 -0
  187. package/dist/runtime-governance.d.ts +1 -1
  188. package/dist/security.d.ts +5 -5
  189. package/dist/security.js +1 -1
  190. package/dist/session-sync.d.ts +52 -37
  191. package/dist/session-sync.js +19 -16
  192. package/dist/shared-memory-scope-authorizer.d.ts +1 -1
  193. package/dist/sighup-idle.d.ts +1 -1
  194. package/dist/spec-fields.d.ts +22 -1
  195. package/dist/spec-fields.js +11 -0
  196. package/dist/store-live-probe.d.ts +2 -2
  197. package/dist/task-a2a.d.ts +1 -1
  198. package/dist/task-cwd.d.ts +25 -0
  199. package/dist/task-cwd.js +3 -0
  200. package/dist/task-mcp.d.ts +3 -3
  201. package/dist/task-settings.d.ts +3 -3
  202. package/dist/task-settings.js +3 -1
  203. package/dist/task-workflow.d.ts +1 -1
  204. package/dist/text-bidi.d.ts +2 -2
  205. package/dist/tool-approval.d.ts +191 -9
  206. package/dist/tool-approval.js +248 -29
  207. package/dist/trace/core-keyset-guard.d.ts +1 -1
  208. package/dist/trace/engine-notice-wire.d.ts +6 -4
  209. package/dist/trace/engine-notice-wire.js +2 -0
  210. package/dist/trace/ledger-events.d.ts +1 -1
  211. package/dist/trace/ledger-sink.d.ts +2 -2
  212. package/dist/trace/project.d.ts +3 -2
  213. package/dist/trace/project.js +1 -0
  214. package/dist/turn-activity.d.ts +1 -1
  215. package/dist/wall-clock-jump-guard.d.ts +3 -3
  216. package/package.json +2 -2
  217. package/dist/http/tar.d.ts +0 -17
  218. package/dist/http/tar.js +0 -53
  219. package/dist/http/workspace-content.d.ts +0 -11
  220. package/dist/http/workspace-content.js +0 -20
  221. package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
  222. package/dist/plugins/file-snapshot-store-sql.js +0 -213
@@ -1,12 +1,32 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
2
3
  import { recordFailOpen } from "./observability/fail-open.js";
3
4
  import { createLogger } from "./observability/logger.js";
4
5
  import { MAX_CWD_CHARS } from "./task-cwd.js";
6
+ import { CONTROL_AND_BIDI_CHARS, hasBidiControls } from "./text-bidi.js";
5
7
  import { confirmRuleApproval, parseAllowRuleText, precheckEditedRuleText, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, } from "@sema-agent/core";
6
8
  import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
7
9
  export const RULE_IMPORT_TICKET_TTL_MS = 10 * 60_000;
8
10
  export const RULE_IMPORT_RETRY_AFTER_SEC = 2;
9
11
  export const MAX_IMPORT_CANDIDATES = 200;
12
+ export const MAX_LOCAL_IMPORT_ROWS = MAX_IMPORT_CANDIDATES;
13
+ export const MAX_LOCAL_IMPORT_BYTES = 256 * 1024;
14
+ export const LOCAL_IMPORT_CLAIM_LEASE_MS = 60_000;
15
+ const LOCAL_IMPORT_CLAIM_WAIT_MS = RULE_IMPORT_RETRY_AFTER_SEC * 1000;
16
+ const LOCAL_IMPORT_CLAIM_POLL_MS = 25;
17
+ const LocalImportOutcomeSchema = z
18
+ .object({
19
+ members: z.array(z
20
+ .object({
21
+ key: z.object({ rule: z.string().min(1), scope: z.string().min(1) }).strict(),
22
+ outcome: z.enum(["persisted", "no-op", "refused", "indeterminate"]),
23
+ reason: z.enum(["store_refused", "walk_failed"]).optional(),
24
+ detail: z.string().optional(),
25
+ })
26
+ .strict()),
27
+ rev: z.number().int().nonnegative().safe(),
28
+ })
29
+ .strict();
10
30
  const precheckLogger = createLogger();
11
31
  export function precheckCardRuleText(text, command) {
12
32
  try {
@@ -21,7 +41,14 @@ export function precheckCardRuleText(text, command) {
21
41
  }
22
42
  }
23
43
  export function serializeRuleScope(scope) {
24
- return scope.kind === "global" ? "global" : `project:${scope.root}`;
44
+ switch (scope.kind) {
45
+ case "global":
46
+ return "global";
47
+ case "project":
48
+ return `project:${scope.root}`;
49
+ case "session":
50
+ throw new Error("serializeRuleScope got a session scope — session consent never lands on the durable rules wire (design/382 §4.3 two-member durable face); a session row reaching this serializer is a defect upstream, not a spelling to invent");
51
+ }
25
52
  }
26
53
  export const MAX_RULE_SCOPE_CHARS = "project:".length + MAX_CWD_CHARS;
27
54
  export function parseRuleScope(text) {
@@ -122,11 +149,87 @@ function anchorBatchMembers(members, displayed) {
122
149
  anchored.sort((a, b) => a.memberIndex - b.memberIndex);
123
150
  return { kind: "batch", rules: anchored.map((a) => a.rule), members: anchored };
124
151
  }
152
+ function screenLocalImportRow(row) {
153
+ const scope = parseRuleScope(row.scope);
154
+ if (scope === undefined) {
155
+ return { reason: "scope_unreadable", detail: "scope must be 'global' or 'project:<root>' with a non-empty root" };
156
+ }
157
+ if (scope.kind === "project" && hasBidiControls(scope.root)) {
158
+ return {
159
+ reason: "scope_bidi_controls",
160
+ detail: "this project root carries bidirectional control characters — the directory a reader sees is not the directory the matcher would use",
161
+ };
162
+ }
163
+ if (scope.kind === "project" && CONTROL_AND_BIDI_DETECT_RE.test(scope.root)) {
164
+ return {
165
+ reason: "scope_control_chars",
166
+ detail: "this project root carries control bytes (C0/DEL) — it is not a path, one SQL dialect refuses to store it at all, and it breaks the separator assumption this lane's comparison keys rest on",
167
+ };
168
+ }
169
+ const pre = precheckCardRuleText(row.rule, row.command);
170
+ if (pre === undefined) {
171
+ return { reason: "rule_uncheckable", detail: "the rule text gate could not be asked about this row (its command is absent or not a string) — the row itself is malformed" };
172
+ }
173
+ if (!pre.ok)
174
+ return { reason: "rule_rejected", detail: pre.message };
175
+ const canonical = parseAllowRuleText(pre.canonicalRule);
176
+ if ("reject" in canonical) {
177
+ return { reason: "rule_rejected", detail: `the canonical spelling the engine minted does not re-parse: ${canonical.reject.message}` };
178
+ }
179
+ const derived = canonical.rule;
180
+ if (derived.tool !== row.tool || derived.match !== row.match || derived.command !== row.command) {
181
+ return {
182
+ reason: "row_inconsistent",
183
+ detail: `this row's tool/match/command are derived facts of its rule text, and they disagree: the rule reads as ` +
184
+ `${derived.tool}/${derived.match}/${JSON.stringify(derived.command)} but the row says ${String(row.tool)}/${String(row.match)}/${JSON.stringify(row.command)}`,
185
+ };
186
+ }
187
+ return { rule: derived.rule, scope };
188
+ }
189
+ const LOCAL_IMPORT_LAYER_PATH = "local-import:rules";
190
+ const CONTROL_AND_BIDI_DETECT_RE = new RegExp(`[${CONTROL_AND_BIDI_CHARS}]`);
191
+ const LOCAL_IMPORT_KEY_SEP = "\u0000";
192
+ function buildLocalImportLayers(candidates) {
193
+ const byRoot = new Map();
194
+ for (const c of candidates) {
195
+ const key = c.scope.kind === "global" ? "" : c.scope.root;
196
+ const bucket = byRoot.get(key);
197
+ if (bucket === undefined)
198
+ byRoot.set(key, [c.rule]);
199
+ else
200
+ bucket.push(c.rule);
201
+ }
202
+ return [...byRoot].map(([root, rules]) => ({
203
+ layer: root === "" ? "userSettings" : "projectSettings",
204
+ path: root === "" ? LOCAL_IMPORT_LAYER_PATH : `${LOCAL_IMPORT_LAYER_PATH}:${root}`,
205
+ root: root === "" ? "/" : root,
206
+ readFile: async () => JSON.stringify({ permissions: { allow: rules } }),
207
+ }));
208
+ }
209
+ function buildLocalImportMembers(members) {
210
+ return members.map((m) => {
211
+ const key = { rule: m.rule, scope: serializeRuleScope(m.scope) };
212
+ switch (m.status) {
213
+ case "persisted":
214
+ return { key, outcome: "persisted" };
215
+ case "deduped":
216
+ return { key, outcome: "no-op" };
217
+ case "refused":
218
+ return { key, outcome: "indeterminate", reason: "store_refused", detail: m.reason };
219
+ default: {
220
+ const unknown = m;
221
+ throw new Error(`unknown redeemed-batch member status: ${JSON.stringify(unknown)}`);
222
+ }
223
+ }
224
+ });
225
+ }
125
226
  export function createRuleConsentLane(stores, opts) {
126
227
  const deps = { provider: stores.provider, approvals: stores.approvals, cardEdits: true };
127
228
  const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
128
229
  const newTicketId = opts?.newTicketId ?? (() => randomUUID());
129
230
  const monotonic = opts?.monotonic ?? (() => performance.now());
231
+ const sleep = opts?.sleep ?? (async (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
232
+ const localImportLogger = createLogger();
130
233
  const anchors = new Map();
131
234
  const ANCHOR_CAP = 4096;
132
235
  const pruneAnchors = (nowMono) => {
@@ -150,15 +253,24 @@ export function createRuleConsentLane(stores, opts) {
150
253
  };
151
254
  return {
152
255
  async persistCardRule(input) {
153
- const prepared = await prepareCardApproval({
154
- principal: input.principal,
155
- toolName: input.toolName,
156
- command: input.command,
157
- ...(input.toolCallId !== undefined ? { toolCallId: input.toolCallId } : {}),
158
- ...(input.boundInputHash !== undefined ? { boundInputHash: input.boundInputHash } : {}),
159
- ...(input.scope !== undefined ? { scope: input.scope } : {}),
160
- deps,
161
- });
256
+ let prepared;
257
+ try {
258
+ prepared = await prepareCardApproval({
259
+ principal: input.principal,
260
+ toolName: input.toolName,
261
+ command: input.command,
262
+ ...(input.toolCallId !== undefined ? { toolCallId: input.toolCallId } : {}),
263
+ ...(input.boundInputHash !== undefined ? { boundInputHash: input.boundInputHash } : {}),
264
+ ...(input.scope !== undefined ? { scope: input.scope } : {}),
265
+ deps,
266
+ });
267
+ }
268
+ catch (err) {
269
+ if (err.code === "config.missing_scope") {
270
+ return { ok: false, reason: "scope-unresolved", detail: err.message };
271
+ }
272
+ throw err;
273
+ }
162
274
  if (prepared === undefined) {
163
275
  return { ok: false, reason: "no-candidates", detail: "the rule lane cannot speak for this command (compound / redirection / unsupported tool)" };
164
276
  }
@@ -249,19 +361,53 @@ export function createRuleConsentLane(stores, opts) {
249
361
  return { ok: false, reason: "too-many-candidates", candidates: preview.candidates.length };
250
362
  }
251
363
  const mintedAtMono = monotonic();
252
- const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, candidates: preview.candidates, ttlMs: ticketTtlMs });
364
+ const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, purpose: "cc-import", candidates: preview.candidates, ttlMs: ticketTtlMs });
253
365
  pruneAnchors(mintedAtMono);
254
366
  anchors.set(minted.ticketId, { mintedAtWall: minted.expiresAtMs - ticketTtlMs, mintedAtMono });
255
367
  return { ok: true, preview, ticket: minted.ticketId, expiresAtMs: minted.expiresAtMs };
256
368
  },
257
369
  async redeemImport(principal, ticket) {
258
370
  const clock = decisionClock(ticket);
259
- const consumed = await stores.tickets.consume(ticket, principal, clock);
371
+ const consumeOutcomeAfterThrow = async (err) => {
372
+ localImportLogger.warn("cc_import_claim_write_failed", {
373
+ err: err instanceof Error ? err.message : String(err),
374
+ note: "the consume write failed with an ambiguous outcome (it may have committed) — classifying by the ticket's real state instead of surfacing a 500",
375
+ });
376
+ let snapshot;
377
+ try {
378
+ snapshot = await stores.tickets.peek(ticket, principal, "cc-import");
379
+ }
380
+ catch (peekErr) {
381
+ localImportLogger.warn("cc_import_ticket_state_unreadable", {
382
+ err: peekErr instanceof Error ? peekErr.message : String(peekErr),
383
+ note: "the ticket's state could not be read while classifying a failed consume — answering an explicit ambiguity (503) rather than declaring a terminal outcome or asserting an in-flight redemption",
384
+ });
385
+ return { ok: false, reason: "state-unreadable", detail: "the ticket's state could not be read while classifying a failed consume" };
386
+ }
387
+ if (snapshot === undefined)
388
+ return { ok: false, reason: "ticket-unusable", detail: "unknown" };
389
+ if (snapshot.state === "minted") {
390
+ return { ok: false, reason: "state-unreadable", detail: "the consume write did not report its outcome and may still land — the ticket's fate cannot be answered yet" };
391
+ }
392
+ if (snapshot.state === "settled") {
393
+ localImportLogger.warn("cc_import_ticket_unexpectedly_settled", {
394
+ note: "a cc-import ticket reads SETTLED — structurally unreachable (settle is only ever called on the local lane and purpose binding keeps the lanes apart); answering same-shape 404, loudly",
395
+ });
396
+ }
397
+ return { ok: false, reason: "ticket-unusable", detail: "claim outstanding after a failed consume write" };
398
+ };
399
+ let consumed;
400
+ try {
401
+ consumed = await stores.tickets.consume(ticket, principal, "cc-import", clock);
402
+ }
403
+ catch (err) {
404
+ return await consumeOutcomeAfterThrow(err);
405
+ }
260
406
  if (!consumed.ok)
261
407
  return { ok: false, reason: "ticket-unusable", detail: consumed.reason };
262
408
  const releaseClaim = async () => {
263
409
  try {
264
- return await stores.tickets.release(ticket, principal, RULE_IMPORT_RETRY_AFTER_SEC * 1000, decisionClock(ticket));
410
+ return await stores.tickets.release(ticket, principal, consumed.claimId, RULE_IMPORT_RETRY_AFTER_SEC * 1000, decisionClock(ticket));
265
411
  }
266
412
  catch (err) {
267
413
  recordFailOpen("server.rules.ticket-claim-release-failed", err instanceof Error ? err.message : String(err));
@@ -304,6 +450,258 @@ export function createRuleConsentLane(stores, opts) {
304
450
  return await indeterminate(err instanceof Error ? err.message : String(err));
305
451
  }
306
452
  },
453
+ async prepareLocalImport(principal, rows) {
454
+ if (rows.length > MAX_LOCAL_IMPORT_ROWS)
455
+ return { ok: false, reason: "too-many-rows", rows: rows.length };
456
+ const bytes = JSON.stringify(rows).length;
457
+ if (bytes > MAX_LOCAL_IMPORT_BYTES)
458
+ return { ok: false, reason: "batch-too-large", bytes };
459
+ const members = [];
460
+ const admitted = [];
461
+ const seen = new Set();
462
+ for (const row of rows) {
463
+ const screened = screenLocalImportRow(row);
464
+ if ("reason" in screened) {
465
+ members.push({ key: { rule: row.rule, scope: row.scope }, admitted: false, reason: screened.reason, detail: screened.detail });
466
+ continue;
467
+ }
468
+ const key = { rule: screened.rule, scope: serializeRuleScope(screened.scope) };
469
+ const dedupKey = `${key.scope}${LOCAL_IMPORT_KEY_SEP}${key.rule}`;
470
+ if (seen.has(dedupKey)) {
471
+ members.push({ key, admitted: false, reason: "duplicate_row", detail: "this (rule, scope) appears earlier in the same batch — the engine would fold the two silently, so it is reported here instead" });
472
+ continue;
473
+ }
474
+ seen.add(dedupKey);
475
+ members.push({ key, admitted: true });
476
+ admitted.push(screened);
477
+ }
478
+ if (admitted.length === 0)
479
+ return { ok: true, preview: { members } };
480
+ const { preview, approvalId } = await prepareCcImport({ layers: buildLocalImportLayers(admitted), principal, deps });
481
+ const engineKeys = new Set(preview.candidates.map((c) => `${serializeRuleScope(c.scope)}${LOCAL_IMPORT_KEY_SEP}${c.rule}`));
482
+ for (let i = 0; i < members.length; i++) {
483
+ const m = members[i];
484
+ if (m === undefined || !m.admitted)
485
+ continue;
486
+ if (engineKeys.has(`${m.key.scope}${LOCAL_IMPORT_KEY_SEP}${m.key.rule}`))
487
+ continue;
488
+ const why = preview.skipped.find((s) => s.rule === m.key.rule)?.reason ?? "the engine's import gate did not take it, and said nothing about it";
489
+ localImportLogger.warn("local_import_row_admitted_but_not_importable", {
490
+ rule: m.key.rule,
491
+ scope: m.key.scope,
492
+ reason: why,
493
+ note: "this repository's row gate and core's import gate disagree about this rule — the row is reported refused rather than silently dropped",
494
+ });
495
+ members[i] = { key: m.key, admitted: false, reason: "not_importable", detail: why };
496
+ }
497
+ if (approvalId === undefined)
498
+ return { ok: true, preview: { members } };
499
+ const mintedAtMono = monotonic();
500
+ const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, purpose: "local-import", candidates: preview.candidates, ttlMs: ticketTtlMs });
501
+ pruneAnchors(mintedAtMono);
502
+ anchors.set(minted.ticketId, { mintedAtWall: minted.expiresAtMs - ticketTtlMs, mintedAtMono });
503
+ return { ok: true, preview: { members }, ticket: minted.ticketId, expiresAtMs: minted.expiresAtMs };
504
+ },
505
+ async redeemLocalImport(principal, ticket) {
506
+ const readSettled = (raw) => {
507
+ const parsed = LocalImportOutcomeSchema.safeParse(raw);
508
+ if (!parsed.success) {
509
+ throw new Error(`the settled outcome recorded on this import ticket does not carry a readable shape: ${parsed.error.message}`);
510
+ }
511
+ return parsed.data;
512
+ };
513
+ const tryPeek = async () => {
514
+ try {
515
+ return await stores.tickets.peek(ticket, principal, "local-import");
516
+ }
517
+ catch (err) {
518
+ localImportLogger.warn("local_import_ticket_state_unreadable", {
519
+ err: err instanceof Error ? err.message : String(err),
520
+ note: "the ticket's state could not be read while classifying a failure — answering in-flight (retry with the same ticket) rather than declaring a terminal outcome",
521
+ });
522
+ return "unreadable";
523
+ }
524
+ };
525
+ const unreadableTicketState = () => ({
526
+ ok: false,
527
+ reason: "in-flight",
528
+ detail: "the ticket's state could not be read — retry with the same ticket",
529
+ });
530
+ const driveAndSettle = async (approvalId, payloadHash, claimId) => {
531
+ const tryRelease = async () => {
532
+ try {
533
+ return await stores.tickets.release(ticket, principal, claimId, RULE_IMPORT_RETRY_AFTER_SEC * 1000, decisionClock(ticket));
534
+ }
535
+ catch (err) {
536
+ recordFailOpen("server.rules.ticket-claim-release-failed", err instanceof Error ? err.message : String(err));
537
+ return false;
538
+ }
539
+ };
540
+ const verdict = async (reason, detail) => {
541
+ await tryRelease();
542
+ return { ok: false, reason, detail };
543
+ };
544
+ const transient = async (detail) => {
545
+ const released = await tryRelease();
546
+ if (released)
547
+ return { ok: false, reason: "record-unusable", detail, retryable: true };
548
+ const snapshot = await tryPeek();
549
+ if (snapshot === "unreadable")
550
+ return unreadableTicketState();
551
+ if (snapshot === undefined)
552
+ return { ok: false, reason: "record-unusable", detail };
553
+ if (snapshot.state === "settled")
554
+ return { ok: true, outcome: readSettled(snapshot.outcome), replayed: true };
555
+ return { ok: false, reason: "in-flight", detail: `${detail} — the claim could not be handed back, but the ticket is still recoverable; retry with the same ticket` };
556
+ };
557
+ let members;
558
+ let rev;
559
+ try {
560
+ const record = await stores.approvals.get(approvalId);
561
+ if (record === undefined)
562
+ return await verdict("record-unusable", "the approval record behind this ticket is gone");
563
+ if ("staleSchema" in record) {
564
+ return await verdict("record-unusable", "this approval record predates the schema:2 form — re-run the import to mint a fresh record");
565
+ }
566
+ if (buildRulePayloadHash(record.candidates) !== payloadHash) {
567
+ return await verdict("payload-mismatch", "the approval record's candidates changed after the ticket was minted");
568
+ }
569
+ if (record.state === "pending") {
570
+ const confirmed = await confirmRuleApproval({ approvalId, principal, deps });
571
+ if (!confirmed.ok) {
572
+ if (confirmed.reason === "conflict")
573
+ return await transient(confirmed.reason);
574
+ return await verdict("record-unusable", confirmed.reason);
575
+ }
576
+ }
577
+ const result = await redeemRuleBatch({ approvalId, principal, deps });
578
+ if ("status" in result) {
579
+ return await transient(result.reason);
580
+ }
581
+ members = buildLocalImportMembers(result.members);
582
+ rev = result.rev;
583
+ }
584
+ catch (err) {
585
+ return await transient(err instanceof Error ? err.message : String(err));
586
+ }
587
+ const outcome = { members, rev };
588
+ let settled;
589
+ try {
590
+ settled = await stores.tickets.settle(ticket, principal, claimId, outcome);
591
+ }
592
+ catch (err) {
593
+ localImportLogger.warn("local_import_settle_failed", {
594
+ err: err instanceof Error ? err.message : String(err),
595
+ note: "the redemption ran but its outcome could not be recorded — answering in-flight so the owner retries and reads the authoritative state, never 500 (which reads as 'nothing happened')",
596
+ });
597
+ const afterThrow = await tryPeek();
598
+ if (afterThrow !== "unreadable" && afterThrow?.state === "settled") {
599
+ return { ok: true, outcome: readSettled(afterThrow.outcome), replayed: true };
600
+ }
601
+ return { ok: false, reason: "in-flight", detail: "the redemption ran but its outcome could not be recorded — retry with the same ticket to read the authoritative state" };
602
+ }
603
+ if (settled)
604
+ return { ok: true, outcome, replayed: false };
605
+ const snapshot = await tryPeek();
606
+ if (snapshot === "unreadable")
607
+ return unreadableTicketState();
608
+ if (snapshot?.state === "settled") {
609
+ return { ok: true, outcome: readSettled(snapshot.outcome), replayed: true };
610
+ }
611
+ if (snapshot !== undefined) {
612
+ localImportLogger.warn("local_import_claim_superseded", {
613
+ ticketState: snapshot.state,
614
+ note: "this claim was superseded (lease takeover) before it could settle — the local outcome is NOT authoritative and is deliberately not returned; the client retries and reads the canonical settled outcome",
615
+ });
616
+ return { ok: false, reason: "in-flight", detail: "this redemption's claim was superseded before it settled — retry with the same ticket to read the authoritative outcome" };
617
+ }
618
+ localImportLogger.warn("local_import_outcome_not_recorded", {
619
+ ticketState: "gone",
620
+ note: "the redemption ran and its result is being returned truthfully, but the ticket row is gone so it could not be recorded — a lost response cannot be replayed for this one",
621
+ });
622
+ return { ok: true, outcome, replayed: false };
623
+ };
624
+ const claimOutcomeAfterThrow = async (err, what) => {
625
+ localImportLogger.warn("local_import_claim_write_failed", {
626
+ op: what,
627
+ err: err instanceof Error ? err.message : String(err),
628
+ note: "a claim-changing write failed with an ambiguous outcome (it may have committed) — classifying by the ticket's real state instead of surfacing a 500",
629
+ });
630
+ const snapshot = await tryPeek();
631
+ if (snapshot === "unreadable")
632
+ return unreadableTicketState();
633
+ if (snapshot === undefined)
634
+ return { ok: false, reason: "ticket-unusable", detail: "unknown" };
635
+ if (snapshot.state === "settled")
636
+ return { ok: true, outcome: readSettled(snapshot.outcome), replayed: true };
637
+ return { ok: false, reason: "in-flight", detail: `the ${what} write did not report its outcome — retry with the same ticket` };
638
+ };
639
+ let consumed;
640
+ try {
641
+ consumed = await stores.tickets.consume(ticket, principal, "local-import", decisionClock(ticket));
642
+ }
643
+ catch (err) {
644
+ return await claimOutcomeAfterThrow(err, "consume");
645
+ }
646
+ if (consumed.ok)
647
+ return await driveAndSettle(consumed.approvalId, consumed.payloadHash, consumed.claimId);
648
+ if (consumed.reason !== "consumed")
649
+ return { ok: false, reason: "ticket-unusable", detail: consumed.reason };
650
+ const deadline = monotonic() + LOCAL_IMPORT_CLAIM_WAIT_MS;
651
+ for (;;) {
652
+ const snapshot = await tryPeek();
653
+ if (snapshot === "unreadable")
654
+ return unreadableTicketState();
655
+ if (snapshot === undefined)
656
+ return { ok: false, reason: "ticket-unusable", detail: "unknown" };
657
+ switch (snapshot.state) {
658
+ case "settled":
659
+ return { ok: true, outcome: readSettled(snapshot.outcome), replayed: true };
660
+ case "minted": {
661
+ let again;
662
+ try {
663
+ again = await stores.tickets.consume(ticket, principal, "local-import", decisionClock(ticket));
664
+ }
665
+ catch (err) {
666
+ return await claimOutcomeAfterThrow(err, "consume");
667
+ }
668
+ if (again.ok)
669
+ return await driveAndSettle(again.approvalId, again.payloadHash, again.claimId);
670
+ if (again.reason !== "consumed")
671
+ return { ok: false, reason: "ticket-unusable", detail: again.reason };
672
+ break;
673
+ }
674
+ case "claimed": {
675
+ if (snapshot.claimAgeMs >= LOCAL_IMPORT_CLAIM_LEASE_MS) {
676
+ let seized;
677
+ try {
678
+ seized = await stores.tickets.reclaim(ticket, principal, "local-import", LOCAL_IMPORT_CLAIM_LEASE_MS);
679
+ }
680
+ catch (err) {
681
+ return await claimOutcomeAfterThrow(err, "reclaim");
682
+ }
683
+ if (seized.ok) {
684
+ localImportLogger.warn("local_import_stale_claim_reclaimed", {
685
+ claimAgeMs: snapshot.claimAgeMs,
686
+ leaseMs: LOCAL_IMPORT_CLAIM_LEASE_MS,
687
+ note: "a claim outlived its lease (the replica holding it is presumed gone) — re-driving the batch, which is member-level idempotent",
688
+ });
689
+ return await driveAndSettle(seized.approvalId, seized.payloadHash, seized.claimId);
690
+ }
691
+ }
692
+ break;
693
+ }
694
+ default: {
695
+ const unknown = snapshot;
696
+ throw new Error(`unknown import-ticket state: ${JSON.stringify(unknown)}`);
697
+ }
698
+ }
699
+ if (monotonic() >= deadline) {
700
+ return { ok: false, reason: "in-flight", detail: "another redemption of this ticket is still in flight" };
701
+ }
702
+ await sleep(LOCAL_IMPORT_CLAIM_POLL_MS);
703
+ }
704
+ },
307
705
  async listRules(bucket) {
308
706
  const stored = await storeForBucket(stores.provider, bucket).list();
309
707
  const rows = stored.rules.map((r) => ({ rule: r.rule, scope: serializeRuleScope(r.scope), tool: r.tool, match: r.match, command: r.command, adds: r.adds }));
package/dist/run-local.js CHANGED
@@ -245,6 +245,7 @@ export async function runLocal(argv, deps = {}) {
245
245
  try {
246
246
  fileBackend = new FileStorageBackend({
247
247
  root,
248
+ ...(config.fileHistory !== undefined ? { fileHistory: config.fileHistory } : {}),
248
249
  onCorruptRead: (info) => logger.warn("file_store_corrupt_read", {
249
250
  path: info.path,
250
251
  reason: info.reason,
package/dist/runs.d.ts CHANGED
@@ -122,6 +122,20 @@ export declare function isSessionConflictResult(result: {
122
122
  * the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
123
123
  * result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
124
124
  * codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
125
+ *
126
+ * design/383 (core 7.0.0 片2/3, S-49 S-3): the session capture opt-out's four prepare-throw refusals ride the
127
+ * same pipe. `memory.capture_optout_denied` is TERMINAL (the principal's `allowMemoryOptOut` verdict is an
128
+ * explicit `false`, or the posture is `capture-required` / `governed`-with-absent-verdict — a retry cannot
129
+ * change it) → 403, the `memory.admission_denied` twin. `memory.capture_optout_unpersisted` is the
130
+ * declaration arm's "control-plane record could not be written" refusal — a store outage, retry may succeed
131
+ * → 503 (transient fail-closed, the `memory.admission_required` twin; no retryAfterMs on this one — core
132
+ * mints none, so the sync leg forwards no hint rather than inventing one). `config.memory_capture_spelling`
133
+ * is a TaskSpec authoring mistake (the only legal spelling is the string "off") → 400, the
134
+ * `config.memory_erasure_request` family shape. `config.memory_capture_unsupported` is the DEPLOYMENT SHAPE
135
+ * refusing the declaration (remote/per-run execution with no durable capture-record carrier: the record
136
+ * would not survive to a resume replica) → 409 — the request is well-formed, the deployment's state
137
+ * conflicts with it, the `config.refresh_unavailable` family shape ("change the deployment, not the request").
138
+ * Status choices are pinned by the `config.`/`memory.` family status sets in error-code-catalog-live.test.ts.
125
139
  */
126
140
  export declare function resumeAtHttpStatus(result: {
127
141
  status: string;
@@ -249,7 +263,7 @@ cascade?: CascadeConfig, instrumentDegenerate?: (result: TaskResult) => void, pl
249
263
  record: (scope: string | undefined, objective: string, completed: boolean) => void;
250
264
  }, persistThinking?: boolean, inflight?: Map<string, AbortController>, preemptable?: Map<string, AbortController>, steerable?: Map<string, TaskStream>, modelUsage?: ModelUsageTracker, elicitation?: ElicitationCoordinator,
251
265
  /** E23: the VERIFIED principal (gatedPrincipal) that may answer this run's elicitations — matches the respond
252
- * owner-gate's identity source (NOT the cost-attribution `principal`, which on a direct door is the spoofable
266
+ * owner-gate's identity source (NOT the cost-attribution `principal`, which on a direct door is the self-asserted
253
267
  * header → can be null while a JWT principal exists). Falls back to the run owner when not supplied. */
254
268
  elicitOwner?: string | null,
255
269
  /** E18 resume-at: capture the turn's (firstTextEventId → leaf entryId) anchor at each turn_end. Built by the caller
package/dist/runs.js CHANGED
@@ -73,6 +73,14 @@ export function resumeAtHttpStatus(result) {
73
73
  return 403;
74
74
  if (result.errorCode === "memory.admission_required")
75
75
  return 503;
76
+ if (result.errorCode === "memory.capture_optout_denied")
77
+ return 403;
78
+ if (result.errorCode === "memory.capture_optout_unpersisted")
79
+ return 503;
80
+ if (result.errorCode === "config.memory_capture_spelling")
81
+ return 400;
82
+ if (result.errorCode === "config.memory_capture_unsupported")
83
+ return 409;
76
84
  if (!(result.errorCode?.startsWith("resume_at.") ?? false))
77
85
  return undefined;
78
86
  return result.errorCode === "resume_at.not_found" ? 404 : 422;
@@ -176,6 +184,17 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
176
184
  nowMonotonicMs: performance.now(),
177
185
  });
178
186
  const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
187
+ if (approval) {
188
+ const coordinator = approval.coordinator;
189
+ spec.onAsk = approvalLeg.windowZero
190
+ ? () => Promise.resolve(coordinator.unattendedAskOutcome())
191
+ : coordinator.boundAsk({
192
+ owner: elicitOwner ?? owner,
193
+ taskId,
194
+ ...(spec.sessionId ? { sessionId: spec.sessionId } : {}),
195
+ ...(approvalLeg.active ? { legKey: "", ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}) } : {}),
196
+ });
197
+ }
179
198
  const heartbeat = setInterval(() => {
180
199
  void runStore.heartbeat(taskId, owner).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "heartbeat" }); });
181
200
  if (!cancelCtrl.signal.aborted) {
@@ -66,7 +66,7 @@ export interface EntitlementsResolverOpts {
66
66
  * `allowObservers`(observer 开闸线,core 1.270.0/1.273.0):**极性与 allow* 相反** —
67
67
  * core 是 `=== true` 显式 opt-in(默认 OFF,undefined ships dark)。registry-core 0.10.3 起该键已收编进
68
68
  * `EntitlementRuntimeCaps` 类型(此前 0.10.2 走 unknown 宽读透传,现为类型直读,行为零变)。
69
- * **缺键=不写键**(绝不把缺键补成显式 false —— core 是唯一默认源,补 false 语义上等价但会伪造「center
69
+ * **缺键=不写键**(绝不把缺键补成显式 false —— core 是唯一默认源,补 false 语义上等价但会自造「center
70
70
  * 说过话」的显式判决,污染 env-baseline 合成序,见 applyObserverEnvOptIn)。非 boolean 虽然骗不过 core
71
71
  * 的 `=== true`(不会误 GRANT),但 PRESENT-非-boolean 与 allow* 同属同一 body 的契约破损信号 → 同款 THROW。
72
72
  *
@@ -135,6 +135,41 @@ export declare function createPrincipalEntitlementsClient(opts: EntitlementsReso
135
135
  export declare function createEntitlementsResolver(opts: EntitlementsResolverOpts): (principal: string | undefined) => Promise<RuntimeCaps | undefined>;
136
136
  /** core's `RunnerDeps.runtimeCapsResolver` seam signature (types.d.ts): sync or async, undefined = no caps. */
137
137
  export type EntitlementsResolverFn = (principal: string | undefined) => RuntimeCaps | undefined | Promise<RuntimeCaps | undefined>;
138
+ /**
139
+ * design/383 §3.1(S-49 S-1 / S-50 §1.2)—— `RuntimeCaps.allowMemoryOptOut` 的 **verdict 源**(本地腿)。
140
+ * 唯一实现 = `plugins/memory-optout-grant-store-sql.ts`(三层折叠:per-principal 行 → 部署缺省哨兵行 →
141
+ * 代码缺省 allow,所以健康的源**恒答显式 boolean**;`principal` 缺席=匿名/系统 run,只看缺省行)。
142
+ * 契约:答非 boolean 或 throw = **面故障**(见 {@link applyMemoryOptOutGrant} 的处置)。
143
+ */
144
+ export interface MemoryOptOutVerdictSource {
145
+ resolveAllow(principal: string | undefined): Promise<boolean>;
146
+ }
147
+ /**
148
+ * design/383 §3.1 **F2 例外臂**(server 阻断级,采;core 7.0.0 `RuntimeCaps.allowMemoryOptOut` JSDoc 的
149
+ * FAULT-TRANSIT CONTRACT 逐字):把本地 grant 腿的 verdict 合成进 caps resolver —— **与 allowWorkflows 家族
150
+ * 反向的例外臂,逐字注释**:
151
+ *
152
+ * · 本文件 `load()` 的 catch 臂对 center 硬失败铸 `{allowWorkflows:false, allowFork:false}`(fail-closed
153
+ * 降级:放大面上「故障 = 拒」是安全方向)。**本键禁止照抄**:隐私轴上「拒」= 把用户明说不要记的会话
154
+ * 记进去(不可逆),而「错误放行」只是少记一次会话(可恢复)。core 因此把 `open` 姿态的故障臂定成
155
+ * **放行 + 具名披露**,把 `governed` 姿态的故障臂定成**拒跑**;两臂都以「故障」为输入。把故障编成
156
+ * 显式 `false` = 把「故障」伪装成「显式 denied verdict」,遮蔽这两条分臂 —— 在 `open` 下恰是
157
+ * 极性反转(故障走了「显式拒」臂)。
158
+ * · 故障过境形写死:本键故障 = **键缺席**(对象上不写键;core 读作 `value:undefined`)。不选 throw
159
+ * 透传是因为 throw 会把**同一次**解析里 center 已答好的 allowWorkflows/allowFork 一并作废(core 的
160
+ * resolver-throw 降级臂),那是把一条腿的故障扩成整车的故障。缺席的代价如实记:`open` 下 core 读
161
+ * 「clean undefined」不铸 `faultDisclosure`(那句披露只给 throw/非 boolean),所以**本层**用 `onFault`
162
+ * 打一行 warn 补上披露;`governed` 下缺席 = verdict absent = 拒跑,方向正确。
163
+ * · 源答非 boolean 同判故障(契约:never truthiness-read)。
164
+ * · 无源 ⇒ 原 resolver **同一引用**透传(零行为差;多数部署路径)。
165
+ * · `principal` 缺席也问源:部署缺省行那一层对匿名 run 同样成立(governed 部署的匿名 run 也该被
166
+ * 一个显式 verdict 罩住,而不是因为「没 principal」结构性拿不到 verdict 而被拒跑)。
167
+ *
168
+ * 双向 G 钉在 `test/memory-capture-optout-lane.test.ts` ②:故障 ⇒ 键缺席;显式 false ⇒ 键=false。
169
+ */
170
+ export declare function applyMemoryOptOutGrant(base: EntitlementsResolverFn | undefined, source: MemoryOptOutVerdictSource | undefined, opts: {
171
+ onFault: (err: Error, principal: string | undefined) => void;
172
+ }): EntitlementsResolverFn | undefined;
138
173
  /**
139
174
  * observer 开闸线的 env 半场:`EXPERIMENTAL_OBSERVER_AGENTS=true` 时把
140
175
  * `RuntimeCaps.allowObservers: true` 作为**部署基线**合成进 caps resolver(core 1.270.0/1.273.0 的
@@ -1,6 +1,7 @@
1
1
  import { fetchPrincipalCaps } from "./config-center/facade.js";
2
2
  import { HttpError } from "./security.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
+ import { recordFailOpen } from "./observability/fail-open.js";
4
5
  const defaultLogger = createLogger();
5
6
  const executionLaneDefaultTrace = new Map();
6
7
  export function resetExecutionLaneDefaultTraceForTest() {
@@ -156,6 +157,30 @@ export function createPrincipalEntitlementsClient(opts) {
156
157
  export function createEntitlementsResolver(opts) {
157
158
  return createPrincipalEntitlementsClient(opts).resolveRuntimeCaps;
158
159
  }
160
+ export function applyMemoryOptOutGrant(base, source, opts) {
161
+ if (source === undefined)
162
+ return base;
163
+ return async (principal) => {
164
+ const caps = base !== undefined ? await base(principal) : undefined;
165
+ let allow;
166
+ try {
167
+ const v = await source.resolveAllow(principal);
168
+ if (typeof v !== "boolean")
169
+ throw new Error(`memory opt-out grant source answered a non-boolean (${typeof v}) — treated as a fault, never truthiness-read`);
170
+ allow = v;
171
+ }
172
+ catch (err) {
173
+ try {
174
+ opts.onFault(err instanceof Error ? err : new Error(String(err)), principal);
175
+ }
176
+ catch (hookErr) {
177
+ recordFailOpen("server.memory-optout-grant.onfault-hook-threw", String(hookErr));
178
+ }
179
+ return caps;
180
+ }
181
+ return { ...(caps ?? {}), allowMemoryOptOut: allow };
182
+ };
183
+ }
159
184
  export function applyObserverEnvOptIn(centerResolver, opts) {
160
185
  if (!opts.experimentalObserverAgents)
161
186
  return centerResolver;