@sema-agent/server 7.11.0 → 7.12.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 (48) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +56 -0
  3. package/dist/auth-keys.d.ts +28 -4
  4. package/dist/auth-keys.js +60 -15
  5. package/dist/boot/parked-revive-gate.d.ts +18 -2
  6. package/dist/boot/parked-revive-gate.js +136 -14
  7. package/dist/boot/permission-rules-audit.d.ts +49 -0
  8. package/dist/boot/permission-rules-audit.js +57 -0
  9. package/dist/boot/resolve-spec.js +43 -12
  10. package/dist/budget.js +22 -0
  11. package/dist/config-types.d.ts +17 -9
  12. package/dist/config.d.ts +28 -2
  13. package/dist/config.js +348 -79
  14. package/dist/governance-ask-marks.js +8 -2
  15. package/dist/http/route-ctx.d.ts +6 -3
  16. package/dist/http/routes/approvals-assistant.js +2 -1
  17. package/dist/http/routes/capabilities.js +44 -9
  18. package/dist/http/routes/rules.d.ts +19 -7
  19. package/dist/http/routes/rules.js +180 -4
  20. package/dist/http/server.d.ts +4 -2
  21. package/dist/http/server.js +81 -3
  22. package/dist/http/wire-types.d.ts +48 -0
  23. package/dist/main.js +19 -1
  24. package/dist/observability/fail-open.d.ts +4 -0
  25. package/dist/observability/fail-open.js +4 -0
  26. package/dist/observability/metrics.js +2 -1
  27. package/dist/observability/tool-trace.d.ts +5 -1
  28. package/dist/observability/tool-trace.js +33 -6
  29. package/dist/parked-decide.d.ts +13 -3
  30. package/dist/parked-decide.js +10 -1
  31. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  32. package/dist/plugins/permission-rule-store-file.js +371 -0
  33. package/dist/plugins/permission-rule-store-sql.d.ts +7 -0
  34. package/dist/plugins/permission-rule-store-sql.js +11 -0
  35. package/dist/plugins/store-backend.d.ts +12 -6
  36. package/dist/plugins/store-backend.js +58 -9
  37. package/dist/rules-consent.d.ts +69 -1
  38. package/dist/rules-consent.js +43 -1
  39. package/dist/run-local.js +120 -13
  40. package/dist/runtime-governance.js +9 -3
  41. package/dist/task-settings.d.ts +44 -0
  42. package/dist/task-settings.js +57 -1
  43. package/dist/tool-approval.d.ts +6 -1
  44. package/dist/tool-approval.js +106 -27
  45. package/dist/trace/core-keyset-guard.d.ts +13 -2
  46. package/dist/trace/project.d.ts +19 -2
  47. package/dist/trace/project.js +24 -4
  48. package/package.json +2 -2
package/README.md CHANGED
@@ -157,7 +157,7 @@ The server is configured entirely through environment variables. The most import
157
157
  | `DEFAULT_SCENARIO` | `code` | Default scenario when the request body names none |
158
158
  | `SANDBOX_PKG_SOURCE` | `global` | Package sources inside sandboxes: `global` (official upstreams) / `cn` (China mirrors) / `custom` / `none` |
159
159
  | `SENSITIVE_WRITE_PATTERNS` | core's recommended set | Sensitive-path write deny list; comma-separated value replaces the set, `off` **or an empty value** disables. Applied unconditionally at the governance layer (independent of client permission mode, lane or settings presence) — including on the `run-local` leg. A value that cannot compile into a guard set (e.g. `/`, a pattern with no path segment) refuses to start |
