@sema-agent/server 7.53.0 → 7.55.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 (72) hide show
  1. package/USAGE.md +5 -0
  2. package/dist/approval-ask-audit-store.d.ts +129 -0
  3. package/dist/approval-ask-audit-store.js +284 -0
  4. package/dist/approval-card.d.ts +18 -6
  5. package/dist/approval-card.js +2 -2
  6. package/dist/approval-reconciler.d.ts +2 -1
  7. package/dist/approval-reconciler.js +1 -1
  8. package/dist/boot/config-center.js +2 -2
  9. package/dist/boot/coordinators.d.ts +2 -0
  10. package/dist/boot/coordinators.js +18 -1
  11. package/dist/boot/leader.js +2 -0
  12. package/dist/boot/reapers.d.ts +2 -1
  13. package/dist/boot/resolve-spec.js +1 -3
  14. package/dist/boot/runner-deps.d.ts +20 -0
  15. package/dist/boot/runner-deps.js +14 -8
  16. package/dist/boot/shutdown.js +1 -1
  17. package/dist/boot/stores.js +8 -1
  18. package/dist/capabilities/team.d.ts +10 -9
  19. package/dist/config-center/apply-effective.d.ts +25 -26
  20. package/dist/config-center/apply-effective.js +28 -66
  21. package/dist/config-center/effective-keys.d.ts +30 -0
  22. package/dist/config-center/effective-keys.js +49 -0
  23. package/dist/config-center/facade.d.ts +3 -3
  24. package/dist/config-center/facade.js +1 -1
  25. package/dist/config-center/restart-signal.js +3 -9
  26. package/dist/config-center/types.d.ts +19 -40
  27. package/dist/config-lkg.js +7 -1
  28. package/dist/config-provider.js +1 -1
  29. package/dist/device-enrollment.d.ts +5 -3
  30. package/dist/device-store.d.ts +81 -3
  31. package/dist/device-store.js +37 -0
  32. package/dist/device-ws-hub.d.ts +15 -2
  33. package/dist/device-ws-hub.js +31 -5
  34. package/dist/http/routes/approvals-assistant.js +1 -0
  35. package/dist/http/routes/devices.d.ts +64 -0
  36. package/dist/http/routes/devices.js +173 -0
  37. package/dist/http/server.d.ts +14 -0
  38. package/dist/http/server.js +32 -2
  39. package/dist/http/wire-types.d.ts +19 -0
  40. package/dist/leader/wire.d.ts +12 -0
  41. package/dist/leader/wire.js +6 -5
  42. package/dist/main.js +3 -1
  43. package/dist/observability/fail-open.d.ts +20 -0
  44. package/dist/observability/fail-open.js +20 -0
  45. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  46. package/dist/plugins/approval-ask-store-memory.js +20 -3
  47. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  48. package/dist/plugins/approval-ask-store-sql.js +41 -10
  49. package/dist/plugins/device-store-sql.d.ts +38 -1
  50. package/dist/plugins/device-store-sql.js +82 -2
  51. package/dist/plugins/pg-pool.d.ts +29 -2
  52. package/dist/plugins/pg-pool.js +45 -2
  53. package/dist/plugins/remote-env-device.d.ts +4 -2
  54. package/dist/plugins/remote-env-device.js +12 -2
  55. package/dist/plugins/roster-store-sql.d.ts +1 -3
  56. package/dist/plugins/sql-errors.d.ts +12 -0
  57. package/dist/plugins/sql-errors.js +10 -0
  58. package/dist/plugins/store-backend.js +1 -1
  59. package/dist/plugins/tidb-pool.d.ts +13 -0
  60. package/dist/runs.js +1 -0
  61. package/dist/runtime-governance.d.ts +4 -0
  62. package/dist/runtime-governance.js +23 -1
  63. package/dist/tool-approval.d.ts +63 -39
  64. package/dist/tool-approval.js +322 -109
  65. package/dist/trace/engine-notice-wire.d.ts +1 -1
  66. package/dist/trace/engine-notice-wire.js +2 -0
  67. package/dist/trace/injection-tier.d.ts +11 -21
  68. package/dist/trace/injection-tier.js +3 -4
  69. package/dist/trace/ledger-events.d.ts +9 -0
  70. package/dist/trace/project.d.ts +1 -0
  71. package/dist/trace/project.js +1 -0
  72. package/package.json +3 -3
