@sema-agent/server 7.93.3 → 7.93.5

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.
@@ -0,0 +1,22 @@
1
+ import type { Logger } from "../observability/logger.js";
2
+ /** {@link installProcessExitBoundary} 的把手。 */
3
+ export interface ProcessExitBoundary {
4
+ /**
5
+ * **优化点**,不承担不变量:受控退的收尾链在它自己的终点早按一次子进程腿(理由逐字写在
6
+ * `boot/shutdown.ts` 的调用点上 —— 那之后进程还活十秒量级,后台腿的软→硬梯子跑得完)。
7
+ * 工作区腿**刻意不给**这条早按:停机中段 `rm -rf` 会把在飞 turn 正在写的目录从它脚下抽掉。
8
+ */
9
+ reapChildProcessesEarly(): void;
10
+ /** 摘掉本次装配的 `exit` 钩。幂等(同进程内反复装配的调用方=测试必须用它收尾)。 */
11
+ dispose(): void;
12
+ }
13
+ /**
14
+ * 装上本进程的退出边界(`process.once("exit")`)。返回 {@link ProcessExitBoundary}。
15
+ *
16
+ * **进程级注册表是全局单例** ⇒ 装两次就是两只钩(两个写者)。生产里每条车道只装一次、装到死;
17
+ * 同进程内反复装配的调用方(测试)必须 `dispose()`。
18
+ */
19
+ export declare function installProcessExitBoundary({ logger }: {
20
+ logger: Logger;
21
+ }): ProcessExitBoundary;
22
+ //# sourceMappingURL=exit-boundary.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { defaultTaskRegistry } from "@sema-agent/core";
2
+ import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
3
+ import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
4
+ export function installProcessExitBoundary({ logger }) {
5
+ const reapOwnChildProcesses = () => {
6
+ try {
7
+ const reaped = defaultTaskRegistry.reapAllSessionBackground();
8
+ if (reaped > 0)
9
+ logger.info("shutdown_background_reaped", { reaped });
10
+ }
11
+ catch (err) {
12
+ logger.warn("shutdown_background_reap_failed", { err: String(err) });
13
+ }
14
+ try {
15
+ const fg = reapHostForegroundChildren();
16
+ if (fg.signalled > 0 || fg.alreadyDead > 0)
17
+ logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
18
+ if (fg.failed.length > 0)
19
+ logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
20
+ }
21
+ catch (err) {
22
+ logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
23
+ }
24
+ };
25
+ const reapOwnHostWorkspaces = () => {
26
+ try {
27
+ const { removed, failed } = reapEphemeralHostWorkspaces();
28
+ if (removed.length > 0)
29
+ logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
30
+ if (failed.length > 0)
31
+ logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
32
+ }
33
+ catch (err) {
34
+ logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
35
+ }
36
+ };
37
+ const onProcessExit = () => {
38
+ const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
39
+ try {
40
+ reapOwnChildProcesses();
41
+ }
42
+ catch (err) {
43
+ console.error("shutdown_exit_boundary_child_reap_threw", err);
44
+ }
45
+ try {
46
+ reapOwnHostWorkspaces();
47
+ }
48
+ catch (err) {
49
+ console.error("shutdown_exit_boundary_workspace_reap_threw", err);
50
+ }
51
+ const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
52
+ try {
53
+ logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
54
+ if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
55
+ logger.warn("shutdown_exit_boundary_incomplete", left);
56
+ }
57
+ catch (err) {
58
+ console.error("shutdown_exit_boundary_log_threw", err);
59
+ }
60
+ };
61
+ process.once("exit", onProcessExit);
62
+ let disposed = false;
63
+ return {
64
+ reapChildProcessesEarly: reapOwnChildProcesses,
65
+ dispose: () => {
66
+ if (disposed)
67
+ return;
68
+ disposed = true;
69
+ process.removeListener("exit", onProcessExit);
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=exit-boundary.js.map
@@ -12,8 +12,8 @@
12
12
  * "第二个信号"的语义读数。
13
13
  * 3. `clearInterval(reaper)` 必须是 hardShutdown 的第一件事(在 `server.close()` 之前),否则
14
14
  * 收尾期还会有 reaper tick 打向正在关闭的池。
15
- * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩)
16
- * 只能经返回的
15
+ * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩
16
+ * —— S-560 起后者由 {@link installProcessExitBoundary} 代装,摘除仍并进同一把手)只能经返回的
17
17
  * {@link ShutdownHandle} 摘 —— 调用方手写「摘三信号」的收尾会把双钩漏在进程上,而陈旧的那只
18
18
  * rejection 钩仍握着 `process.exit(1)`,会对后续无关的 rejection 把进程打掉。
19
19
  */
@@ -1,9 +1,8 @@
1
- import { Runner, defaultTaskRegistry } from "@sema-agent/core";
1
+ import { Runner } from "@sema-agent/core";
2
2
  import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
- import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
6
- import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
5
+ import { installProcessExitBoundary } from "./exit-boundary.js";
7
6
  import { createEngineLeaseTracker } from "./engine-lease.js";
8
7
  import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
9
8
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
@@ -32,63 +31,7 @@ export function installShutdownHandlers(ctx) {
32
31
  if (dataRoot)
33
32
  writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
34
33
  };
35
- const reapOwnChildProcesses = () => {
36
- try {
37
- const reaped = defaultTaskRegistry.reapAllSessionBackground();
38
- if (reaped > 0)
39
- logger.info("shutdown_background_reaped", { reaped });
40
- }
41
- catch (err) {
42
- logger.warn("shutdown_background_reap_failed", { err: String(err) });
43
- }
44
- try {
45
- const fg = reapHostForegroundChildren();
46
- if (fg.signalled > 0 || fg.alreadyDead > 0)
47
- logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
48
- if (fg.failed.length > 0)
49
- logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
50
- }
51
- catch (err) {
52
- logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
53
- }
54
- };
55
- const reapOwnHostWorkspaces = () => {
56
- try {
57
- const { removed, failed } = reapEphemeralHostWorkspaces();
58
- if (removed.length > 0)
59
- logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
60
- if (failed.length > 0)
61
- logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
62
- }
63
- catch (err) {
64
- logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
65
- }
66
- };
67
- const onProcessExit = () => {
68
- const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
69
- try {
70
- reapOwnChildProcesses();
71
- }
72
- catch (err) {
73
- console.error("shutdown_exit_boundary_child_reap_threw", err);
74
- }
75
- try {
76
- reapOwnHostWorkspaces();
77
- }
78
- catch (err) {
79
- console.error("shutdown_exit_boundary_workspace_reap_threw", err);
80
- }
81
- const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
82
- try {
83
- logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
84
- if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
85
- logger.warn("shutdown_exit_boundary_incomplete", left);
86
- }
87
- catch (err) {
88
- console.error("shutdown_exit_boundary_log_threw", err);
89
- }
90
- };
91
- process.once("exit", onProcessExit);
34
+ const exitBoundary = installProcessExitBoundary({ logger });
92
35
  const onUncaughtException = (err) => {
93
36
  try {
94
37
  writeLastWords(err);
@@ -151,7 +94,7 @@ export function installShutdownHandlers(ctx) {
151
94
  subRunner.sessions.dispose();
152
95
  void lspManager?.dispose?.();
153
96
  void workflowNotifyJournal?.close?.();
154
- reapOwnChildProcesses();
97
+ exitBoundary.reapChildProcessesEarly();
155
98
  void fleetClient?.stop();
156
99
  server.close(() => {
157
100
  void retentionTeardown.then(() => (backend ? backend.close() : Promise.resolve())).finally(() => process.exit(0));
@@ -248,7 +191,7 @@ export function installShutdownHandlers(ctx) {
248
191
  if (disposed)
249
192
  return;
250
193
  disposed = true;
251
- process.removeListener("exit", onProcessExit);
194
+ exitBoundary.dispose();
252
195
  process.removeListener("uncaughtException", onUncaughtException);
253
196
  process.removeListener("unhandledRejection", onUnhandledRejection);
254
197
  process.removeListener("SIGTERM", drainThenShutdown);
@@ -14,7 +14,7 @@
14
14
  * ⇒ 本仓的规则从「记住一张词×决定的相容表」变成「说出你知道的那两件事」。相容性也不再靠记忆:一次
15
15
  * sweep 不可能 `allow`,core pre-CAS 拒。
16
16
  */
17
- import { type ResumeOutcome } from "@sema-agent/core";
17
+ import { type AskOutcome, type ResumeOutcome } from "@sema-agent/core";
18
18
  /**
19
19
  * 谁结束了这次等待,以及通道自报的归属。
20
20
  * · `decidedBy: "person"` —— 运维经审批通道做的决定(两条 HTTP `/decide` 腿);
@@ -25,6 +25,33 @@ import { type ResumeOutcome } from "@sema-agent/core";
25
25
  * 并把结算记成 `policy_refused`(`who.party:"none"`)。`allow` 侧结构上不可(core pre-CAS 拒:只有人批准)。
26
26
  * · `approver` —— 通道自报的结算方标识串。**缺席永不读成**「没人批」或「有人批」,只意味着通道没报名字。
27
27
  */
28
+ /**
29
+ * core 5.23.0([ref]/[ref]②)—— **宿主自报**的结算来源,`AskOutcome` 对象臂那一格的型。本仓在这一格上有
30
+ * **两种身份**,合成一只座是刻意的(两只座 = 同一个 wire 位的两个写者):
31
+ * · **铸**(本仓自己观察到的事实):只有 `"timeout"` —— 同步 `onAsk` 腿的等待窗归**宿主**所有(引擎不为
32
+ * 一个它没排程的回调起表),所以「本仓的 TTL 窗走完了、没人答」这件事引擎结构上观察不到,不自报就
33
+ * 永远被记成「一个人拒绝了」;
34
+ * · **转**(调用方在回决体里自报的事实,core 7.26.2 / #1016 起):`settledBy:"policy"` = 答这张卡的是
35
+ * **这台部署自己的策略**,不是人。本仓**零判据、原样过境**:词的拒绝逻辑归引擎(`resolveAsk` 对
36
+ * 自报词表外的值是响亮 fail-closed deny),本仓抄一份词表的那天 core 加词就会被静默 400 掉。
37
+ * 逐词(词表属主恒是 core):
38
+ * · `"human"` —— 与「不铸这个键」逐字同义(core 缺席即按人处理);
39
+ * · `"aborted"` —— core **明令拒收**宿主自报(那是引擎自家 fail-closed 端的词,宿主自报等于把自己的
40
+ * 拒绝改标成引擎故障),所以它**不在** `AskOutcome` 的这一格上,本型也就够不着它。
41
+ * 类型从 core 的**同步 outcome 面**推导(不手抄):`AskOutcome` 的对象臂上 `settledBy` 是**宿主报的事实**,
42
+ * core 7.6.0([ref] S6-A)删掉的是那张**结算词表**(`ApprovalSettledBy`),不是这一格 —— 词由 core 从
43
+ * 这个事实铸(`human_refused` / `approval_window_expired` / `policy_refused`)。core 若改这一格的词表,
44
+ * 读它的每一处编译期先红。
45
+ *
46
+ * 🔴 **S-558:为什么这一格住在本叶子模块**(此前是 `tool-approval.ts` 的模块私有型):来源位随决议
47
+ * **落持久行**之后,店(`src/plugins/approval-ask-store-sql.ts` 的 `AskRow.settledBy` /
48
+ * `DecideAskInput.settledBy`)与协调器都要说同一个型 —— 而店不能反向依赖协调器。本模块只依赖
49
+ * `@sema-agent/core`,与同住的 {@link HostDecidedBy}(耐久腿的同位事实)恰好是一对:一条决议的来源
50
+ * 在两条腿上是同一件事,型也该在同一处。
51
+ */
52
+ export type HostSettledBy = NonNullable<Extract<AskOutcome, {
53
+ allow: boolean;
54
+ }>["settledBy"]>;
28
55
  /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那几个词(第二铸点)。 */
29
56
  export type HostDecision = Extract<ResumeOutcome, {
30
57
  gate: "policy_ask";
@@ -16,7 +16,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
16
16
  import { streamSseLog } from "../sse-log.js";
17
17
  import { bindSseLifecycle } from "../sse-lifecycle.js";
18
18
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
19
- import { ASK_DECISION_CONSUMER_ABSENT, resolveStreamApprovalGate } from "../../tool-approval.js";
19
+ import { ASK_DECISION_CONSUMER_ABSENT, outcomeOfDecidedRow, resolveStreamApprovalGate } from "../../tool-approval.js";
20
20
  import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
21
21
  import { normalizeRunEventType, screenedStoredEventData } from "../../trace/project.js";
22
22
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
@@ -1321,7 +1321,7 @@ export async function handleRunVerbs(req, res, match, ctx) {
1321
1321
  return true;
1322
1322
  if (deps.toolApproval?.editedDecisionInFlight(row.askId) === true)
1323
1323
  return true;
1324
- const settled = deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve", row.updatedInput);
1324
+ const settled = deps.toolApproval?.notifyExternalDecision(row.askId, outcomeOfDecidedRow(row));
1325
1325
  observedSettled.set(row.askId, settled?.settled ?? 0);
1326
1326
  return true;
1327
1327
  };
@@ -1431,7 +1431,7 @@ export async function handleRunVerbs(req, res, match, ctx) {
1431
1431
  confirmed.decision === body.decision &&
1432
1432
  confirmed.idempotencyKey === body.idempotencyKey &&
1433
1433
  !confirmed.provisional) {
1434
- const settledNow = deps.toolApproval.notifyExternalDecision(askId, confirmed.decision === "approve", confirmed.updatedInput).settled;
1434
+ const settledNow = deps.toolApproval.notifyExternalDecision(askId, outcomeOfDecidedRow(confirmed)).settled;
1435
1435
  deps.logger?.warn?.("ask_decision_edit_settled_after_ambiguous_commit", { taskId, askId, settled: settledNow });
1436
1436
  }
1437
1437
  }
@@ -1470,11 +1470,11 @@ export async function handleRunVerbs(req, res, match, ctx) {
1470
1470
  return;
1471
1471
  if (outcome.row.decision !== body.decision) {
1472
1472
  deps.logger?.error?.("ask_decision_row_diverged", { taskId, askId, requested: body.decision, stored: outcome.row.decision });
1473
- deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", outcome.row.updatedInput);
1473
+ deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcomeOfDecidedRow(outcome.row));
1474
1474
  sendError(res, 500, "internal.error", "the ask row was rewritten between commit and read-back — the decision that stands is not the one this request submitted; re-read the ask before acting on it");
1475
1475
  return;
1476
1476
  }
1477
- const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", outcome.row.updatedInput).settled ?? 0;
1477
+ const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcomeOfDecidedRow(outcome.row)).settled ?? 0;
1478
1478
  const forwarded = outcome.row.decision === "approve" && outcome.row.updatedInput !== undefined && settled > 0;
1479
1479
  sendJson(res, 200, {
1480
1480
  askId: outcome.row.askId,
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
2
+ import { assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, 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;
@@ -42,7 +42,7 @@ export class InMemoryApprovalAskStore {
42
42
  }
43
43
  importRows(snapshot) {
44
44
  for (const r of snapshot.asks ?? [])
45
- this.asks.set(r.askId, { ...r });
45
+ this.asks.set(r.askId, { ...r, settledBy: r.settledBy ?? null });
46
46
  for (const b of snapshot.batches ?? [])
47
47
  this.batches.set(b.batchId, { ...b });
48
48
  }
@@ -80,6 +80,7 @@ export class InMemoryApprovalAskStore {
80
80
  decision: null,
81
81
  decisionActor: null,
82
82
  decisionNote: null,
83
+ settledBy: null,
83
84
  decidedAtMs: null,
84
85
  deniedReason: null,
85
86
  gateToken: null,
@@ -113,6 +114,7 @@ export class InMemoryApprovalAskStore {
113
114
  async decideAsk(askId, batchId, decision) {
114
115
  if (decision.idempotencyKey != null)
115
116
  assertIdempotencyKeyShape(decision.idempotencyKey);
117
+ assertDecideSettledBy(decision);
116
118
  const now = this.now();
117
119
  const batch = this.batches.get(batchId);
118
120
  if (!batch || batch.state !== "OPEN") {
@@ -139,6 +141,7 @@ export class InMemoryApprovalAskStore {
139
141
  ask.decision = decision.decision;
140
142
  ask.decisionActor = decision.decisionActor ?? null;
141
143
  ask.decisionNote = decision.decisionNote ?? null;
144
+ ask.settledBy = decision.settledBy ?? null;
142
145
  if (decision.updatedInput === undefined)
143
146
  delete ask.updatedInput;
144
147
  else
@@ -41,6 +41,7 @@ import type { Pool as PgPool } from "pg";
41
41
  import type { PgQueryFn } from "./pg-query.js";
42
42
  import { type SqlDriver, type SqlDialect } from "./sql-driver.js";
43
43
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
44
+ import type { HostSettledBy } from "../host-decision.js";
44
45
  import { type IndexSpec } from "./ensure-index.js";
45
46
  export type AskDecision = "approve" | "deny";
46
47
  export interface AskRow {
@@ -79,6 +80,32 @@ export interface AskRow {
79
80
  /** 171 ActorAssertion JSON(车4 才消费,列先在——JSON.parse 后的裸值,类型未知)。 */
80
81
  decisionActor: unknown | null;
81
82
  decisionNote: string | null;
83
+ /**
84
+ * 🔴 S-558(#1016 的持久半场)—— 这条决议的**结算来源**:`null` = 一个人答的(= 缺席律,core 对
85
+ * 不带这一格的 outcome 逐字按 `"human"` 读),非 null = 宿主自报的那个词(今天 wire 上只开
86
+ * `"policy"`,词表属主恒是 core —— 见 {@link HostSettledBy})。
87
+ *
88
+ * **为什么必须是行上的一格**(病灶,7.93.3 合并树 codex ① 验真):`settledBy` 此前只到达**当次**回决的
89
+ * 即时回调,行上不留痕 ⇒ 重放 / 再入 / 同 askId 的兄弟等待者 / 歧义提交恢复读回的是裸 `false`,
90
+ * 而裸 `false` 在 core 那边逐字是「**一个人**拒绝了」—— 模型于是又读到那句「用户不想继续,停下来等他」,
91
+ * 在一条根本没有人的 run 里。不变量:**一条决议的来源在所有读回路径上是同一个事实**;唯一读回铸点 =
92
+ * `tool-approval.ts` 的 `outcomeOfDecidedRow`。
93
+ *
94
+ * 🔴 **为什么是自己一列,不是塞进 `decision_actor` 那只袋**(S-558 取舍,亲读后定):
95
+ * · 那只袋是**自由载荷**——型是 `unknown`(见上一格),读回不过任何 schema(`parseJsonColumn` 返回
96
+ * `unknown`),schema 属主在别处([ref] ActorAssertion),`adoption/plan.ts` 的裁定 D2 把它连同
97
+ * `decision_note` 一起定为**史实字段,永不改写**。往一只没有属主、没有类型化读回的袋里塞一个
98
+ * **执行面**的判别位 = 让一格决定「工具跑不跑、模型读哪一句」的事实寄生在一个只做审计的载荷上。
99
+ * · 更硬的一条:**live 回决腿根本不写那只袋**(`respondWithCas` 的 `decideAsk` 入参里没有
100
+ * `decisionActor`,列恒 NULL)。来源位进袋 ⇒ 那条腿得为了捎一个词现铸一只它压根没有的 actor
101
+ * ——凭空发明「谁按的」,正是 `hostDecisionFacts` 顶注禁的那件事。
102
+ * · 一列的代价已经由本仓的 SCHEMA POLICY 定死且**与既有两批加列同形**([ref] 两列、[ref] 一列):
103
+ * 改 CREATE + 启动期响亮拒(见 {@link assertApprovalAskRuleMaterialSchema}),零 ALTER、零双读、
104
+ * 老行 NULL 恒读成「人决」。
105
+ *
106
+ * 三态收成两态是刻意的:`"human"` 与缺席在 core 那边逐字同义,故店只存「非人」的那些词,`null` 即人。
107
+ */
108
+ settledBy: HostSettledBy | null;
82
109
  /**
83
110
  * [ref]([ref] / [ref] 收口):人批准时带的 **ctrl+g 编辑实参**(wire `updatedInput`),与决议**同一条
84
111
  * UPDATE** 原子落列 `updated_input`。语义三态,读法**严格**:
@@ -199,6 +226,14 @@ export interface DecideAskInput {
199
226
  decisionNote?: string | null;
200
227
  /** 车4 §12-E:回决幂等键,赢 CAS 时随决议一起落列(见 `AskRow.idempotencyKey`)。缺席 ⇒ 列保持 NULL。 */
201
228
  idempotencyKey?: string | null;
229
+ /**
230
+ * 🔴 S-558:本次回决的**结算来源**,与决议同一条 UPDATE 落列 `settled_by`(语义/两态读法/为什么是
231
+ * 自己一列,全在 {@link AskRow.settledBy})。缺席 ⇒ 列 NULL ⇒ 读回是「一个人答的」。
232
+ *
233
+ * 店**不判词**(词表属主是 core):写什么存什么。店只判**形** —— `assertDecideSettledBy` 的两条
234
+ * (长度 ≤ 列宽、只与 `deny` 同行),理由见那只门的顶注;三 twin 同判。
235
+ */
236
+ settledBy?: HostSettledBy;
202
237
  /** [ref]:ctrl+g 编辑实参,与决议同一条 UPDATE 落列(语义三态见 {@link AskRow.updatedInput})。
203
238
  * 缺席 / `undefined` ⇒ 列 NULL(没带编辑);**`null` 是值**(编辑成 null),照存 JSON `"null"`。
204
239
  * 调用方纪律:deny 不传(店不替调用方判决议方向,写什么存什么)。 */
@@ -233,6 +268,24 @@ export type DecideResult = {
233
268
  * 的 deadline 铸点)负责给整数,本守卫只拦漏网。 */
234
269
  export declare function assertIntegralEpochMs(field: string, value: number): void;
235
270
  export declare function assertIdempotencyKeyShape(key: string): void;
271
+ /** `settled_by` 列宽 = 这一格的唯一尺(两方言 DDL 与下面的形门读同一个常量,不手抄第二份)。 */
272
+ export declare const MAX_SETTLED_BY_CHARS = 16;
273
+ /**
274
+ * S-558 的**入店卫生门**(三 twin 同判,与 {@link assertIdempotencyKeyShape} / {@link assertIntegralEpochMs} 同族)。
275
+ * 判的是**形**,不是**词** —— 词表属主恒是 core(`resolveAsk` 对自报词表外的值是响亮 fail-closed deny),
276
+ * 本层一个词都不认识。两条:
277
+ *
278
+ * ① **长度 ≤ 列宽**。`settled_by` 是 `VARCHAR(16)` 的闭集词列,而值来自 wire(两条回决腿只证得出「它是个串」)。
279
+ * 不拦的话:MySQL 协议侧非严格模式**静默截断**、PG 侧直接报错 —— 同一份调用方代码在两个后端拿到两种
280
+ * 答案(`assertIdempotencyKeyShape` 的 PAD SPACE 论证逐字同源),而**被截断过的词与原词是两个词**:
281
+ * 一条决议的来源在库里变成了另一件事。空串同拒(core 那边它不是词,存进去只会读成一个假的「非人」)。
282
+ * ② **只与 `deny` 同行**。core 的契约明写两个非人词只在 `allow:false` 上合法(`{allow:true,settledBy}` 是
283
+ * fail-closed 拒),两条回决腿也都在**裁决之前**照此响亮拒(`tool-approval.ts` 的
284
+ * `SETTLED_BY_DENY_ONLY`,两腿共用一份文案)。在店门口再执行一次不是「第二个判官」:判的不是词而是**这一对列能不能共存**。
285
+ * 收益是结构性的 —— 「approve 行带来源位」从此在写面不可表示,读回铸点(`outcomeOfDecidedRow`)的 allow 臂
286
+ * 因此**证得出**不会丢掉任何真事实,而不是靠记性。
287
+ */
288
+ export declare function assertDecideSettledBy(decision: Pick<DecideAskInput, "decision" | "settledBy">): void;
236
289
  /**
237
290
  * [ref] 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。
238
291
  *
@@ -277,6 +330,9 @@ export type AskTerminalClaimCurrent = {
277
330
  decision: AskDecision | null;
278
331
  /** [ref] 三态读法照 {@link AskRow.updatedInput}:键缺席 = 没带编辑;`null` 是编辑成 null 的值。 */
279
332
  updatedInput?: unknown;
333
+ /** S-558:赢家决议的**结算来源**(语义与两态读法见 {@link AskRow.settledBy})。**必填**(不是可选)——
334
+ * 可选会让投影漏挑这一列时静默编译过,而漏挑的后果恰是本批要修的病:CAS 输家把赢家的策略拒读成人拒。 */
335
+ settledBy: HostSettledBy | null;
280
336
  };
281
337
  /**
282
338
  * 单次原子往返的答案(语义律的唯一规范陈述点 = core `checkpoint-store.ts` `claimTerminal` 契约 JSDoc,
@@ -459,7 +515,7 @@ export declare function ensurePgApprovalAskSchema(q: PgQueryFn): Promise<void>;
459
515
  *
460
516
  * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。于是一台**没删表**就升上来
461
517
  * 的部署,`CREATE TABLE IF NOT EXISTS` 对存量 `approval_ask` 是空操作 —— 本车新加的
462
- * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `ASK_COLS` 的每一条读
518
+ * `rule_command` / `rule_scope_root` 两列不在,而 `ensureAsk` 的 INSERT 与 `askCols` 的每一条读
463
519
  * **无条件**引用它们。后果不是「少一个新功能」,是**整条流内审批协议**在那台机器上是坏的:
464
520
  * · 每一只 ask 的 `ensureAsk` 撞 unknown column ⇒ 被 `noteStoreError` 吞掉 ⇒ 整只 ask 退回 park 路由
465
521
  * (卡不落库、`GET /v1/approvals` 的重放面恒空、迟到决议腿恒 404);