160
- | `MANUAL_MODE_SHELL_GATE` | unset (off) | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence) |
160
+ | `MANUAL_MODE_SHELL_GATE` | unset | `always`\|`classify` — tighten `Bash` into the approval chain, applied unconditionally at the governance layer (≥7.1.0: independent of client permission mode, lane, or settings presence). **Unset is not "off"**: since 7.12.0 the caller's explicit `permissionMode` supplies the baseline this knob tightens from (`bypassPermissions` → `off`, `auto`/`default`/`acceptEdits`/`plan` → `classify`; **no** mode stated → no gate, core's `off` default). This knob only ever raises that baseline — it has no relax half, so `off` is accepted as an explicit **no-op** (a boot line says so; not symmetric with `SENSITIVE_WRITE_PATTERNS=off`, which really does clear a set). **Any other value refuses to start** (7.12.0, BREAKING for a deployment that had a typo: it was previously treated as unset, i.e. silently no gate at all) |
161
161
  | `SCRATCHPAD_SWEEP_TTL_MS` | 7 days | Idle-reap window for per-session scratchpad dirs (by dir mtime; `0` disables). The scratchpad is **ephemeral by contract**: replica-local disk, NOT part of the durable-suspend persistence set — a resume on a different replica, or after a sweep, starts with an empty dir (same two-track posture as the Agent SDK hosting doc: conversation persists, working-directory artifacts don't). Raise/disable only on single-replica deployments that park approvals for longer than the window |
162
162
  | `MODEL_CONNECT_TIMEOUT_MS` | `30000` | Gateway connect timeout |
163
163
  | `MODEL_FIRST_TOKEN_TIMEOUT_MS` | `120000` | First-token timeout |
package/USAGE.md CHANGED
@@ -266,6 +266,62 @@ QUESTION_TTL_MS=300000
266
266
  - 窗用尽 / 到期 **不是替人作答**:服务只报「此刻无人可答」,落点由引擎选——**durable 部署**(`DURABLE_APPROVAL=true` 且这条腿没有活的流)把问题 **park** 成待办,运维随后经审批面带答案补答;**无 durable 的部署**则让模型被告知"没人在"后按自己的判断继续(run 永不挂死)。
267
267
  - 想让人有更长时间答就调大 `QUESTION_TTL_MS`;想让 agent 少打扰人就调小 `QUESTION_MAX_TOTAL_PER_RUN`。
268
268
 
269
+ **流内审批协议(`approval_request` 帧族,design/172)—— 总开关 + 九个调优钮**
270
+
271
+ > 🔴 **默认已是 ON**(clay 裁 2026-08-08;7.3.0/7.4.0 的**已发布**线上仍是 OFF,翻转落在其后的下一个发布)。
272
+ > 开关只是发帧五合取里的一项:还要**活卡腿开着 ∧ 非 local 的持久 store backend ∧ park 设施在场**
273
+ > (`DURABLE_APPROVAL=true` + checkpoint 能力的 backend)。所以一台什么都没配的默认 worker 仍是零帧 +
274
+ > 启动一行 `stream_approval_disabled`;而**已经配齐那套前置的部署,升级后不必再显式开这个开关就会开始发帧**
275
+ > —— 壳/SDK 不实现消费就等于用户看不到审批卡。帧格式、消费端契约、五合取全表在
276
+ > [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quint](docs/ASSISTANT-WIRE-CONTRACT.md)(错误码 `feature.approval_ask_disabled` 在附录 A)。
277
+
278
+ ```bash
279
+ # 协议总开关。默认 true;显式 =false 是**唯一干净还原键**(全链逐字回到翻转前:不发 approval_request、
280
+ # 不落 ask 行、不起收敛器腿,窗回到 DEFAULT_APPROVAL_TTL_MS)
281
+ STREAM_APPROVAL_ENABLED=true
282
+ # 活卡窗(毫秒,默认 300000=5min)。0 = 运维显式关窗 ⇒ 恒 park(不是"还原",还原用上面那个键)
283
+ STREAM_ASK_WINDOW_MS=300000
284
+ # 协调器窗长三元的安全余量(毫秒,默认 10000):有效窗 = min(ttl, 本 leg 余量 − 本值)
285
+ STREAM_ASK_WINDOW_MARGIN_MS=10000
286
+ # 开流重放:一次最多投几张未决卡(默认 50)。超出只投最新的并记一次 warn,开流不失败
287
+ STREAM_APPROVAL_REPLAY_MAX=50
288
+ # 写侧准入帽:单个 task / 单个 owner 的未决 ask 上限(默认 32 / 256)。超限走 park,**永不 deny**
289
+ STREAM_APPROVAL_ADMIT_MAX_PER_TASK=32
290
+ STREAM_APPROVAL_ADMIT_MAX_PER_OWNER=256
291
+ # 对账收敛器每 tick 每段最多处理的行数(默认 200,有界 [1,10000],且必须是**整数**)
292
+ STREAM_APPROVAL_RECONCILE_BATCH=200
293
+ # 崩溃恢复扫描的宽限(毫秒,默认 30000,有界 [0,3600000]):只有 expiresAtMs+本值 已过的孤儿行才被代打过期
294
+ STREAM_APPROVAL_PENDING_GRACE_MS=30000
295
+ # adhoc 腿的窗后宽限(毫秒,默认 60000,有界 [0,86400000])
296
+ STREAM_APPROVAL_ADHOC_GRACE_MS=60000
297
+ # 遗孤最终可判上界(毫秒,默认 604800000=7d,有界 [60000, 7776000000=90d]),量的是 createdAtMs
298
+ STREAM_APPROVAL_ORPHAN_TTL_MS=604800000
299
+ ```
300
+ - **坏形一律启动期炸,不静默折默认**:五个 `numEnv` 键(`STREAM_ASK_WINDOW_MS` / `STREAM_ASK_WINDOW_MARGIN_MS` /
301
+ `STREAM_APPROVAL_REPLAY_MAX` / 两个 `ADMIT_MAX_*`)非数字即拒启;四个收敛器键额外**有界**,越界拒启
302
+ (不夹取);`STREAM_APPROVAL_RECONCILE_BATCH` 再加一道整数门(`200.5` 会一路走到 SQL `LIMIT` 上)。
303
+ - **🔴 跨旋钮不变量**:`STREAM_ASK_WINDOW_MS + STREAM_APPROVAL_ADHOC_GRACE_MS` 必须**严格小于**
304
+ `STREAM_APPROVAL_ORPHAN_TTL_MS`,否则**拒启**并点名。理由是归因诚实:adhoc 判据在
305
+ `(创建 + 窗 + 宽限)` 触发、遗孤兜底在 `(创建 + TTL)` 触发,兜底若先到,每条 adhoc 腿都会被记成
306
+ `orphan_ttl_exceeded`(「遗孤」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),审计面从此读不出真成因。
307
+ 默认值(300s + 60s vs 7d)自然满足,只有显式改坏才会撞上。
308
+ - **调参方向**:想让人有更长时间点审批卡 → 调大 `STREAM_ASK_WINDOW_MS`;卡太多刷屏 → 调小两个 `ADMIT_MAX_*`
309
+ (代价是超限的 ask 走 park,要有人去审批队列捞);库压大 → 调小 `STREAM_APPROVAL_RECONCILE_BATCH`
310
+ (代价是收敛变慢,靠队列轮转保证下轮接着扫)。
311
+
312
+ **可选 — MCP 入站表单(elicitation,E23):默认关**
313
+
314
+ ```bash
315
+ MCP_ELICITATION_ENABLED=true # 总开关,默认 **false**(fail-closed:不开则 MCP 服务器的表单请求根本不上场)
316
+ MCP_ELICITATION_MAX_CONCURRENT=2 # 每条 run 同时挂几张表单(默认 2,有界 [1,64])
317
+ MCP_ELICITATION_MAX_TOTAL=20 # 每条 run 一共能弹几张(默认 20,有界 [1,10000])
318
+ MCP_ELICITATION_MIN_INTERVAL_MS=1000 # 同一个 MCP server 两张表单之间的最小间隔(默认 1000,有界 [0,600000])
319
+ MCP_ELICITATION_TTL_MS=300000 # 一张没人填的表单挂多久后释放(默认 300000=5min,有界 [1000,3600000])
320
+ ```
321
+ - 四个节流钮都是 `numEnvBounded`:**越界启动期响亮拒,不静默夹取**(一个手滑的多小时 TTL 会让表单实际上永不过期)。
322
+ - 总开关关着时四个节流钮解析照跑但无消费者——不设=行为不变。帧格式见
323
+ [`docs/ASSISTANT-WIRE-CONTRACT.md` §4a-quater](docs/ASSISTANT-WIRE-CONTRACT.md)(`elicitation` / `elicitation_complete`)。
324
+
269
325
  **布尔旋钮的取值与极性(运维必读)**
270
326
 
271
327
  布尔 env **只认 `true` / `false` 两个字面量**。写成 `1` / `yes` / `TRUE` ⇒ 该旋钮退回自己的缺省值,并在启动时
@@ -15,9 +15,31 @@ export interface ApprovalHmacKey {
15
15
  key: string;
16
16
  status?: "active" | "retiring";
17
17
  }
18
+ /**
19
+ * #210(黑板 [3425]③「坏值禁静默回默认」)—— 一条**被丢弃**的 key-set 条目的诊断。
20
+ *
21
+ * 为什么两个解析器要新长一个必填出口:它们的 fail-closed 方向是对的(坏形 ⇒ `[]` ⇒ 门关着),但**丢弃
22
+ * 本身此前零留痕**。真场景:轮换新钥时手滑把 `key` 写成 `secret`,这一条被 `filter` 悄悄滤掉,进程照常
23
+ * 起、旧钥还在、验签照常过 —— 直到旧钥退役那天,全部审批回决在生产上一起开始 401,而配置早在几周前就
24
+ * 已经坏了。诊断出口让「我加的那把钥没进来」在 boot 当场可见。
25
+ *
26
+ * `index` 是**原数组下标**(operator 拿它直接定位是 JSON 里第几条);`"-"` = 整体形坏(不是数组 / 不是
27
+ * JSON),没有可指的条目。`reason` 只说**缺陷字段名**,永不带值 —— HMAC 的 `key` 是对称密钥,诊断行会
28
+ * 进日志。
29
+ */
30
+ export interface KeySetRejection {
31
+ index: number | "-";
32
+ reason: string;
33
+ }
34
+ /**
35
+ * 诊断出口**必填**(不是 `onReject?:`)。可选回调 = 「没人接就等于错误蒸发」,正是本仓 #191 静默降级
36
+ * 门数的 SHAPE B;这两条钥又都在凭据轴上,所以由**类型**逼每个调用点当场表态,而不是靠人记得去接。
37
+ */
38
+ export type OnKeySetRejection = (rejection: KeySetRejection) => void;
18
39
  /** Parse APPROVAL_HMAC_KEYS — a sema-registry-managed, rotating key-SET, as a JSON array of {kid,key,status}.
19
- * Empty / unset / unparseable ⇒ `[]` ⇒ HMAC verification is OFF (D-G inactive; back-compat, BFF-only door). */
20
- export declare function parseApprovalHmacKeys(raw: string | undefined): ApprovalHmacKey[];
40
+ * Empty / unset / unparseable ⇒ `[]` ⇒ HMAC verification is OFF (D-G inactive; back-compat, BFF-only door).
41
+ * fail-closed 语义与改前**逐字相同**;新增的只有 `onReject`(#210:每一条被丢弃的条目都要说出来) */
42
+ export declare function parseApprovalHmacKeys(raw: string | undefined, onReject: OnKeySetRejection): ApprovalHmacKey[];
21
43
  /** One issuer public key in the rotating principal JWKS (mirror of the HMAC key-set rotation discipline). The
22
44
  * `alg` is PINNED per key — the JWT header's alg MUST match it, killing alg-confusion (`none`, HS-with-the-pubkey).
23
45
  * `key` is a PEM (SPKI) public key. PUBLIC, non-secret. */
@@ -28,6 +50,8 @@ export interface PrincipalJwtKey {
28
50
  status?: "active" | "retiring";
29
51
  }
30
52
  /** Parse PRINCIPAL_JWT_PUBKEYS — a JSON array of {kid,alg,key,status}. Empty/unset/malformed ⇒ [] ⇒ no trust
31
- * anchor ⇒ the direct door cannot open (D-G inactive). Each entry must have kid + a pinned alg + a PEM key. */
32
- export declare function parsePrincipalJwks(raw: string | undefined): PrincipalJwtKey[];
53
+ * anchor ⇒ the direct door cannot open (D-G inactive). Each entry must have kid + a pinned alg + a PEM key.
54
+ * #210: fail-closed 语义逐字不变,但每条被丢弃的条目经 `onReject` 报出(序号 + 缺陷字段名,不带值)——
55
+ * 这里的 key 是**公**钥,但 reason 仍只说字段名,与 HMAC 那条同形(两个出口的口径不该分岔)。 */
56
+ export declare function parsePrincipalJwks(raw: string | undefined, onReject: OnKeySetRejection): PrincipalJwtKey[];
33
57
  //# sourceMappingURL=auth-keys.d.ts.map
package/dist/auth-keys.js CHANGED
@@ -8,37 +8,82 @@
8
8
  * (verifyApprovalHmac / verifyPrincipalJwt) stay in ./approval-hmac.ts and ./principal-jwt.ts; consumers import these four names from HERE ([2354] 兼容面全清).
9
9
  */
10
10
  /** Parse APPROVAL_HMAC_KEYS — a sema-registry-managed, rotating key-SET, as a JSON array of {kid,key,status}.
11
- * Empty / unset / unparseable ⇒ `[]` ⇒ HMAC verification is OFF (D-G inactive; back-compat, BFF-only door). */
12
- export function parseApprovalHmacKeys(raw) {
11
+ * Empty / unset / unparseable ⇒ `[]` ⇒ HMAC verification is OFF (D-G inactive; back-compat, BFF-only door).
12
+ * fail-closed 语义与改前**逐字相同**;新增的只有 `onReject`(#210:每一条被丢弃的条目都要说出来) */
13
+ export function parseApprovalHmacKeys(raw, onReject) {
13
14
  if (!raw)
14
15
  return [];
16
+ let v;
15
17
  try {
16
- const v = JSON.parse(raw);
17
- if (!Array.isArray(v))
18
- return [];
19
- return v.filter((k) => !!k && typeof k.kid === "string" && typeof k.key === "string");
18
+ v = JSON.parse(raw);
20
19
  }
21
20
  catch {
21
+ // ⚠️ 解析器自带的 message **不能**转述:V8 的 `JSON.parse` 报错里嵌了输入片段
22
+ // (`Unexpected token 'T', "TOP_SECRET"... is not valid JSON`),而这条 env 的内容就是对称密钥 ——
23
+ // 把它写进 boot 日志等于把密钥前缀印出来。只说「不是合法 JSON」,定位靠 env 名本身。
24
+ onReject({ index: "-", reason: "not valid JSON (value withheld: this env holds secrets)" });
22
25
  return [];
23
26
  }
27
+ if (!Array.isArray(v)) {
28
+ onReject({ index: "-", reason: "not a JSON array of {kid,key,status} entries" });
29
+ return [];
30
+ }
31
+ const out = [];
32
+ v.forEach((raw, i) => {
33
+ const e = raw;
34
+ if (!e || typeof e !== "object") {
35
+ onReject({ index: i, reason: "entry is not an object" });
36
+ return;
37
+ }
38
+ const bad = ["kid", "key"].filter((f) => typeof e[f] !== "string");
39
+ if (bad.length > 0)
40
+ onReject({ index: i, reason: `missing/non-string field(s): ${bad.join(", ")}` });
41
+ else
42
+ out.push(e);
43
+ });
44
+ return out;
24
45
  }
25
46
  /** Parse PRINCIPAL_JWT_PUBKEYS — a JSON array of {kid,alg,key,status}. Empty/unset/malformed ⇒ [] ⇒ no trust
26
- * anchor ⇒ the direct door cannot open (D-G inactive). Each entry must have kid + a pinned alg + a PEM key. */
27
- export function parsePrincipalJwks(raw) {
47
+ * anchor ⇒ the direct door cannot open (D-G inactive). Each entry must have kid + a pinned alg + a PEM key.
48
+ * #210: fail-closed 语义逐字不变,但每条被丢弃的条目经 `onReject` 报出(序号 + 缺陷字段名,不带值)——
49
+ * 这里的 key 是**公**钥,但 reason 仍只说字段名,与 HMAC 那条同形(两个出口的口径不该分岔)。 */
50
+ export function parsePrincipalJwks(raw, onReject) {
28
51
  if (!raw)
29
52
  return [];
30
53
  const ALGS = new Set(["EdDSA", "RS256", "ES256"]);
54
+ let v;
31
55
  try {
32
- const v = JSON.parse(raw);
33
- if (!Array.isArray(v))
34
- return [];
35
- return v.filter((k) => {
36
- const e = k;
37
- return !!e && typeof e.kid === "string" && typeof e.key === "string" && typeof e.alg === "string" && ALGS.has(e.alg);
38
- });
56
+ v = JSON.parse(raw);
39
57
  }
40
58
  catch {
59
+ // 同 parseApprovalHmacKeys:不转述 V8 的 message(它嵌输入片段)。这一口装的是**公**钥,但两个出口
60
+ // 的口径不该分岔 —— 一个「视情况才脱敏」的规矩,迟早会在错的那一侧被抄走。
61
+ onReject({ index: "-", reason: "not valid JSON (value withheld)" });
62
+ return [];
63
+ }
64
+ if (!Array.isArray(v)) {
65
+ onReject({ index: "-", reason: "not a JSON array of {kid,alg,key,status} entries" });
41
66
  return [];
42
67
  }
68
+ const out = [];
69
+ v.forEach((row, i) => {
70
+ const e = row;
71
+ if (!e || typeof e !== "object") {
72
+ onReject({ index: i, reason: "entry is not an object" });
73
+ return;
74
+ }
75
+ const bad = ["kid", "key"].filter((f) => typeof e[f] !== "string");
76
+ // alg 单列:缺席与「不在闭集」是两种手滑,但都必须点名 `alg` —— 运维看到字段名才知道去改哪一格
77
+ // (alg-confusion 防线就挂在这条钉上,静默滤掉等于防线自己消失了没人知道)。
78
+ if (typeof e.alg !== "string")
79
+ bad.push("alg");
80
+ else if (!ALGS.has(e.alg))
81
+ bad.push(`alg (must be one of: ${[...ALGS].join(", ")})`);
82
+ if (bad.length > 0)
83
+ onReject({ index: i, reason: `missing/invalid field(s): ${bad.join(", ")}` });
84
+ else
85
+ out.push(e);
86
+ });
87
+ return out;
43
88
  }
44
89
  //# sourceMappingURL=auth-keys.js.map
@@ -1,4 +1,4 @@
1
- import { type BackgroundAgentRecord, type ToolExecuteContext } from "@sema-agent/core";
1
+ import { type BackgroundAgentRecord, type RunnerDeps, type ToolExecuteContext } from "@sema-agent/core";
2
2
  import type { ServiceConfig } from "../config.js";
3
3
  import type { RunnerDepsOnAsk } from "./runner-deps.js";
4
4
  import { type ApprovalBaselineConfigView, type DeploymentGovernanceConfigView, type LiveQuestionFace } from "../deployment-governance.js";
@@ -46,6 +46,20 @@ export interface ParkedReviveGateDeps {
46
46
  */
47
47
  readonly approverSeat: RunnerDepsOnAsk | undefined;
48
48
  readonly approvalExemptionStore: ApprovalExemptionProbe | undefined;
49
+ /**
50
+ * A-010.1 —— core `RunnerDeps.runtimeCapsResolver` 的**同一只**(main.ts 的 `runtimeCapsResolver`,
51
+ * 单一属主同 `boot/runtime-caps.ts`)。重建条目的第三位决议链元数据 `autoModeArmed` 的唯一判据是
52
+ * per-principal 的 `RuntimeCaps.autoMode`,而它只有 center 解得出 —— 见 {@link autoModePostureOf}。
53
+ * 缺席(无 center 的部署 / dry-run)⇒ core 侧 `runtimeCaps` 也恒 undefined ⇒ 该位在 park 时也从不置位,
54
+ * 两侧同为「不供」,逐字零行为差。
55
+ *
56
+ * 类型从 **core 的载体位**推导(同 `boot/runner-deps.ts:94` 的先例,理由同 {@link RebuiltInheritedGate}:
57
+ * 手抄一个等价签名会让 core 改这条 seam 的那天变成静默漂移而不是编译期事件)。
58
+ */
59
+ readonly resolveRuntimeCaps?: RunnerDeps["runtimeCapsResolver"];
60
+ /** `RunnerDeps.autoMode` 分类器面(信任门半场)在不在场 —— core 的武装条件是
61
+ * `runtimeCaps.autoMode === true` **∧** 这一面在场,两个半场缺一不武装(prepare-task 原式)。 */
62
+ readonly autoModeSeatMounted?: boolean;
49
63
  readonly logger: {
50
64
  info(event: string, fields?: Record<string, unknown>): void;
51
65
  warn(event: string, fields?: Record<string, unknown>): void;
@@ -97,6 +111,8 @@ export type RebuiltParentConstraint = NonNullable<RebuiltInheritedGate["parentCo
97
111
  * 时点语义(成文进 docs/ASSISTANT-WIRE-CONTRACT.md,免运维当 bug 报):
98
112
  * · toolPolicy 轴 = **当前** config(与 resume 重折一致,收紧与放松都被兑现——放松是一次显式运维动作);
99
113
  * · shellGate 轴 = core 取 max(live, park 时 seed)—— 收紧兑现、放松**不**兑现(fail-safe)。
114
+ * · `autoModeArmed` 轴(A-010.1)= 按**当前** per-principal entitlement 现解(见 {@link autoModePostureOf});
115
+ * 与 toolPolicy 轴同族:挂起期间权益被撤/被授 ⇒ 摘要对不上 ⇒ core pre-CAS 响亮拒,不静默换姿势跑。
100
116
  * · `onAsk` 轴 = 部署的活体审批席(`deps.approverSeat`),**在场即供**:park 时那一层冻的就是它。
101
117
  * 席位缺席的部署上不供,而**这不等于 auto-deny**(codex R3-中1 验真纠正):此形恒带
102
118
  * `durableMandate`(见 {@link mandatePostureOf}),继承层判 `ask` 时 core 只要还看得见 checkpoint 店
@@ -107,5 +123,5 @@ export type RebuiltParentConstraint = NonNullable<RebuiltInheritedGate["parentCo
107
123
  * ask 门、scratchpad 豁免……整类)park 时未持久化 ⇒ 重建不出。§2.3 已披露的残余,机制面有特征化钉
108
124
  * (test/parked-revive-e2e.test.ts);真 settings→park→赎回的端到端钉未建(候件,随收口件一起)。
109
125
  */
110
- export declare function createParkedReviveInheritedGate(deps: ParkedReviveGateDeps): (row: BackgroundAgentRecord) => RebuiltInheritedGate;
126
+ export declare function createParkedReviveInheritedGate(deps: ParkedReviveGateDeps): (row: BackgroundAgentRecord) => Promise<RebuiltInheritedGate>;
111
127
  //# sourceMappingURL=parked-revive-gate.d.ts.map
@@ -7,7 +7,9 @@
7
7
  * 治理矩阵的第三维消费腿)。装配次序契约不变:main.ts 仍在同一位置构造,构造条件逐字保持。
8
8
  */
9
9
  import { join } from "node:path";
10
- import { NodeExecutionEnv } from "@sema-agent/core";
10
+ import { createAutoModeDecider, NodeExecutionEnv } from "@sema-agent/core";
11
+ import { recordFailOpen } from "../observability/fail-open.js";
12
+ import { decodeCheckpointScope } from "../security.js";
11
13
  import { createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "../deployment-governance.js";
12
14
  import { applyRuntimeGovernance } from "../runtime-governance.js";
13
15
  import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
@@ -57,6 +59,8 @@ function hostGateCwd(config, localRoot) {
57
59
  * 时点语义(成文进 docs/ASSISTANT-WIRE-CONTRACT.md,免运维当 bug 报):
58
60
  * · toolPolicy 轴 = **当前** config(与 resume 重折一致,收紧与放松都被兑现——放松是一次显式运维动作);
59
61
  * · shellGate 轴 = core 取 max(live, park 时 seed)—— 收紧兑现、放松**不**兑现(fail-safe)。
62
+ * · `autoModeArmed` 轴(A-010.1)= 按**当前** per-principal entitlement 现解(见 {@link autoModePostureOf});
63
+ * 与 toolPolicy 轴同族:挂起期间权益被撤/被授 ⇒ 摘要对不上 ⇒ core pre-CAS 响亮拒,不静默换姿势跑。
60
64
  * · `onAsk` 轴 = 部署的活体审批席(`deps.approverSeat`),**在场即供**:park 时那一层冻的就是它。
61
65
  * 席位缺席的部署上不供,而**这不等于 auto-deny**(codex R3-中1 验真纠正):此形恒带
62
66
  * `durableMandate`(见 {@link mandatePostureOf}),继承层判 `ask` 时 core 只要还看得见 checkpoint 店
@@ -70,8 +74,11 @@ function hostGateCwd(config, localRoot) {
70
74
  export function createParkedReviveInheritedGate(deps) {
71
75
  const { config, question, approvalExemptionStore, logger, localRoot, approverSeat } = deps;
72
76
  const slots = deps.slots ?? sandboxPathEnvSlots;
73
- const mandate = mandatePostureOf(deps);
74
- return (row) => {
77
+ return async (row) => {
78
+ // A-010.1:三位决议链元数据。**async 的唯一理由**就是其中两位要按 principal 现解 entitlement
79
+ // (core 的 resolver 座本身也是 sync-or-async),而这条腿的消费点(parked-decide 的 `decideParkedAgent`)
80
+ // 从来就在 async 里,「席位必须同步」是本文件此前自设的约束,不是结构事实。
81
+ const metadata = await chainMetadataOf(deps, row);
75
82
  // lane 分形与 resolve-spec 同一判别式(单一属主,取值处只此一个)。
76
83
  // 🔑 沙箱腿的 slot 键 = **row.sessionId**(子代自己的会话,design/181 §2.2 裁定):revive 后 core 以
77
84
  // 该 sessionId 铸 env 并注册 slot(subagent → prepare-task → 工厂装饰器),这个键真能绑上。
@@ -108,7 +115,7 @@ export function createParkedReviveInheritedGate(deps) {
108
115
  {
109
116
  policy: governed.toolPolicy ?? baseline,
110
117
  ...(approverSeat !== undefined ? { onAsk: approverSeat } : {}),
111
- ...mandate,
118
+ ...metadata,
112
119
  },
113
120
  ],
114
121
  ...(governed.shellGate !== undefined ? { shellGate: governed.shellGate } : {}),
@@ -116,7 +123,9 @@ export function createParkedReviveInheritedGate(deps) {
116
123
  };
117
124
  }
118
125
  /**
119
- * 重建条目的**决议链元数据**(core 5.22.0 F-012)—— 两位 mandate 的逐字重算。
126
+ * 重建条目的**决议链元数据**(core 5.22.0 F-012)—— 三位的逐字重算,**一次** entitlement 现解喂全三位
127
+ * (第三位与两位 mandate 的 `forceDurableGate` 项读的是同一次 caps,分两次解会开一个「同一条链里两位读到
128
+ * 不同代 entitlement」的窗)。
120
129
  *
121
130
  * 为什么它承重:5.22.0 把 re-supply 契约从 count 换成 `cpv1:` 内容摘要
122
131
  * (`constraintChainDigest`),而摘要 **把这两位卷了进去**。park 时那一层记的是什么,赎回腿就必须供
@@ -140,17 +149,130 @@ export function createParkedReviveInheritedGate(deps) {
140
149
  * 协调器生效(`isLiveQuestionFace` 判 true);缺席 ⇒ spec stamp `QUESTION_AWAITS_RESUME`
141
150
  * (该 sentinel 被 `isLiveQuestionFace` 判 false)。两支都归结为 `question` 在不在场。
142
151
  *
143
- * **已披露残余(fail-closed 且响亮,不是静默放行)**:`runtimeCaps.forceDurableGate`(center 逐
144
- * principal 的授权位)在本装配点解不出来 —— core `runtimeCapsResolver` async 且按 principal 现解,
145
- * 而重供席位的形是同步 `(row) => gate`。该位为真、且部署又装了活体席的组合上,park 记 true 而本腿供
146
- * false core pre-CAS 响亮拒、checkpoint 留 pending(带 `parked_resume.startup_failed` 码可观测),
147
- * 绝不会让赎回腿在比挂起时更松的链下跑起来。收口件二选一:core 把该位一起持久进链条目,或重供席位改
148
- * async resolver 进得来。
152
+ * **原「已披露残余」销账(A-010.1 同车)**:`runtimeCaps.forceDurableGate`(center 逐 principal 的
153
+ * 授权位)此前写着「在本装配点解不出来——resolver async 而重供席位的形是同步 `(row) => gate`」。那条
154
+ * 阻塞是**本文件自设**的,不是结构事实:唯一的消费点(`parked-decide.ts` `decideParkedAgent`)本来
155
+ * 就在 async 里。席位随本件改成 async 之后,该位与 `autoModeArmed` 走同一条 caps 现解腿一并补齐 —— 否则
156
+ * 「该位为真 + 部署装了活体席」这个组合上 park 记 true 而本腿供 false,摘要恒不匹配、这批 principal 的
157
+ * parked 卡同样永不可赎(与 A-010.1 同族同因的第二个总失效面)。
158
+ *
159
+ * ⚠️ 时点语义与 toolPolicy 轴同族:两位都按**当前** entitlement 现解,挂起期间被撤/被授 ⇒ 摘要对不上
160
+ * ⇒ core pre-CAS 响亮拒(带 `parked_resume.startup_failed` 码可观测),绝不静默按新姿势跑。
161
+ */
162
+ async function chainMetadataOf(deps, row) {
163
+ const caps = await resolveCapsFor(deps, row);
164
+ const forced = caps?.forceDurableGate === true;
165
+ return {
166
+ ...(forced || deps.approverSeat === undefined ? { durableMandate: true } : {}),
167
+ ...(forced || deps.question === undefined ? { contentMandate: true } : {}),
168
+ ...autoModePostureOf(deps, row, caps),
169
+ };
170
+ }
171
+ /**
172
+ * 本行 principal 的 entitlement 现解(三位元数据共用**一次**)。
173
+ *
174
+ * principal 从哪来:`row.scope`。承重的不是「两个写入方碰巧写了同一个字符串」,而是**取数结构**:
175
+ * `findParkedAgentForCheckpoint` 走的是 `agentStore.listByScope(cp.scope, …)` 这条**按 scope 分区**的
176
+ * 查询(parked-decide.ts),所以任何能走到本函数的行都满足 `row.scope === cp.scope` —— 这是构造式的,
177
+ * 不是巧合。而 `cp.scope` 由 `boot/resolve-spec.ts` 写成 `encodeCheckpointScope(auth.principal)`
178
+ * (legacy resume 腿反向读回 `principal: decodeCheckpointScope(cp.scope)` 是同一等式的另一半),
179
+ * 故 `decodeCheckpointScope(row.scope)` 就是那条 run 的 `spec.principal`,与 core 喂给
180
+ * `runtimeCapsResolver(spec.principal)` 的**同一个**值 —— 摘要两侧因此解到同一份 caps。
181
+ *
182
+ * ⚠️ 别把 `row.scope` 当成「和 `cp.scope` 同一个写入方铸的」:**不是**。写行那一侧(core `subagent.js`
183
+ * 的 `ctx.principal ?? opts.background.scope`,本仓 `capabilities/scenarios.ts` 填字面 `"default"`)用的是
184
+ * `principal ?? "default"` 这套约定,与 `encodeCheckpointScope` 的 `"_"` 哨兵**不同源**。有 principal 时
185
+ * 两套折出同一个字符串(encode 只在 null/undefined 时才替换),所以上面那条等式成立;**匿名** run 上两者
186
+ * 分别是 `"default"` 与 `"_"`,分区查询直接落空 ⇒ 本函数根本到不了(decide 退回 legacy resume 腿,
187
+ * parked-decide.ts 自述的「未命中=零回归」形)。谁哪天去弥合那个匿名未命中,必须**连本函数一起**重看:
188
+ * 那时 `decodeCheckpointScope("default")` 会解出字符串 `"default"` 并把它当租户名递进 resolver。
189
+ *
190
+ * resolver 缺席(无 center / dry-run)⇒ core 侧 `runtimeCaps` 也恒 undefined ⇒ 这两位在 park 时也从不
191
+ * 由 caps 置位 ⇒ 逐字零行为差。resolver 抛错(契约上它自己 fail-closed 且不抛)⇒ 当作没解出来:元数据
192
+ * 位按「无 caps」算 ⇒ 与 park 记的对不上就响亮拒,**绝不**静默按更松的姿势赎回。
193
+ */
194
+ async function resolveCapsFor(deps, row) {
195
+ if (deps.resolveRuntimeCaps === undefined)
196
+ return undefined;
197
+ try {
198
+ return await deps.resolveRuntimeCaps(decodeCheckpointScope(row.scope));
199
+ }
200
+ catch (err) {
201
+ // 留痕是必需的:否则「这批卡为什么突然赎不动」查无痕迹。
202
+ deps.logger.warn("parked_revive_caps_unresolved", { handle: row.handle, err: err instanceof Error ? err.message : String(err) });
203
+ return undefined;
204
+ }
205
+ }
206
+ /**
207
+ * A-010.1 —— 重建条目的**第三位**决议链元数据 `autoModeArmed`(core 5.22.0 F-012 与两位 mandate 同车)。
208
+ *
209
+ * ## 病(本函数存在的唯一理由)
210
+ * core 的摘要口(`tool-policy.js` `constraintChainEntryOf`)把**三**位卷进 `cpv1:` 摘要,而判据是
211
+ * 「条目上有没有 `autoMode` 这个键」:park 时 `prepare-task.js` 的 `inheritedGateForChildren` 写
212
+ * `...(autoModeDecider !== undefined ? { autoMode: { decider } } : {})`,赎回时 `runtask.js` 的 pre-CAS
213
+ * 门按 `pc.autoMode !== undefined` 逐字重算。本腿此前只供 policy/onAsk/两位 mandate,**从不供** autoMode
214
+ * ⇒ auto-mode 权益 principal 的每一条跨副本 parked 赎回摘要恒不匹配、`resume.parent_constraint_mismatch`
215
+ * 响亮拒、卡永不可赎(方向 fail-safe,但功能面对这批 principal 是整条失效)。
216
+ *
217
+ * ## core 的原式(逐字对位,不绕过)
218
+ * ```
219
+ * if (runtimeCaps?.autoMode === true && deps.autoMode !== undefined) autoModeDecider = createAutoModeDecider(…)
220
+ * ```
221
+ * 两个半场本仓分别对位:
222
+ * · `deps.autoMode !== undefined` ⇒ {@link ParkedReviveGateDeps.autoModeSeatMounted}(装配面恒挂,见
223
+ * `boot/runner-deps.ts` 的 `autoMode:{onBreakerOpen}`——**信任门**,单挂它武装不了任何东西);
224
+ * · `runtimeCaps.autoMode === true` ⇒ {@link resolveCapsFor} 按**本行的 principal** 现解(时点语义、
225
+ * principal 从哪来、resolver 缺席/抛错怎么判,全在那只函数的头注)。
226
+ *
227
+ * ## ⛔ 已披露残余:decider 的**身份**跨不了进程(P-DEBT,不是等价替换)
228
+ * 摘要只看键在不在,但 core 在赎回后的每一次继承 ask 上会**真的调** `pc.autoMode.decider`
229
+ * (`prepare-task.js` 的继承包装器,顺序:分类器 → 冻结审批席)。而那只 decider 是祖先任务上的活闭包
230
+ * (绑着它自己的 session 转写窗 + brain),跨副本重建不出来 —— 与本文件 `approverSeat` 那格「诚实的降级」
231
+ * 同族。本腿交的是 **core 自己的构造器**铸的一只 decider,其 classify 腿如实拒答 ⇒ core 收到
232
+ * `unavailable`(它自己 `.catch(() => ({kind:"unavailable"}))` 的同一形)⇒ **不产生任何自动裁决**,原样
233
+ * 落到祖先冻结审批席那条链上(本腿的席位又是无 ALS 的降级形 ⇒ `approverUnavailable` ⇒ 再 park 给人)。
234
+ * 方向:分类器本会 `allow` 的调用改成问人(**更严**);本会 `block` 的也改成问人(**不是自动放行**,但
235
+ * 确实比自动拒松一档)。所以它按 P-DEBT 记债而不是当合法兜底 —— tag
236
+ * `server.parked-revive.ancestor-classifier-unreachable`,逐次计数 + 一次性 warn,收口件二选一:core 把
237
+ * 分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。
238
+ */
239
+ function autoModePostureOf(deps, row, caps) {
240
+ if (deps.autoModeSeatMounted !== true || caps?.autoMode !== true)
241
+ return {};
242
+ return { autoMode: { decider: createUnreachableAncestorClassifier(deps, row) } };
243
+ }
244
+ /**
245
+ * 祖先分类器的**跨进程降级形**(全部理由在 {@link autoModePostureOf} 的残余段)。
246
+ *
247
+ * 内核用 core 自己的 `createAutoModeDecider` 铸 —— 超时/熔断/verdict 词表(`unavailable` 这个词本身)
248
+ * 都归 core 单一属主,本仓只提供一条**如实拒答**的 classify 腿,绝不在此手抄 `{kind:"unavailable"}`。
249
+ * `failureThreshold: 1` 让熔断一拍即开:第一次被咨询就把一次性告警喊出来,之后每次 `decide` 在 core 内
250
+ * 早返 `unavailable`(不再进 classify、不再起 15s 定时器)。
251
+ *
252
+ * 🔴 计数**必须**打在外层 `decide` 上、不能打在 `classify` 里:熔断开了以后 core 早返、classify 再也不
253
+ * 被调用,计数打在里面就永远只记 1 —— 而这个 tag 的语义(和普查表里那一行的文字)是「**丢了祖先分类器
254
+ * 判决的继承 ask 次数**」。打在里面等于让遥测读数与它自称的含义不是一回事,债就在账上消失了。
149
255
  */
150
- function mandatePostureOf(deps) {
256
+ function createUnreachableAncestorClassifier(deps, row) {
257
+ const inner = createAutoModeDecider({
258
+ failureThreshold: 1,
259
+ classify: async () => {
260
+ throw new Error("the ancestor task's auto-mode classifier is a live closure over that task's own transcript/brain — it cannot be " +
261
+ "reconstructed on a fresh replica, so this inherited layer has no classifier verdict to give (the call falls " +
262
+ "through to the ancestor's frozen approver chain)");
263
+ },
264
+ onBreakerOpen: () => deps.logger.warn("parked_revive_ancestor_classifier_unreachable", {
265
+ handle: row.handle,
266
+ sessionId: row.sessionId,
267
+ note: "an inherited ancestor layer was auto-mode armed at park time; its classifier does not survive the process boundary — inherited asks resolve on the approver chain (durable park) instead of a classifier verdict",
268
+ }),
269
+ });
151
270
  return {
152
- ...(deps.approverSeat === undefined ? { durableMandate: true } : {}),
153
- ...(deps.question === undefined ? { contentMandate: true } : {}),
271
+ breakerOpen: () => inner.breakerOpen(),
272
+ decide: async (input, signal) => {
273
+ recordFailOpen("server.parked-revive.ancestor-classifier-unreachable", row.handle);
274
+ return inner.decide(input, signal);
275
+ },
154
276
  };
155
277
  }
156
278
  /** host 腿的裁决 env(真 fs;构造是纯字段赋值,不 spawn)。**每次赎回现铸**——与「禁 memoize」同因:
@@ -0,0 +1,49 @@
1
+ /**
2
+ * #203 §3(design/203 v2 §6 F4 的**残余采纳**)—— 默认 ON 的**休眠行审计**。
3
+ *
4
+ * 🔴 这条审计存在的唯一理由:`PERMISSION_RULES_ENABLED` 的默认值在本车从 OFF 翻成 ON,而**旋钮翻转
5
+ * 与规则行的寿命是两条独立的时间线**。三张表自 #154 车二起就随中央 `ensureSchema` 建、并且从来没有被
6
+ * 清过,所以下面这两类部署在升级的那一刻会突然带着**既有**规则行开始放行:
7
+ * · 曾经把旋钮开过一段时间又关掉的部署(行留在库里,只是没人读);
8
+ * · 从旧库恢复/克隆出来的部署(行跟着数据一起来的)。
9
+ * 对运维来说,这是「我什么都没改,worker 却开始少问我了」——一次**放宽面**上的静默变化。审计做的事
10
+ * 很小但是承重:在启动日志里把它说出来,让「少问」是一条能被读到的因果,而不是一次玄学。
11
+ *
12
+ * ── 三条判据,逐条有理由 ────────────────────────────────────────────────────────────────────────
13
+ * ① **店缺席 ⇒ 整条不上场**(零 I/O、零行)。店缺席包括:显式关旋钮、`local` 车道没接 File 店的旧
14
+ * 部署、以及压根没有 backend 的 env-only worker。没有店就没有行,谈不上唤醒。
15
+ * ② **零桶 ⇒ 不打行**。一条「0 条既有规则随默认 ON 激活」的行是纯噪音:它对读日志的人不传递任何
16
+ * 可行动的信息,却让真正有东西被唤醒的那一行变得不显眼。
17
+ * ③ **运维**显式**表过态(`PERMISSION_RULES_ENABLED` 被设成 true 或 false)⇒ 不打行**。那些行本来
18
+ * 就是活的(或者旋钮被显式关着、店根本没装配),默认值翻不翻转对它们零影响 —— 说「随默认 ON
19
+ * 激活」是**假话**,而在启动日志里说假话比不说更坏。判据用 `permissionRulesEnabledExplicit`
20
+ * (config.ts 与 `dbBackendExplicit` 同姿势),不是猜。
21
+ *
22
+ * ── 失败方向 ────────────────────────────────────────────────────────────────────────────────────
23
+ * 数不出来(库抖动 / 文件读不了)⇒ **不拒启、也不编一个 0 出来**:返回 `undefined` + 一条 warn。
24
+ * 这是一条纯诊断面(它不参与任何放行/拒绝判决),所以它的降级方向是「安静地不知道」而不是「谎报
25
+ * 零」——`{ buckets: 0 }` 会被下游读成「确认没有休眠行」,那是把一次读失败伪装成一个结论。
26
+ * 因为它零判决,这条 fail-open 不属于 `docs/FAIL-OPEN-CENSUS.md` 的安全轴 P 类,故不占 `recordFailOpen`
27
+ * 的标签;它的可见性由那条 warn 承担。
28
+ */
29
+ /** 审计只需要一只桶计数口——**刻意**不吃整个 `PermissionRuleStoreBundle`:诊断面对店的其余能力
30
+ * 一无所知也不该知道,窄口让测试能用四行 fixture 驱动真实现同一段代码。 */
31
+ export interface DormantRuleCounter {
32
+ /** 库里已存在的规则**桶**(一只 owner 一只桶)数量。 */
33
+ countBuckets(): Promise<number>;
34
+ }
35
+ export interface DormantRuleAuditCtx {
36
+ /** 已装配的规则店束;`undefined` = 本部署没有规则店(判据 ①)。 */
37
+ stores: DormantRuleCounter | undefined;
38
+ logger: {
39
+ info(msg: string, meta?: unknown): void;
40
+ warn?(msg: string, meta?: unknown): void;
41
+ };
42
+ /** 运维是否**显式**设过 `PERMISSION_RULES_ENABLED`(判据 ③)。 */
43
+ explicit: boolean;
44
+ }
45
+ /** boot 期一次性调用。返回读数(`undefined` = 没店 / 数不出来),绝不抛。 */
46
+ export declare function auditDormantPermissionRules(ctx: DormantRuleAuditCtx): Promise<{
47
+ buckets: number;
48
+ } | undefined>;
49
+ //# sourceMappingURL=permission-rules-audit.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * #203 §3(design/203 v2 §6 F4 的**残余采纳**)—— 默认 ON 的**休眠行审计**。
3
+ *
4
+ * 🔴 这条审计存在的唯一理由:`PERMISSION_RULES_ENABLED` 的默认值在本车从 OFF 翻成 ON,而**旋钮翻转
5
+ * 与规则行的寿命是两条独立的时间线**。三张表自 #154 车二起就随中央 `ensureSchema` 建、并且从来没有被
6
+ * 清过,所以下面这两类部署在升级的那一刻会突然带着**既有**规则行开始放行:
7
+ * · 曾经把旋钮开过一段时间又关掉的部署(行留在库里,只是没人读);
8
+ * · 从旧库恢复/克隆出来的部署(行跟着数据一起来的)。
9
+ * 对运维来说,这是「我什么都没改,worker 却开始少问我了」——一次**放宽面**上的静默变化。审计做的事
10
+ * 很小但是承重:在启动日志里把它说出来,让「少问」是一条能被读到的因果,而不是一次玄学。
11
+ *
12
+ * ── 三条判据,逐条有理由 ────────────────────────────────────────────────────────────────────────
13
+ * ① **店缺席 ⇒ 整条不上场**(零 I/O、零行)。店缺席包括:显式关旋钮、`local` 车道没接 File 店的旧
14
+ * 部署、以及压根没有 backend 的 env-only worker。没有店就没有行,谈不上唤醒。
15
+ * ② **零桶 ⇒ 不打行**。一条「0 条既有规则随默认 ON 激活」的行是纯噪音:它对读日志的人不传递任何
16
+ * 可行动的信息,却让真正有东西被唤醒的那一行变得不显眼。
17
+ * ③ **运维**显式**表过态(`PERMISSION_RULES_ENABLED` 被设成 true 或 false)⇒ 不打行**。那些行本来
18
+ * 就是活的(或者旋钮被显式关着、店根本没装配),默认值翻不翻转对它们零影响 —— 说「随默认 ON
19
+ * 激活」是**假话**,而在启动日志里说假话比不说更坏。判据用 `permissionRulesEnabledExplicit`
20
+ * (config.ts 与 `dbBackendExplicit` 同姿势),不是猜。
21
+ *
22
+ * ── 失败方向 ────────────────────────────────────────────────────────────────────────────────────
23
+ * 数不出来(库抖动 / 文件读不了)⇒ **不拒启、也不编一个 0 出来**:返回 `undefined` + 一条 warn。
24
+ * 这是一条纯诊断面(它不参与任何放行/拒绝判决),所以它的降级方向是「安静地不知道」而不是「谎报
25
+ * 零」——`{ buckets: 0 }` 会被下游读成「确认没有休眠行」,那是把一次读失败伪装成一个结论。
26
+ * 因为它零判决,这条 fail-open 不属于 `docs/FAIL-OPEN-CENSUS.md` 的安全轴 P 类,故不占 `recordFailOpen`
27
+ * 的标签;它的可见性由那条 warn 承担。
28
+ */
29
+ /** boot 期一次性调用。返回读数(`undefined` = 没店 / 数不出来),绝不抛。 */
30
+ export async function auditDormantPermissionRules(ctx) {
31
+ if (ctx.stores === undefined)
32
+ return undefined; // ①
33
+ let buckets;
34
+ try {
35
+ buckets = await ctx.stores.countBuckets();
36
+ }
37
+ catch (err) {
38
+ ctx.logger.warn?.("permission_rules_dormant_audit_failed", {
39
+ error: err instanceof Error ? err.message : String(err),
40
+ note: "the boot audit could not count pre-existing permission-rule buckets; the lane itself is unaffected",
41
+ });
42
+ return undefined;
43
+ }
44
+ if (buckets > 0 && !ctx.explicit) {
45
+ // ②③ 都不成立才说话。文案给的是**一条可行动的因果**:看到少问了 ⇒ 这里有 N 只既有桶 ⇒ 用
46
+ // `GET /v1/rules` 看、用 `DELETE /v1/rules` 收,或者把旋钮显式关掉。
47
+ ctx.logger.info("permission_rules_activated_by_default", {
48
+ buckets,
49
+ knob: "PERMISSION_RULES_ENABLED",
50
+ explicit: false,
51
+ note: `${buckets} pre-existing permission-rule bucket(s) went live with this release's ON-by-default flip — ` +
52
+ "inspect with GET /v1/rules, revoke with DELETE /v1/rules, or set PERMISSION_RULES_ENABLED=false to keep the lane off",
53
+ });
54
+ }
55
+ return { buckets };
56
+ }
57
+ //# sourceMappingURL=permission-rules-audit.js.map