@@ -0,0 +1,173 @@
1
+ import { sendError, sendJson } from "../send.js";
2
+ import { explicitOperatorOk, gatedPrincipal } from "../principal-gate.js";
3
+ import { ssoVerifiedScope } from "../../security.js";
4
+ import { assertDeviceIdShape, assertRootSessionIdShape, sameDeviceOwner, } from "../../device-store.js";
5
+ const DEVICE_NOT_FOUND_MESSAGE = "device not found";
6
+ const NO_DEVICE_DOMAIN_MESSAGE = "device management needs a caller domain: this credential carries no verified tenant scope claim (registry auth-bridge JWT `scope`) and is not an explicit operator — connect through the registry auth bridge, or configure OPERATOR_PRINCIPALS";
7
+ const DEVICE_LANE_REQUIRED_MESSAGE = "device management requires the device execution lane (REMOTE_EXEC=device with a SQL store backend) — this deployment has no device lane wired";
8
+ const _deviceRouteCodesPinned = ["not_found.device", "device.rebind_rev_conflict", "device.rebind_active_run"];
9
+ void _deviceRouteCodesPinned;
10
+ function sendDeviceNotFound(res) {
11
+ sendError(res, 404, "not_found.device", DEVICE_NOT_FOUND_MESSAGE);
12
+ }
13
+ export function deviceOwnerDomainOf(req, config) {
14
+ const subject = gatedPrincipal(req, config);
15
+ if (subject === undefined)
16
+ return undefined;
17
+ const tenant = ssoVerifiedScope(req);
18
+ if (tenant === undefined)
19
+ return undefined;
20
+ return { tenant, subject };
21
+ }
22
+ function wellFormedKey(assert, v) {
23
+ try {
24
+ assert(v);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function deviceListRow(row, online) {
32
+ return {
33
+ deviceId: row.deviceId,
34
+ ...(row.displayName !== "" ? { name: row.displayName } : {}),
35
+ status: online.has(row.deviceId) ? "online" : "offline",
36
+ ...(row.lastSeenAtMs !== null ? { lastSeenAt: new Date(row.lastSeenAtMs).toISOString() } : {}),
37
+ };
38
+ }
39
+ function boundDeviceOf(row) {
40
+ return { deviceId: row.deviceId, rev: row.rev, boundAt: new Date(row.boundAtMs).toISOString() };
41
+ }
42
+ export async function handleDevices(req, res, url, ctx) {
43
+ const miss = { fell: false };
44
+ await handleDevicesBody(req, res, url, ctx, miss);
45
+ return !miss.fell;
46
+ }
47
+ async function handleDevicesBody(req, res, url, ctx, miss) {
48
+ const { deps } = ctx;
49
+ const { readJson, safeDecode } = ctx.helpers;
50
+ if (url !== "/v1/devices" && !url.startsWith("/v1/devices/")) {
51
+ miss.fell = true;
52
+ return;
53
+ }
54
+ const listHit = req.method === "GET" && url === "/v1/devices";
55
+ const readM = req.method === "GET" ? /^\/v1\/devices\/sessions\/([^/]+)$/.exec(url) : null;
56
+ const rebindM = req.method === "POST" ? /^\/v1\/devices\/sessions\/([^/]+)\/rebind$/.exec(url) : null;
57
+ if (!listHit && !readM && !rebindM) {
58
+ miss.fell = true;
59
+ return;
60
+ }
61
+ const principal = gatedPrincipal(req, deps.config);
62
+ if (deps.config.requirePrincipal && principal === undefined) {
63
+ sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
64
+ return;
65
+ }
66
+ const store = deps.deviceStore;
67
+ const hub = deps.deviceHub;
68
+ if (!store || !hub) {
69
+ sendError(res, 501, "capability.device_lane_required", DEVICE_LANE_REQUIRED_MESSAGE);
70
+ return;
71
+ }
72
+ const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
73
+ const domain = deviceOwnerDomainOf(req, deps.config);
74
+ if (listHit) {
75
+ if (!operator && domain === undefined) {
76
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
77
+ return;
78
+ }
79
+ const rows = operator ? await store.listDevicesUnscoped() : await store.listDevicesByOwner(domain);
80
+ const online = new Set(hub.connectedDeviceIds());
81
+ sendJson(res, 200, { devices: rows.filter((r) => r.status !== "revoked").map((r) => deviceListRow(r, online)) });
82
+ return;
83
+ }
84
+ const seg = (readM ?? rebindM)[1];
85
+ const decoded = safeDecode(seg);
86
+ if (decoded === null) {
87
+ sendError(res, 400, "request.path_malformed", "session id segment is not valid percent-encoding");
88
+ return;
89
+ }
90
+ if (!wellFormedKey(assertRootSessionIdShape, decoded)) {
91
+ sendError(res, 400, "request.id_invalid", "invalid session id segment (device binding keys are byte-exact: non-empty, no control characters or surrounding whitespace, at most 128 bytes)");
92
+ return;
93
+ }
94
+ const rootSessionId = decoded;
95
+ if (readM) {
96
+ if (!operator && domain === undefined) {
97
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
98
+ return;
99
+ }
100
+ const binding = await store.getSessionBinding(rootSessionId);
101
+ if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
102
+ sendDeviceNotFound(res);
103
+ return;
104
+ }
105
+ sendJson(res, 200, { rootSessionId, boundDevice: boundDeviceOf(binding) });
106
+ return;
107
+ }
108
+ if (!operator && domain === undefined) {
109
+ sendError(res, 403, "auth.forbidden", NO_DEVICE_DOMAIN_MESSAGE);
110
+ return;
111
+ }
112
+ const binding = await store.getSessionBinding(rootSessionId);
113
+ if (!binding || (!operator && !sameDeviceOwner(binding.owner, domain))) {
114
+ sendDeviceNotFound(res);
115
+ return;
116
+ }
117
+ let body;
118
+ try {
119
+ body = (await readJson(req));
120
+ }
121
+ catch {
122
+ sendError(res, 400, "request.invalid_json", "invalid JSON body");
123
+ return;
124
+ }
125
+ if (typeof body !== "object" || body === null || typeof body.toDeviceId !== "string" || body.expectedRev === undefined) {
126
+ sendError(res, 400, "request.body_shape", "body must be { toDeviceId: string, expectedRev: number } — expectedRev is the binding rev read from GET /v1/devices/sessions/{rootSessionId} (explicit prior state; two concurrent operators must not silently stomp each other)");
127
+ return;
128
+ }
129
+ if (typeof body.expectedRev !== "number" || !Number.isInteger(body.expectedRev) || body.expectedRev < 0) {
130
+ sendError(res, 400, "request.field_invalid", "expectedRev must be a non-negative integer");
131
+ return;
132
+ }
133
+ if (!wellFormedKey(assertDeviceIdShape, body.toDeviceId)) {
134
+ sendError(res, 400, "request.field_invalid", "toDeviceId is not a valid device id (byte-exact key: non-empty, no control characters or surrounding whitespace, at most 64 bytes)");
135
+ return;
136
+ }
137
+ const toDeviceId = body.toDeviceId;
138
+ const expectedRev = body.expectedRev;
139
+ const target = await store.readDeviceForAdmission(toDeviceId, binding.owner);
140
+ if (!target || target.row.status !== "active") {
141
+ sendDeviceNotFound(res);
142
+ return;
143
+ }
144
+ if (!deps.runStore) {
145
+ sendError(res, 501, "capability.run_store_required", "device rebind needs the run store to judge the no-active-run door (a durable, cross-replica criterion) — this deployment has no run store wired");
146
+ return;
147
+ }
148
+ const activeTaskId = await deps.runStore.getActiveTaskId(rootSessionId);
149
+ if (activeTaskId) {
150
+ sendError(res, 409, "device.rebind_active_run", "this session has a non-terminal run — rebinding now would leave in-flight work addressed at another workspace; cancel the run (or let it finish), then retry", { activeTaskId });
151
+ return;
152
+ }
153
+ const result = await store.rebindSession({ rootSessionId, toDeviceId, expectedRev, owner: binding.owner });
154
+ switch (result.outcome) {
155
+ case "rebound":
156
+ case "idempotent":
157
+ sendJson(res, 200, { rootSessionId, deviceId: result.row.deviceId, rev: result.row.rev, workspaceCarryover: "none" });
158
+ return;
159
+ case "rev_conflict":
160
+ sendError(res, 409, "device.rebind_rev_conflict", "expectedRev does not match the binding's current rev — the session↔device binding changed since it was read; re-read it and retry with currentRev", { currentRev: result.row.rev });
161
+ return;
162
+ case "binding_not_found":
163
+ case "device_not_found":
164
+ sendDeviceNotFound(res);
165
+ return;
166
+ default: {
167
+ const unhandled = result;
168
+ void unhandled;
169
+ throw new Error("device rebind: unknown store outcome");
170
+ }
171
+ }
172
+ }
173
+ //# sourceMappingURL=devices.js.map
@@ -18,6 +18,7 @@ import { type FleetEventBus } from "../fleet/fleet-bus.js";
18
18
  import type { ElicitationCoordinator } from "../elicitation.js";
19
19
  import type { QuestionCoordinator } from "../question.js";
20
20
  import { type ParkedAskRedeem, type ToolApprovalCoordinator } from "../tool-approval.js";
21
+ import type { ApprovalAskAuditReadFace } from "../approval-ask-audit-store.js";
21
22
  import type { PlanCacheProbe } from "../plan-cache-probe.js";
22
23
  import type { Logger } from "../observability/logger.js";
23
24
  import type { Metrics } from "../observability/metrics.js";
@@ -204,6 +205,11 @@ export interface ServiceCoordinatorDeps {
204
205
  * `POST /v1/tool-approvals/:id/respond` + the per-run ALS wrap on the SYNC streaming leg (live-only by design —
205
206
  * every other leg keeps core's fail-closed headless auto-deny, [ref]⑤/[ref]④). */
206
207
  toolApproval?: ToolApprovalCoordinator;
208
+ /** [ref] 裁 (c) 审计半场:local 车道的 File ask **审计**读面(`GET /v1/approvals` 的 additive 键
209
+ * `crashConverged` 数据源)。在场 ⇔ `DB_BACKEND=local` ∧ 审批面开着(装配 boot/coordinators.ts);
210
+ * SQL 车道恒缺席(那里有真 ask 店,[ref])—— 键缺席 = 本部署无此面,`[]` = 有面且无崩溃残账,
211
+ * 两义与 `livePending` 同律可判别。 */
212
+ approvalAskAudit?: ApprovalAskAuditReadFace;
207
213
  /** SVC-5 ([ref] CORE-5 #6): the process-local registry of STEERABLE workflow-agent handles
208
214
  * (`ctx.agentStream`) live on THIS replica. Enables POST /v1/workflows/:id/agents/:label/steer. See
209
215
  * workflow-agent-steer.ts for the handle-visibility seam status (the registry is real + ready; core does not
@@ -263,6 +269,14 @@ export interface ServiceSeamDeps {
263
269
  * `REMOTE_EXEC=device` 拒启门对齐)属**车A-4/端点车**。在那之前本键只有测试口消费,不是死枝。
264
270
  */
265
271
  deviceHub?: import("../device-ws-hub.js").DeviceWsHub;
272
+ /**
273
+ * O4/S-4([ref]):device lane 四表店 —— `/v1/devices/*` 管理面(routes/devices.ts:列表投影 /
274
+ * 绑定读面 / 显式换绑动词)的持久真源。main.ts 与 {@link deviceHub} 同源装配
275
+ * (`deviceLane?.store`,`REMOTE_EXEC=device` 时在场);缺席 ⇒ 该族路由 501
276
+ * `capability.device_lane_required`(管理面在场性由路由自身答 —— capabilities 刻意不加新位,
277
+ * v2 换绑稿 §7)。
278
+ */
279
+ deviceStore?: import("../device-store.js").DeviceStore;
266
280
  /** Audit回溯: current context (+ summary) for a session, plus its owner. Enables GET /v1/sessions/:id. */
267
281
  sessionAudit?: (sessionId: string) => Promise<({
268
282
  owner: string | null;
@@ -43,6 +43,7 @@ import { handleDiagnostics } from "./routes/diagnostics.js";
43
43
  import { handleAdoption } from "./routes/adoption.js";
44
44
  import { handleRetention } from "./routes/retention-ops.js";
45
45
  import { handleAdminDrain } from "./routes/admin-drain.js";
46
+ import { handleDevices } from "./routes/devices.js";
46
47
  import { handleAdminConfigRefresh } from "./routes/admin-config-refresh.js";
47
48
  import { handleConfigCatalog } from "./routes/config-catalog.js";
48
49
  import { handleMemoryOptOut } from "./routes/admin-memory-optout.js";
@@ -149,6 +150,7 @@ const ROUTE_DOMAINS = [
149
150
  handleAdoption,
150
151
  handleRetention,
151
152
  handleAdminDrain,
153
+ handleDevices,
152
154
  handleAdminConfigRefresh,
153
155
  handleConfigCatalog,
154
156
  handleMemoryOptOut,
@@ -466,6 +468,8 @@ export function createHttpServer(rawDeps) {
466
468
  return;
467
469
  if (await handleAdminDrain(req, res, url, ctx))
468
470
  return;
471
+ if (await handleDevices(req, res, url, ctx))
472
+ return;
469
473
  if (await handleAdminConfigRefresh(req, res, url, ctx))
470
474
  return;
471
475
  if (await handleConfigCatalog(req, res, url, ctx))
@@ -1079,6 +1083,25 @@ export function createHttpServer(rawDeps) {
1079
1083
  },
1080
1084
  };
1081
1085
  }
1086
+ const s57RowCallId = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.toolCallId : undefined;
1087
+ if (binding?.boundCallId !== undefined && s57RowCallId && binding.boundCallId !== s57RowCallId) {
1088
+ let s57CurrentPending;
1089
+ if (decider !== undefined && cp.status === "pending" && cp.sessionId === sessionId && cp.pendingAction.kind === "tool_approval") {
1090
+ s57CurrentPending = {
1091
+ toolName: cp.pendingAction.toolName,
1092
+ boundCallId: s57RowCallId,
1093
+ ...(cp.pendingAction.boundInputHash ? { boundInputHash: cp.pendingAction.boundInputHash } : {}),
1094
+ };
1095
+ }
1096
+ return {
1097
+ status: 409,
1098
+ body: {
1099
+ error: "the approval you are deciding is no longer current (already resolved or superseded since you viewed it)",
1100
+ errorCode: "approval_stale",
1101
+ ...(s57CurrentPending ? { currentPending: s57CurrentPending } : {}),
1102
+ },
1103
+ };
1104
+ }
1082
1105
  const paForGrant = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction : undefined;
1083
1106
  const loadedGrantTool = paForGrant !== undefined && paForGrant.toolName && paForGrant.toolName !== "AskUserQuestion"
1084
1107
  ? paForGrant.toolName
@@ -1648,6 +1671,7 @@ export function createHttpServer(rawDeps) {
1648
1671
  abortSignal: cancelCtrl.signal,
1649
1672
  sessionId,
1650
1673
  emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
1674
+ emitRevoke: (f) => approvalEmit(f),
1651
1675
  legKey,
1652
1676
  ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
1653
1677
  }, driveResume)
@@ -2260,7 +2284,8 @@ export function isCredentialGatedRewrite(method, url) {
2260
2284
  url === "/v1/memory/import" ||
2261
2285
  url === "/v1/memory/export" ||
2262
2286
  url === "/v1/memory/erase" ||
2263
- url.startsWith("/v1/memory/origin/entries/"));
2287
+ url.startsWith("/v1/memory/origin/entries/") ||
2288
+ url.startsWith("/v1/devices/"));
2264
2289
  }
2265
2290
  if (m === "PUT")
2266
2291
  return url === "/v1/admin/memory-optout" || url.startsWith("/v1/admin/memory-optout/");
@@ -2270,7 +2295,9 @@ export function isCredentialGatedRewrite(method, url) {
2270
2295
  return (/^\/v1\/memory\/entries\/[^/]+\/provenance$/.test(url) ||
2271
2296
  url === "/v1/memory/origin/external" ||
2272
2297
  url === "/v1/memory/origin/clearances" ||
2273
- url === "/v1/admin/memory-optout");
2298
+ url === "/v1/admin/memory-optout" ||
2299
+ url === "/v1/devices" ||
2300
+ /^\/v1\/devices\/sessions\/[^/]+$/.test(url));
2274
2301
  }
2275
2302
  return false;
2276
2303
  }
@@ -2315,6 +2342,8 @@ const ROUTE_LABEL_PATTERNS = [
2315
2342
  [/^\/v1\/tasks\/[^/]+\/tool-results\/[^/]+$/, "/v1/tasks/:id/tool-results/:ref"],
2316
2343
  [/^\/v1\/tasks\/[^/]+\/(turns|stream|artifacts)$/, "/v1/tasks/:id/$sub"],
2317
2344
  [/^\/v1\/leader\/[^/]+$/, "/v1/leader/:id"],
2345
+ [/^\/v1\/devices\/sessions\/[^/]+\/rebind$/, "/v1/devices/sessions/:id/rebind"],
2346
+ [/^\/v1\/devices\/sessions\/[^/]+$/, "/v1/devices/sessions/:id"],
2318
2347
  [/^\/v1\/attachments\/[^/]+$/, "/v1/attachments/:id"],
2319
2348
  [/^\/v1\/capabilities\/scenarios\/[^/]+$/, "/v1/capabilities/scenarios/:name"],
2320
2349
  [/^\/v1\/adoption\/[^/]+$/, "/v1/adoption/:id"],
@@ -2333,6 +2362,7 @@ const ROUTE_LABEL_LITERALS = new Set([
2333
2362
  "/v1/assistant/inbox", "/v1/assistant/tasks", "/v1/usage", "/v1/policy", "/v1/capabilities",
2334
2363
  "/v1/images", "/v1/images/bakes", "/v1/images/bakes/claim", "/v1/images/select", "/v1/images/register",
2335
2364
  "/v1/workflows", "/v1/attachments", "/v1/leader", "/v1/outcomes", "/v1/side-query",
2365
+ "/v1/devices",
2336
2366
  "/v1/fleet/stream",
2337
2367
  "/v1/memory/export", "/v1/sendfile-links",
2338
2368
  "/v1/memory/import",
@@ -350,6 +350,25 @@ export interface TaskRequestBody {
350
350
  * through the same way: a boolean rides, absent/garbage ⇒ key omitted (core's default = interactive). Rides the
351
351
  * persisted body onto resume legs. */
352
352
  oneShot?: boolean;
353
+ /** [ref]①/[ref]② (core 1.296 three-state knob): per-request mount toggle for the interactive ask tools —
354
+ * `false` unmounts AskUserQuestion for this submission (the headless `-p` posture; `oneShot` above is its
355
+ * sibling, same passthrough), `true` forces the mount, absent = core's own default (delivery-face probing).
356
+ * A boolean rides the spec unchanged (boot/resolve-spec.ts); a present non-boolean is a fail-loud 400 on
357
+ * submit (server.ts prepareSpec), and resume legs replay the persisted body through the same defensive
358
+ * typeof read. Declared per [ref] 件1 ([ref] boundary-must-schema): the submit chain reads this key, and an
359
+ * undeclared read is a bare cast the exported type cannot audit (embedders could not even write it). */
360
+ interactiveTools?: boolean;
361
+ /** The caller's per-request settings stamp (the client-side `SemaSettings` shape). The KEY SET is deliberately
362
+ * NOT mirrored here — a hand-written second shape on this interface would be the classic mirror-drift form.
363
+ * Per-key ownership is split, not single: `parseTaskSettings` (src/task-settings.ts) defensively owns the
364
+ * shared settings keys on every path, while `settings.webSearch` has its OWN validator on the single-user
365
+ * scenario lane (capabilities/scenarios.ts `webSearchConfigFromSettings` — the multi-tenant lane ignores it
366
+ * by design). The declared type is the INTENDED caller form (sibling convention: `oneShot` declares boolean,
367
+ * runtime drops garbage): a non-array object bag, or omit the key. Runtime is more tolerant than the type on
368
+ * purpose — a `null` reads as absence on both the fresh-submit wall (prepareSpec) and replay
369
+ * (parseTaskSettings), and malformed forms 400 on submit / no-op on replay. ([ref] 件1 leftover key,
370
+ * declared 2026-08-31; ownership wording per codex adversarial round.) */
371
+ settings?: Record<string, unknown>;
353
372
  /** [R3] Caller-supplied per-request MCP servers (the TOC client's local `.mcp.json`), aligned to core
354
373
  * `McpServerSpec`. 🔒 honored on any SINGLE-USER deployment (task-mcp.ts `mcpInjectionHonored` = `requirePrincipal!==true`)
355
374
  * — the requester is the super-admin of their OWN worker (CC-parity), on ANY execution lane (the stdio MCP runs on the
@@ -1,6 +1,7 @@
1
1
  import { type Brain, type Model, type ModelRoles, type ModelPricing, type TaskSpec, type ExecutionEnvFactory, type RemoteExecutionEnv, type ToolResultStore, type SessionStore, type RunnerDeps } from "@sema-agent/core";
2
2
  import type { CheckpointStoreFull } from "../plugins/store-backend.js";
3
3
  import { type LeaderDeps, type LeaderResult } from "./leader.js";
4
+ import { type DeploymentPostureSeats } from "../boot/runner-deps.js";
4
5
  import type { LeaderRequestBody } from "./endpoint.js";
5
6
  export interface LeaderWireConfig {
6
7
  /** Static-env compat mode (E2B only): the leader provisions/owns each env. Required when `envFactory` unset. */
@@ -36,6 +37,17 @@ export interface LeaderWireConfig {
36
37
  * 搬过来:一个进程里「这台部署禁哪些能力、锁了哪些键」只能有一个答案。缺席 = 与接线前逐字相同。
37
38
  */
38
39
  governance?: Pick<RunnerDeps, "compliancePostureResolver" | "lockedConfig" | "retentionPolicy">;
40
+ /**
41
+ * [ref](黑板 [ref])—— **部署姿态座席族**(readFace 四席 `readFace`/`readDenyPatterns`/`readDenyBuiltinTiers`/
42
+ * `readDenyBuiltinExclude` + `memoryDelegationEvidence`/`memoryProvenance`/`memoryCapturePolicy`/
43
+ * `delegationEntryCaps`),**原样递给本车道的每一只 Runner**。唯一属主 = `boot/runner-deps.ts` 的
44
+ * `buildDeploymentPostureSeats(config)`(主 runner / subRunner / run-local 的共享基座展开的就是它),
45
+ * `boot/leader.ts` 把**同一函数、同一 config** 的产物搬过来:一个进程里「这台部署的 READ 面 / 记忆姿态 /
46
+ * 委派 caps」只能有一个答案。缺席 = 展开空对象 = 五处字面量键缺席(与接线前逐字相同)。
47
+ * 修前(≤7.53)这五只 Runner 一席都没有:运维写的 READ_DENY_PATTERNS / MEMORY_DELEGATION_EVIDENCE 等对
48
+ * leader 编排的 planner / worker / repair / conflict 四类任务**静默不生效**(core 内建 deny 表仍在)。
49
+ */
50
+ deploymentPosture?: DeploymentPostureSeats;
39
51
  /** Worker model brain + catalog (the same the service runs tasks on). */
40
52
  brain: Brain;
41
53
  models: Record<string, Model>;
@@ -129,6 +129,7 @@ export function createLeaderRunner(cfg) {
129
129
  const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
130
130
  const onNoticeSeat = createEngineNoticeSeat(cfg.logger);
131
131
  const governanceSeat = cfg.governance ?? {};
132
+ const deploymentPostureSeat = cfg.deploymentPosture ?? {};
132
133
  const mkRepair = (rawEnv, repoDir, oracleFiles) => {
133
134
  if (repairRounds <= 0)
134
135
  return undefined;
@@ -142,7 +143,7 @@ export function createLeaderRunner(cfg) {
142
143
  throw new Error(`oracle still present after remove (${f.path}) — refusing repair (measurement integrity)`);
143
144
  }
144
145
  try {
145
- const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, executionEnv: rawEnv, rootPath: repoDir });
146
+ const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, executionEnv: rawEnv, rootPath: repoDir });
146
147
  const objective = [
147
148
  `The integrated project at ${repoDir} fails its build/test. Make the MINIMAL change to the working tree so this command exits 0 (cd into the repo and run it yourself to confirm):`,
148
149
  ` ${testCmd}`,
@@ -185,7 +186,7 @@ export function createLeaderRunner(cfg) {
185
186
  throw new Error(`oracle still present after remove (${f.path}) — refusing conflict-resolve (measurement integrity)`);
186
187
  }
187
188
  try {
188
- const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, executionEnv: rawEnv, rootPath: repoDir });
189
+ const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, executionEnv: rawEnv, rootPath: repoDir });
189
190
  const objective = [
190
191
  `A parallel worker '${workerId}' ported a module on its own branch, but applying its patch onto the already-integrated tree at ${repoDir} produced a MERGE CONFLICT (git apply --3way). The OTHER workers' patches already applied cleanly — integrate THIS worker's changes too, resolving the overlap.`,
191
192
  `Resolve EVERY conflict in the working tree: open each file containing conflict markers (<<<<<<< / ======= / >>>>>>>) and merge BOTH sides' real intent (keep both workers' behaviour — never drop one side just to make it apply). Then apply any rejected hunks recorded in *.rej files by hand, and DELETE every *.rej and *.orig file.`,
@@ -248,7 +249,7 @@ export function createLeaderRunner(cfg) {
248
249
  if (Array.isArray(body.subtasks) && body.subtasks.length > 0) {
249
250
  return validateSubtasks(body.subtasks);
250
251
  }
251
- const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat });
252
+ const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat });
252
253
  const runRoute = async (prompt) => {
253
254
  const res = await planRunner.runTaskStream({
254
255
  objective: prompt,
@@ -338,7 +339,7 @@ export function createLeaderRunner(cfg) {
338
339
  workerId: sub.workerId, sessionId, branch: sub.branch,
339
340
  baseSha,
340
341
  runner: keepCtxWarm(new Runner({
341
- brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat,
342
+ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat,
342
343
  ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}),
343
344
  ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}),
344
345
  executionEnvFactory: (ctx) => withStaging(envFactory(ctx), stage, async (e) => {
@@ -358,7 +359,7 @@ export function createLeaderRunner(cfg) {
358
359
  const baseSha = await sh(env)(`cd ${repo} && git rev-parse HEAD`);
359
360
  return {
360
361
  workerId: sub.workerId, sessionId, branch: sub.branch, baseSha,
361
- runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
362
+ runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...governanceSeat, ...deploymentPostureSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
362
363
  diffEnv: env,
363
364
  destroy: () => env.destroy().then(() => { }),
364
365
  spec: { ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec },
package/dist/main.js CHANGED
@@ -165,7 +165,7 @@ async function main() {
165
165
  };
166
166
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
167
167
  let parkedAskRedeem;
168
- const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
168
+ const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, approvalAskAudit, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
169
169
  config, logger, backend, sendUserFileTaskEnvs, ruleConsent,
170
170
  getParkedRedeem: () => parkedAskRedeem,
171
171
  ruleScopeRootFor: (sessionId) => cardRuleScopeRoot(sessionId !== undefined ? perSessionCwd.get(sessionId) : undefined, config),
@@ -591,6 +591,7 @@ async function main() {
591
591
  elicitation: elicitation ? elicitation : undefined,
592
592
  question: question ? question : undefined,
593
593
  toolApproval: toolApproval ? toolApproval : undefined,
594
+ approvalAskAudit: approvalAskAudit ? approvalAskAudit : undefined,
594
595
  workflowAgentRegistry: workflowAgentRegistry ? workflowAgentRegistry : undefined,
595
596
  subagentSteerRegistry,
596
597
  workflowCompletionInbox: workflowCompletionInbox ? workflowCompletionInbox : undefined,
@@ -604,6 +605,7 @@ async function main() {
604
605
  };
605
606
  const seams = {
606
607
  deviceHub: deviceLane?.hub,
608
+ deviceStore: deviceLane?.store,
607
609
  sessionAudit,
608
610
  routeJudgeBrain: brain,
609
611
  purgeSession: purgeSession ? purgeSession : undefined,
@@ -149,6 +149,26 @@ export declare const FAIL_OPEN_TAGS: {
149
149
  readonly cls: "F";
150
150
  readonly note: "S-15 twin 保留条款(core 7.0.0 design/381 §片2 的 SQL 孪生):boundary 提交后的自剪 pass(core 共享选择函数 `fileHistoryBoundariesToKeep` + 既有 `reap` 级联)失败 ⇒ 本轮 boundary **已提交**、rewind 座照常,只是这一轮没剪。放行的是「GC 失败不失败用户这一轮」(core InMemory/File 参照同形 `reap(...).catch(() => {})`,但参照是无声吞,本仓走留痕三件套);最坏后果 = 该 scope 暂时超上限一个 boundary,下一次提交的 pass 按**当前全集**重算、连它一起剪(不累积欠账)。detail 带 scope / entry / 异常文案。";
151
151
  };
152
+ readonly "server.approval.ambiguous-cas.expire": {
153
+ readonly cls: "P-DEBT";
154
+ readonly note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290)→ S-69 收窄(S-25-R1 终局,core design/384 §4.3 七律 / §4.4 键形对表,[5934]):流内 ask 的**窗到期臂**打原子终局 claim(`claimTerminal(expire)`,S-69 前 `expireAsk` CAS)被 reject(抛错 / 超墙钟,什么也证明不了 —— 律 3 K2)之后,有界退避**重发同一 claim**(6 格 ⇒ 至多 7 把,在飞帽 2 按持久 askId 记、撞帽拍不计 attempt、让路探针读同帽同界、帽满或同 askId 尚有在飞操作时探针不计 rung、真相广播同 askId 全部登记(本登记已 done 亦广播)—— codex S-69 R2-1/R3-1/R3-2/R4-1/R5-1/R6-1/R6-2/R7-1;末格睡后仍再发 —— codex R1-F1 同律;总墙钟硬上界 TERMINAL_LADDER_WALL_MS ≈10.15s —— R2-2 修正算术 + R3-3 硬封顶)**全部 reject** ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(A-075.116②:其兜底覆盖本形经确认后本 tag 降 F)。**不计**的形(S-69 起命中条件收窄到「店对整条阶梯持续不可用」):claim 输 ⇒ 败方答案**携真相**(DECIDED ⇒ 按人的决议结算,零补读;PARKING/PARKED ⇒ park 路由;absent = 行不在/错 scope ⇒ K1 零退避投影),迟到回来的 claim 同样自足(迟到赢 ⇒ 副作用补做,迟到输 ⇒ 按真决议结算 —— 此前迟到回调只认 won 的那条残余窗即 S-25-R1,本批闭合);编辑在飞标记在场 ⇒ 有界让路(不重打 claim)、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);阶梯里曾拿到真相而标记已放开 ⇒ 按那份真相结算。detail 带 askId 与 claim 把数。";
155
+ };
156
+ readonly "server.approval.ambiguous-cas.cancel": {
157
+ readonly cls: "P-DEBT";
158
+ readonly note: "S-25 同族第二员 → S-69 收窄:**取消臂**(task signal abort / 连接全灭同路)打 `claimTerminal(cancel)`(STREAM_PENDING→VOID;S-69 前 `transitionAsk` CAS)被 reject 之后,有界重发至多 7 把 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);S-25 起先读行;S-69 起干净输的答案本身携真相、迟到答案同样自足,只有店对整条阶梯持续 reject 才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。";
159
+ };
160
+ readonly "server.approval.ambiguous-cas.unreachable": {
161
+ readonly cls: "P-DEBT";
162
+ readonly note: "S-25 同族第三员 → S-69 收窄:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `claimTerminal(expire)`(S-69 前 `expireAsk` CAS)被 reject 之后,有界重发至多 7 把 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;S-25 起行读出 DECIDED ⇒ 按人的决议结算;S-69 起真相随败方答案回来、迟到答案自足,只有店对整条阶梯持续 reject 才计数。";
163
+ };
164
+ readonly "server.approval-ask-audit.append-failed": {
165
+ readonly cls: "F";
166
+ readonly note: "S-38 裁 (c) 审计半场:local 车道 File ask 审计店(`approval-ask-audit-store.ts`)的**运行期** append(铸造/决议/腿闭/收敛四类行)失败(盘满/只读/fd 失效)⇒ 丢**这一条**审计行,审批路径照常。方向刻意 fail-open:审批可用性不押在审计盘上 —— 盘满时人还得能批,把一次审计写失败升级成审批失败是把「痕迹缺一条」换成「门整个不可用」,方向更坏。boot 期(mkdir/重放)失败则**拒启**(File 店族同律,store 头注成文),不经本 tag。放行的最坏后果 = 该 ask 在崩溃收敛读面上缺席或分臂失真(与 A-075.55 的零痕迹病同向)—— 所以必须留痕:「审计一直在漏写」与「没有审批发生」在读面上同形,不计数则病灶永不显形。detail 带行类与异常文案。";
167
+ };
168
+ readonly "server.pg-pool.idle-client-error": {
169
+ readonly cls: "F";
170
+ readonly note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。";
171
+ };
152
172
  };
153
173
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
154
174
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -140,6 +140,26 @@ export const FAIL_OPEN_TAGS = {
140
140
  cls: "F",
141
141
  note: "S-15 twin 保留条款(core 7.0.0 design/381 §片2 的 SQL 孪生):boundary 提交后的自剪 pass(core 共享选择函数 `fileHistoryBoundariesToKeep` + 既有 `reap` 级联)失败 ⇒ 本轮 boundary **已提交**、rewind 座照常,只是这一轮没剪。放行的是「GC 失败不失败用户这一轮」(core InMemory/File 参照同形 `reap(...).catch(() => {})`,但参照是无声吞,本仓走留痕三件套);最坏后果 = 该 scope 暂时超上限一个 boundary,下一次提交的 pass 按**当前全集**重算、连它一起剪(不累积欠账)。detail 带 scope / entry / 异常文案。",
142
142
  },
143
+ "server.approval.ambiguous-cas.expire": {
144
+ cls: "P-DEBT",
145
+ note: "S-25(设计件 2026-08-31 §3 步骤 3;DEBTS S-25 / #290)→ S-69 收窄(S-25-R1 终局,core design/384 §4.3 七律 / §4.4 键形对表,[5934]):流内 ask 的**窗到期臂**打原子终局 claim(`claimTerminal(expire)`,S-69 前 `expireAsk` CAS)被 reject(抛错 / 超墙钟,什么也证明不了 —— 律 3 K2)之后,有界退避**重发同一 claim**(6 格 ⇒ 至多 7 把,在飞帽 2 按持久 askId 记、撞帽拍不计 attempt、让路探针读同帽同界、帽满或同 askId 尚有在飞操作时探针不计 rung、真相广播同 askId 全部登记(本登记已 done 亦广播)—— codex S-69 R2-1/R3-1/R3-2/R4-1/R5-1/R6-1/R6-2/R7-1;末格睡后仍再发 —— codex R1-F1 同律;总墙钟硬上界 TERMINAL_LADDER_WALL_MS ≈10.15s —— R2-2 修正算术 + R3-3 硬封顶)**全部 reject** ⇒ 按本臂意图投影结算 = park 路由(`\"unavailable\"`;`deny` 政策下 `{allow:false,settledBy:\"timeout\"}`)并留 park 墓碑。方向 fail-closed(无 DECIDED=approve 行绝不执行工具),但行**可能**已被另一副本判成 DECIDED(approve) 而本腿没兑现 —— 人批了、工具不跑、run 再挂起要人再批一次 = 丢一次人类决议,故记债不当合法兜底。补偿:core 侧仍 pending 的 checkpoint(人再批一次就走)+ 跨副本收敛器兜底(A-075.116②:其兜底覆盖本形经确认后本 tag 降 F)。**不计**的形(S-69 起命中条件收窄到「店对整条阶梯持续不可用」):claim 输 ⇒ 败方答案**携真相**(DECIDED ⇒ 按人的决议结算,零补读;PARKING/PARKED ⇒ park 路由;absent = 行不在/错 scope ⇒ K1 零退避投影),迟到回来的 claim 同样自足(迟到赢 ⇒ 副作用补做,迟到输 ⇒ 按真决议结算 —— 此前迟到回调只认 won 的那条残余窗即 S-25-R1,本批闭合);编辑在飞标记在场 ⇒ 有界让路(不重打 claim)、用尽走 park 路由(标记的事不是店的债,codex R1-F2/R2-F2;负终态与带载荷 approve 不受标记压制,R2-F1/R3-F1);阶梯里曾拿到真相而标记已放开 ⇒ 按那份真相结算。detail 带 askId 与 claim 把数。",
146
+ },
147
+ "server.approval.ambiguous-cas.cancel": {
148
+ cls: "P-DEBT",
149
+ note: "S-25 同族第二员 → S-69 收窄:**取消臂**(task signal abort / 连接全灭同路)打 `claimTerminal(cancel)`(STREAM_PENDING→VOID;S-69 前 `transitionAsk` CAS)被 reject 之后,有界重发至多 7 把 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = 裸 `settle(false,\"expired\")`(D1 纯进程内语义,不留墓碑:run 已拆,迟到的 Yes 无处兑)。S-25 之前这条 catch **不读行**直接 fail-open —— 行已 DECIDED(approve) 时把人的批准翻成拒绝、回决端点只能如实 404(round6 钉曾把这一形记成「局部竞态残影」);S-25 起先读行;S-69 起干净输的答案本身携真相、迟到答案同样自足,只有店对整条阶梯持续 reject 才走到这里,债的语义与 expire 员同(行可能已 DECIDED 而本腿没兑现),按臂分计是为了让「哪条臂在丢决议」在遥测里读得出。",
150
+ },
151
+ "server.approval.ambiguous-cas.unreachable": {
152
+ cls: "P-DEBT",
153
+ note: "S-25 同族第三员 → S-69 收窄:**emit 全灭臂**(开卡帧两族都没送到任何连接)打 `claimTerminal(expire)`(S-69 前 `expireAsk` CAS)被 reject 之后,有界重发至多 7 把 claim(在飞帽 2,墙钟上界同 expire 员)全部 reject ⇒ 按本臂意图投影结算 = `emitFailed` ⇒ `\"unavailable\"`(park 路由;`deny` 政策下 deny),不留墓碑(卡没到任何人手上,无人持有这把 approvalId)。S-25 之前这条 catch 不读行直接强改 —— 正是 round2 finding A「durable 行已批准、活终局被 emit 全灭强改成 unavailable」的 catch 同形缺口;S-25 起行读出 DECIDED ⇒ 按人的决议结算;S-69 起真相随败方答案回来、迟到答案自足,只有店对整条阶梯持续 reject 才计数。",
154
+ },
155
+ "server.approval-ask-audit.append-failed": {
156
+ cls: "F",
157
+ note: "S-38 裁 (c) 审计半场:local 车道 File ask 审计店(`approval-ask-audit-store.ts`)的**运行期** append(铸造/决议/腿闭/收敛四类行)失败(盘满/只读/fd 失效)⇒ 丢**这一条**审计行,审批路径照常。方向刻意 fail-open:审批可用性不押在审计盘上 —— 盘满时人还得能批,把一次审计写失败升级成审批失败是把「痕迹缺一条」换成「门整个不可用」,方向更坏。boot 期(mkdir/重放)失败则**拒启**(File 店族同律,store 头注成文),不经本 tag。放行的最坏后果 = 该 ask 在崩溃收敛读面上缺席或分臂失真(与 A-075.55 的零痕迹病同向)—— 所以必须留痕:「审计一直在漏写」与「没有审批发生」在读面上同形,不计数则病灶永不显形。detail 带行类与异常文案。",
158
+ },
159
+ "server.pg-pool.idle-client-error": {
160
+ cls: "F",
161
+ note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。",
162
+ },
143
163
  };
144
164
  export function failOpenTagForDroppedFrame(frameType) {
145
165
  switch (frameType) {
@@ -12,13 +12,23 @@
12
12
  * - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
13
13
  */
14
14
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
15
- import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
15
+ import type { AskDecision, AskRow, AskTerminalClaimIntent, AskTerminalClaimOutcome, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, EnsureAskResult, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
16
16
  export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
17
17
  private readonly asks;
18
18
  private readonly batches;
19
19
  ensureAsk(row: NewAskRow): Promise<EnsureAskResult>;
20
20
  transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
21
21
  decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
22
+ /**
23
+ * [ref] 原子终局 claim(接口契约见 SQL 侧 {@link ApprovalAskStore.claimTerminal})。**委托形**:CAS 走
24
+ * 既有 verb(expire ⇒ `this.expireAsk`;cancel ⇒ `this.transitionAsk(STREAM_PENDING→VOID)`)——律 2
25
+ * 「一台机器」在本 twin 的落笔处是**字面同一个函数做写**(零第二套谓词);委托也让测试对 verb 的
26
+ * 实例级故障注入(`store.expireAsk = throwing` 等)对 claim 恒有效,注入面不因原语换名而漂移。
27
+ * 原子性:方法体的判定与读回之间唯一的 await 是对**同步体** verb 的调用(本店头注的单线程天然原子
28
+ * 论证);await 后读到的行 ≥ CAS 线性化点,而非 pending 态无回边 ⇒ 败方读数自足(与 SQL 侧同一条
29
+ * 单调性论证)。
30
+ */
31
+ claimTerminal(askId: string, batchId: string, intent: AskTerminalClaimIntent): Promise<AskTerminalClaimOutcome>;
22
32
  expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
23
33
  bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
24
34
  deferReconcile(askId: string, expectedState: AskState, expectedRev: number, nowMs: number): Promise<boolean>;
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError } from "./approval-ask-store-sql.js";
2
+ import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
3
3
  function applyPatch(row, patch) {
4
4
  if (patch.decision !== undefined)
5
5
  row.decision = patch.decision;
@@ -95,7 +95,7 @@ export class InMemoryApprovalAskStore {
95
95
  return { ok: false, reason: "batch_closed", row: row ? { ...row } : null };
96
96
  }
97
97
  const ask = this.asks.get(askId);
98
- if (!ask || ask.state !== "STREAM_PENDING") {
98
+ if (!ask || ask.state !== "STREAM_PENDING" || ask.batchId !== batchId) {
99
99
  return { ok: false, reason: "ask_not_pending", row: ask ? { ...ask } : null };
100
100
  }
101
101
  if (decision.idempotencyKey != null) {
@@ -129,13 +129,30 @@ export class InMemoryApprovalAskStore {
129
129
  ask.updatedAtMs = now;
130
130
  return { ok: true, row: { ...ask } };
131
131
  }
132
+ async claimTerminal(askId, batchId, intent) {
133
+ if (intent === "expire") {
134
+ const res = await this.expireAsk(askId, batchId);
135
+ if (res.won)
136
+ return { claimed: true, voidedSiblings: res.voidedSiblings };
137
+ }
138
+ else {
139
+ const scoped = this.asks.get(askId);
140
+ if (scoped !== undefined && scoped.batchId !== batchId)
141
+ return { claimed: false, current: { state: "absent" } };
142
+ const won = await this.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() });
143
+ if (won)
144
+ return { claimed: true, voidedSiblings: [] };
145
+ }
146
+ const cur = this.asks.get(askId);
147
+ return { claimed: false, current: projectAskTerminalClaimTruth(cur !== undefined && cur.batchId === batchId ? { ...cur } : null) };
148
+ }
132
149
  async expireAsk(askId, batchId) {
133
150
  const now = Date.now();
134
151
  const batch = this.batches.get(batchId);
135
152
  if (!batch)
136
153
  return { won: false, voidedSiblings: [] };
137
154
  const ask = this.asks.get(askId);
138
- if (!ask || ask.state !== "STREAM_PENDING")
155
+ if (!ask || ask.state !== "STREAM_PENDING" || ask.batchId !== batchId)
139
156
  return { won: false, voidedSiblings: [] };
140
157
  ask.state = "PARKING";
141
158
  ask.rev += 1;