@sema-agent/server 7.12.0 → 7.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/USAGE.md +30 -2
  2. package/dist/adoption/plan.d.ts +38 -4
  3. package/dist/adoption/plan.js +72 -0
  4. package/dist/adoption/quiesce.d.ts +70 -0
  5. package/dist/adoption/quiesce.js +148 -0
  6. package/dist/adoption/runner.js +63 -5
  7. package/dist/adoption/sql.d.ts +15 -0
  8. package/dist/adoption/sql.js +18 -0
  9. package/dist/adoption/wire.d.ts +7 -1
  10. package/dist/adoption/wire.js +6 -0
  11. package/dist/approval-card.d.ts +5 -0
  12. package/dist/approval-card.js +22 -0
  13. package/dist/boot/permission-rules-audit.js +29 -1
  14. package/dist/boot/reapers.d.ts +15 -0
  15. package/dist/boot/reapers.js +101 -44
  16. package/dist/boot/runner-deps.d.ts +16 -2
  17. package/dist/boot/runner-deps.js +5 -4
  18. package/dist/config-types.d.ts +14 -2
  19. package/dist/http/active-run-conflict.d.ts +33 -8
  20. package/dist/http/active-run-conflict.js +37 -2
  21. package/dist/http/routes/adoption.js +25 -2
  22. package/dist/http/routes/approvals-assistant.js +33 -3
  23. package/dist/http/routes/capabilities.js +25 -1
  24. package/dist/http/routes/images.js +18 -0
  25. package/dist/http/routes/runs.js +21 -5
  26. package/dist/http/routes/tasks.js +18 -6
  27. package/dist/http/server.d.ts +26 -8
  28. package/dist/http/server.js +102 -16
  29. package/dist/main.js +46 -6
  30. package/dist/observability/fail-open.d.ts +4 -0
  31. package/dist/observability/fail-open.js +4 -0
  32. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  33. package/dist/plugins/adoption-log-sql.js +69 -2
  34. package/dist/plugins/file-run-store.d.ts +85 -1
  35. package/dist/plugins/file-run-store.js +450 -17
  36. package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
  37. package/dist/plugins/permission-rule-store-sql.js +60 -2
  38. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  39. package/dist/plugins/shared-memory-store-sql.js +55 -18
  40. package/dist/plugins/sql-driver.d.ts +19 -0
  41. package/dist/plugins/sql-driver.js +12 -0
  42. package/dist/plugins/store-backend.js +24 -1
  43. package/dist/rules-consent.d.ts +33 -4
  44. package/dist/rules-consent.js +43 -2
  45. package/dist/run-local.js +6 -2
  46. package/dist/runtime-governance.d.ts +33 -0
  47. package/dist/runtime-governance.js +32 -0
  48. package/dist/tool-approval.d.ts +32 -0
  49. package/dist/tool-approval.js +20 -0
  50. package/dist/trace/core-keyset-guard.d.ts +1 -1
  51. package/package.json +3 -3
@@ -190,7 +190,12 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
190
190
  created_at_ms BIGINT NOT NULL,
191
191
  updated_at_ms BIGINT NOT NULL,
192
192
  PRIMARY KEY (record_id),
193
- KEY idx_permission_rule_approval_owner (owner_key)
193
+ KEY idx_permission_rule_approval_owner (owner_key),
194
+ -- 保留期腿的**支撑索引**(A-010.17 / codex R2 [high])。这张表按设计**永久**留着 approved/redeemed
195
+ -- 的审计事实,所以它只增不减 ⇒ 一条没有索引的 WHERE state='pending' AND created_at_ms < ? 谓词
196
+ -- 是一次随审计史无限增长的全表扫,而且每 tick 一次。(state, created_at_ms) 复合序把清扫收敛成
197
+ -- 一次窄区间扫:pending 段本来就短命,超期的那几行紧挨着。
198
+ KEY idx_permission_rule_approval_sweep (state, created_at_ms)
194
199
  ) COLLATE utf8mb4_bin`,
195
200
  `CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
196
201
  ticket_id VARCHAR(190) NOT NULL,
@@ -209,7 +214,9 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
209
214
  consumed_at_ms BIGINT NULL,
210
215
  created_at_ms BIGINT NOT NULL,
211
216
  PRIMARY KEY (ticket_id),
212
- KEY idx_permission_rule_ticket_owner (owner_key)
217
+ KEY idx_permission_rule_ticket_owner (owner_key),
218
+ -- 保留期腿的支撑索引(同上):WHERE expires_at_ms <= ? 是每 tick 一次的区间扫。
219
+ KEY idx_permission_rule_ticket_expires (expires_at_ms)
213
220
  ) COLLATE utf8mb4_bin`,
214
221
  ];
215
222
  /** {@link TIDB_PERMISSION_RULE_STATEMENTS} 的遍历壳(生产路径走 `tidb-pool.ts` 中央 `ensureSchema`;
@@ -255,6 +262,10 @@ export async function ensurePgPermissionRuleSchema(q) {
255
262
  PRIMARY KEY (record_id)
256
263
  )`);
257
264
  await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_owner ON ${PERMISSION_RULE_APPROVAL_TABLE} (owner_key)`);
265
+ // 保留期腿的支撑索引(MySQL 孪生的行内注写了理由:这张表按设计永久留审计事实,无索引的清扫谓词
266
+ // 是一次随史增长的全表扫)。PG 侧是独立 CREATE INDEX —— 存量库上 `IF NOT EXISTS` 会**真的补建**,
267
+ // 与 MySQL 侧「索引写在 CREATE TABLE 里、存量表拿不到」的不对称如实登记在 reapExpired 的注里。
268
+ await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_approval_sweep ON ${PERMISSION_RULE_APPROVAL_TABLE} (state, created_at_ms)`);
258
269
  await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TICKET_TABLE} (
259
270
  ticket_id VARCHAR(190) COLLATE "C" NOT NULL,
260
271
  owner_key VARCHAR(190) COLLATE "C" NOT NULL,
@@ -268,6 +279,7 @@ export async function ensurePgPermissionRuleSchema(q) {
268
279
  PRIMARY KEY (ticket_id)
269
280
  )`);
270
281
  await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
282
+ await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_expires ON ${PERMISSION_RULE_TICKET_TABLE} (expires_at_ms)`);
271
283
  }
272
284
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
273
285
  // 纯函数(桶键 / 摘要 / delta 折叠)
@@ -807,11 +819,57 @@ export class SqlRuleImportTicketStore {
807
819
  };
808
820
  }
809
821
  }
822
+ /**
823
+ * 孤儿 pending 审批记录的保留期(A-010.17)。
824
+ *
825
+ * 判据是「**可证已死**」,不是一个拍脑袋的时长:一条 pending 记录只能经 `redeemRuleTicket` 走活,
826
+ * 而兑付要么发生在铸它的**那一次请求内**(`persistCardRule` 的 prepare→confirm→redeem 三步同请求),
827
+ * 要么要拿一张导入票 —— 而票自铸起最多活 {@link RULE_IMPORT_TICKET_TTL_MS}(10 分钟,`consume` 的
828
+ * `expires_at_ms > now` 是硬条件)。所以创建时刻早于「now − 票 TTL」的 pending 记录**再也不可能**
829
+ * 被兑付。24 小时是在这条上界之上再压两个数量级的余量,留给运维「昨天那次导入怎么没成」的排查窗。
830
+ *
831
+ * 🔴 **只收 pending**。`approved` / `redeemed` 是一次真人同意的**审计事实**(与 `discardPendingRecord`
832
+ * 的 `WHERE state = 'pending'` 同一条硬约束,也与收编把这张表判成 D2「史实不改写」同源)——
833
+ * 保留期策略动不到它们,本腿一行都不碰。
834
+ */
835
+ export const RULE_PENDING_APPROVAL_RETENTION_MS = 24 * 60 * 60_000;
836
+ /**
837
+ * 保留期腿**每轮**最多删多少行(codex R2 [high])。
838
+ *
839
+ * 为什么必须有界:`permission_rule_approval` 按设计**永久**留 approved/redeemed 的审计事实(收编把它
840
+ * 判成 D2「史实不改写」,`discardPendingRecord` 的 `WHERE state='pending'` 也是同一条硬约束)——
841
+ * 也就是说这张表**只增不减**。一条无界 DELETE 在首次开清扫、或一次长期没跑的部署上,会在一个事务里
842
+ * 处理整个积压。500 的取值:一轮的最坏工作量钉在「几百行删除」这个数量级,而常态每轮的真实行数是个位数
843
+ * (一次 prepare 留一行);积压按轮渐进清空,每轮都是完整语义,绝不留半干净状态。
844
+ *
845
+ * ⚠️ 两方言的**索引到货方式不对称**,如实登记:PG 侧是独立的 `CREATE INDEX IF NOT EXISTS`,存量库
846
+ * 下次 `ensureSchema` 就补建;MySQL 侧索引写在 `CREATE TABLE` 里,而本仓 schema 口径是「启动 DDL 是唯一
847
+ * 真源、不发 ALTER」⇒ **存量表拿不到这两个索引**,要靠一次删库重建(与 7.8.0 / 7.10.0 两次 BREAKING 窗
848
+ * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
849
+ */
850
+ export const RULE_REAP_BATCH = 500;
810
851
  export function createSqlPermissionRuleStores(db, now) {
852
+ const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
811
853
  return {
812
854
  provider: new SqlPermissionRuleStoreProvider(db, now),
813
855
  approvals: new SqlRuleApprovalRecordStore(db, now),
814
856
  tickets: new SqlRuleImportTicketStore(db, now),
857
+ reapExpired: async (nowMs) => {
858
+ // 两条 DELETE **不包事务**:它们互相独立、各自幂等,一条失败不该把另一条已删的行回滚回来
859
+ // (维护腿的既有姿势 —— 邻居 reaper 腿全是独立语句)。
860
+ //
861
+ // 🔴 **每 tick 有界**(codex 对抗复审 R2 [high],验真后修):第一版是两条**无界** DELETE。
862
+ // 首次开清扫(或一次长时间没跑的部署)会在**一个事务**里删掉整个积压 —— 一条能跑很久、锁很多行、
863
+ // 把 binlog/WAL 顶起来的语句;而调用点当时又没有重入守卫,一旦耗时越过 tick 间隔,下一轮就叠上来。
864
+ // 收成每轮 {@link RULE_REAP_BATCH} 行:积压按轮**渐进**清空(每轮都是完整语义,不留半干净状态),
865
+ // 单条语句的最坏时长与库大小脱钩。调用侧另配了 in-flight 守卫(`boot/reapers.ts`)。
866
+ const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
867
+ // PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
868
+ // 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
869
+ `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs]);
870
+ const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
871
+ return deadTickets.affected + orphanPending.affected;
872
+ },
815
873
  // 两个方言逐字同形(`COUNT(*)` 无方言差),所以刻意**不**走 `q(tidb, pg)` 的双串姿势 —— 那会造出
816
874
  // 两份可以各自漂的同一句 SQL。参数空数组:本语句没有绑定位。
817
875
  countBuckets: async () => {
@@ -154,10 +154,15 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
154
154
  signal?: AbortSignal;
155
155
  }): Promise<SharedMemorySnapshot>;
156
156
  /**
157
- * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。方言差异显式:
158
- * · MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 一个事务内的两条 SELECT 天然同快照;
159
- * · PG 默认 READ COMMITTED,**每条语句各自取快照** ⇒ 必须显式抬到 REPEATABLE READ,否则这个事务
160
- * 对本条竞态毫无作用(那正是"包了事务就以为一致"最容易踩空的地方)。
157
+ * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
158
+ *
159
+ * 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
160
+ * 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
161
+ * 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
162
+ * MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
163
+ * 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
164
+ * 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
165
+ * MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
161
166
  */
162
167
  private readBinding;
163
168
  private readerFor;
@@ -194,12 +199,21 @@ export declare class SqlSharedMemoryStore implements SharedMemoryStoreProvider {
194
199
  * 与插入之间穿过去,留下一行孤儿文档 —— 被删的内容在 id 复用时复活。
195
200
  */
196
201
  putDocument(record: SharedMemoryDocumentRecord): Promise<void>;
197
- /** Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
198
- * successor tenant's library). Returns whether a row was actually there. */
202
+ /**
203
+ * Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
204
+ * successor tenant's library). Returns whether a row was actually there.
205
+ *
206
+ * 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
207
+ * 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
208
+ * (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
209
+ * 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
210
+ * 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
211
+ * 拿到锁之后再删。
212
+ *
213
+ * 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
214
+ * 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
215
+ */
199
216
  deleteDocument(storeId: string, scopeKey: string, path: string): Promise<boolean>;
200
- /** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
201
- * 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
202
- private assertOwnedBy;
203
217
  /**
204
218
  * De-register a store AND its documents, ATOMICALLY.
205
219
  *
@@ -215,17 +215,20 @@ export class SqlSharedMemoryStore {
215
215
  };
216
216
  }
217
217
  /**
218
- * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)。方言差异显式:
219
- * · MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 一个事务内的两条 SELECT 天然同快照;
220
- * · PG 默认 READ COMMITTED,**每条语句各自取快照** ⇒ 必须显式抬到 REPEATABLE READ,否则这个事务
221
- * 对本条竞态毫无作用(那正是"包了事务就以为一致"最容易踩空的地方)。
218
+ * 盘状态 + 库登记,**一个事务一个快照**(轮2 F3 修)
219
+ *
220
+ * 🔴 A-010.11(两臂不对称,验真后修):此前 PG 臂显式抬隔离级别、**MySQL 臂只靠服务端默认值**,
221
+ * 注里写的是「MySQL/TiDB 的默认隔离级别就是 REPEATABLE READ ⇒ 天然同快照」。那句话对**默认配置**
222
+ * 为真,但 `transaction_isolation` 是可设的服务端/会话变量:一台跑 READ COMMITTED 的实例(托管
223
+ * MySQL 的厂商默认、中间层代理、运维手改)会把「一个事务一个快照」悄悄降成「每条语句各自取快照」,
224
+ * 而本方法**不会报任何错**——它只是读到一对撕裂的 scope/store 行。「靠默认值成立」不是结构保证。
225
+ * 两臂现在都走 {@link SqlTxConn.beginRepeatableRead}(语句文本与**摆放位置**的方言分歧写在那里:
226
+ * MySQL 必须在 BEGIN **之前**发、PG 必须在 BEGIN **之后**发)。
222
227
  */
223
228
  async readBinding(scopes) {
224
229
  const conn = await this.db.connect();
225
230
  try {
226
- await conn.begin();
227
- if (this.db.dialect === "pg")
228
- await conn.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
231
+ await conn.beginRepeatableRead();
229
232
  const scopeRows = await conn.query(this.q(`SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")})`, `SELECT scope_key, state, message FROM ${SHARED_MEMORY_SCOPE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")})`), [...scopes]);
230
233
  const storeRows = await conn.query(this.q(`SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map(() => "?").join(", ")}) ORDER BY store_id`, `SELECT store_id, scope_key, description, writable FROM ${SHARED_MEMORY_STORE_TABLE} WHERE scope_key IN (${scopes.map((_, i) => `$${i + 1}`).join(", ")}) ORDER BY store_id`), [...scopes]);
231
234
  await conn.commit();
@@ -444,23 +447,57 @@ export class SqlSharedMemoryStore {
444
447
  throw new Error(`shared-memory store ${JSON.stringify(record.storeId)} is not registered under ${JSON.stringify(record.scopeKey)} — either it was never created, or the id has since been recycled by another org (call putStore() first; a stale retry must NOT land in the new tenant's library)`);
445
448
  }
446
449
  }
447
- /** Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
448
- * successor tenant's library). Returns whether a row was actually there. */
450
+ /**
451
+ * Remove one document. Scope-fenced like {@link putDocument} (a stale delete must not reach a
452
+ * successor tenant's library). Returns whether a row was actually there.
453
+ *
454
+ * 🔴 A-010.14(验真后修):此前围栏是**两条独立语句** —— `assertOwnedBy()` 先查一次登记表,然后另
455
+ * 起一条 DELETE。它自称与 `putDocument` 对齐,但 `putDocument` 的检查与写是**同事务同行锁**
456
+ * (`FOR UPDATE`),而这里的两条语句之间有一道真窗:登记检查通过之后、DELETE 发出之前,另一个 org
457
+ * 完成 `deleteStore` + `putStore` 的 id 复用,这条 DELETE 就落进**继任租户**的库里删掉他们的文档。
458
+ * 那正是 `putDocument` 的注里写明要挡住的那一形。现在逐字照它:同一事务、对登记行 `FOR UPDATE`、
459
+ * 拿到锁之后再删。
460
+ *
461
+ * 「未登记 ⇒ 响亮抛」与「登记了但没这条文档 ⇒ 回 false」两件事仍然分家(调用方据此分支),所以
462
+ * 不能收成一条 `DELETE … WHERE EXISTS(…)`:那样 `affected === 0` 会把两种结局压成同一个读数。
463
+ */
449
464
  async deleteDocument(storeId, scopeKey, path) {
450
- await this.assertOwnedBy(storeId, scopeKey);
451
- const res = await this.db.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
452
- return res.affected > 0;
453
- }
454
- /** 属主围栏的共用断言(轮3 F1)。**不**覆盖同一个 org 内的"删了又建"代际重用——那不是跨租户面,
455
- * 真要挡住需要一枚不可变的 generation token;这里如实说明边界,不假装它被覆盖了。 */
456
- async assertOwnedBy(storeId, scopeKey) {
457
465
  assertStoreId(storeId);
458
466
  assertScopeKey(scopeKey);
459
- const res = await this.db.query(this.q(`SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = ? AND scope_key = ?`, `SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = $1 AND scope_key = $2`), [storeId, scopeKey]);
460
- if (res.rows.length === 0) {
467
+ let unregistered = false;
468
+ let deleted = false;
469
+ const conn = await this.db.connect();
470
+ try {
471
+ await conn.begin(); // 为什么不是 beginPessimistic:见本类头注的"事务与锁"段(与 putDocument 同判)
472
+ const registered = await conn.query(this.q(`SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = ? AND scope_key = ? FOR UPDATE`, `SELECT store_id FROM ${SHARED_MEMORY_STORE_TABLE} WHERE store_id = $1 AND scope_key = $2 FOR UPDATE`), [storeId, scopeKey]);
473
+ if (registered.rows.length === 0) {
474
+ unregistered = true;
475
+ await conn.rollback();
476
+ }
477
+ else {
478
+ const res = await conn.query(this.q(`DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = ? AND path = ?`, `DELETE FROM ${SHARED_MEMORY_ENTRY_TABLE} WHERE store_id = $1 AND path = $2`), [storeId, path]);
479
+ deleted = res.affected > 0;
480
+ await conn.commit();
481
+ }
482
+ }
483
+ catch (err) {
484
+ await rollbackPreservingError(conn, err);
485
+ throw err;
486
+ }
487
+ finally {
488
+ conn.release();
489
+ }
490
+ if (unregistered) {
491
+ // 文案逐字同 assertOwnedBy(调用方/测试按它对表);围栏的**执行面**改了,拒绝的**说法**没改。
461
492
  throw new Error(`shared-memory store ${JSON.stringify(storeId)} is not registered under ${JSON.stringify(scopeKey)} — either it does not exist, or the id has since been recycled by another org`);
462
493
  }
494
+ return deleted;
463
495
  }
496
+ // 属主围栏的共用断言 `assertOwnedBy()` 曾住在这里(轮3 F1)。A-010.14 把它**唯一**的消费者
497
+ // (`deleteDocument`)改成「同事务 + FOR UPDATE」之后它就没有调用点了 —— 留着一个自带围栏语义却
498
+ // 无人调用的私有方法,下一个人会以为「围栏在这儿,照着调就行」,而它恰恰是被判定为不够强的那一版。
499
+ // 边界的如实说明随之搬进 `deleteDocument` / `putDocument`:两者**都不**覆盖同一个 org 内的
500
+ // "删了又建"代际重用 —— 那不是跨租户面,真要挡住需要一枚不可变的 generation token。
464
501
  /**
465
502
  * De-register a store AND its documents, ATOMICALLY.
466
503
  *
@@ -91,6 +91,25 @@ export interface SqlTxConn extends SqlExec {
91
91
  begin(): Promise<void>;
92
92
  /** TiDB: `BEGIN PESSIMISTIC` (current-read txn). PG: plain `BEGIN`. */
93
93
  beginPessimistic(): Promise<void>;
94
+ /**
95
+ * Open a transaction whose reads are pinned to ONE snapshot (A-010.11).
96
+ *
97
+ * 🔴 Why this is a named verb and not "just `begin()` — MySQL defaults to REPEATABLE READ anyway":
98
+ * a *server default* is not a structural guarantee. `transaction_isolation` is a settable
99
+ * server/session variable; a deployment (or a proxy, or a managed-MySQL vendor default) that runs
100
+ * READ COMMITTED silently turns "one transaction one snapshot" into "each statement its own
101
+ * snapshot" — and the caller that relied on it (`shared-memory-store-sql.ts` readBinding) goes on
102
+ * reading a torn scope/store pair with no error anywhere. The PG arm was already explicit; the
103
+ * MySQL arm was leaning on the default. Both arms now PIN it.
104
+ *
105
+ * The two arms diverge in WHERE the statement goes, and that is not cosmetic:
106
+ * · MySQL/TiDB — `SET TRANSACTION ISOLATION LEVEL …` with no scope keyword applies to the **next**
107
+ * transaction, and issuing it *inside* an open transaction is an ERROR
108
+ * (`ER_CANT_CHANGE_TX_CHARACTERISTICS`). So it must precede `beginTransaction()`.
109
+ * · PG — the same statement must be issued *inside* the transaction, before its first query.
110
+ * Writing one "portable" form would be wrong on one of the two engines; hence one verb, two texts.
111
+ */
112
+ beginRepeatableRead(): Promise<void>;
94
113
  commit(): Promise<void>;
95
114
  rollback(): Promise<void>;
96
115
  release(): void;
@@ -24,6 +24,12 @@ export function mysqlDriver(pool) {
24
24
  beginPessimistic: async () => {
25
25
  await c.query("BEGIN PESSIMISTIC");
26
26
  },
27
+ // Scope-less `SET TRANSACTION …` = next-transaction only, so it goes BEFORE the BEGIN and
28
+ // leaves the pooled connection's session default untouched for whoever gets it next.
29
+ beginRepeatableRead: async () => {
30
+ await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
31
+ await c.beginTransaction();
32
+ },
27
33
  commit: () => c.commit(),
28
34
  rollback: () => c.rollback(),
29
35
  release: () => c.release(),
@@ -50,6 +56,12 @@ export function pgDriver(pool) {
50
56
  beginPessimistic: async () => {
51
57
  await c.query("BEGIN");
52
58
  },
59
+ // PG takes it INSIDE the transaction (and only before its first query) — the mirror image of
60
+ // the MySQL arm above. Same verb, deliberately different placement.
61
+ beginRepeatableRead: async () => {
62
+ await c.query("BEGIN");
63
+ await c.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
64
+ },
53
65
  commit: async () => {
54
66
  await c.query("COMMIT");
55
67
  },
@@ -222,7 +222,30 @@ class LocalBackend {
222
222
  // adoptLocalDataRoot 到完成,或修 adoption.json」的出路),我们没有更好的话可说,包一层只会更差。
223
223
  // 包装只留给真正的 boot-lock 类失败(那句文案的适用条件)。
224
224
  try {
225
- this.fileBackend = new FileStorageBackend({ root, ...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}) }); // REWIND_SNAPSHOT_MAX_MB
225
+ this.fileBackend = new FileStorageBackend({
226
+ root,
227
+ ...(config.rewindSnapshotMaxMb !== undefined ? { snapshotBounds: { maxBytes: Math.round(config.rewindSnapshotMaxMb * 1024 * 1024) } } : {}), // REWIND_SNAPSHOT_MAX_MB
228
+ // 🔴 腐读披露座(交接件④,与 `run-local.ts` 的同座同形、同 warn 名 `file_store_corrupt_read`)。
229
+ // core 对**读不出来的**持久文件是 documented fail-open —— 当作「缺席」继续跑(抛会让整条任务死,
230
+ // 也会自锁修复写)。这条 fail-open 是 core 的裁定,本仓不改它;但它**不许无声**(#157 安全轴纪律)。
231
+ // 此前 HTTP 服务的 local 车道**根本没接这个座**:同一份坏字节,run-local 上打一行、服务上一个字
232
+ // 都没有 —— 而服务形恰恰是没人盯着终端的那一个。
233
+ //
234
+ // ⚠️ 文案按脸分列后果、**不下统一结论**(run-local 那次 codex R2-medium 的教训一并吸收):一个座
235
+ // 覆盖 core 转发的三张脸,后果各不相同 —— 把「少了会话规则这一层」写成「整个任务无约束」会误导
236
+ // 事故定级,而且对另外两张脸根本不成立。共同事实只有一句:一次持久读被当成了缺席。
237
+ // ENOENT 不触发(那是真缺席,不是腐读)。
238
+ onCorruptRead: (info) => this.onWarn?.("file_store_corrupt_read", {
239
+ path: info.path,
240
+ reason: info.reason,
241
+ ...(info.sessionId !== undefined ? { sessionId: info.sessionId } : {}),
242
+ ...(info.principal !== undefined ? { principal: info.principal } : {}),
243
+ note: "a durable read was treated as ABSENT because the bytes were unreadable (never a plain ENOENT). " +
244
+ "Consequence depends on which store read it: session-policy = that run lost its SUBTRACTIVE session-rule " +
245
+ "layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
246
+ "rows; file-snapshot = a snapshot or scope read as missing. Inspect the named path before deciding.",
247
+ }),
248
+ });
226
249
  }
227
250
  catch (e) {
228
251
  if (e instanceof AdoptionError)
@@ -1,4 +1,4 @@
1
- import { type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type RuleScope } from "@sema-agent/core";
1
+ import { type RuleOwner, type ImportPreview, type ImportResult, type ImportedSettingsLayer, type PersistedAllowRule, type RemoveResult, type RuleScope } from "@sema-agent/core";
2
2
  import type { PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
3
3
  import { type RuleImportTicket, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
4
4
  /**
@@ -47,6 +47,16 @@ export interface PermissionRuleStoreBundle extends RuleConsentStores {
47
47
  /** #203 §3:库里已有几只规则桶(一 owner 一桶)。boot 期休眠行审计的**唯一**依赖;
48
48
  * 为什么数桶不数规则、以及读失败必须响亮,见 `boot/permission-rules-audit.ts` 与两个实现处的注。 */
49
49
  countBuckets(): Promise<number>;
50
+ /**
51
+ * A-010.17 保留期腿:清掉**可证已死**的导入票与孤儿 pending 审批记录,返回删掉的总行数。
52
+ * 谓词与「为什么不需要旋钮」逐字见 `plugins/permission-rule-store-sql.ts` 的同名成员。
53
+ *
54
+ * 🔴 **可选**,而缺席是**如实登记**过的:File 车道(`permission-rule-store-file.ts`)的审批/票两面是
55
+ * **追加日志**,删一行等于重写整个日志 —— 那是一次压实(compaction)设计,与 SQL 的一条 DELETE 不是
56
+ * 同一件事,不在本条射程内。缺席的实际后果有界:File 车道 = 单用户本地形,两只日志的增长由**一个人**
57
+ * 的导入次数封顶,不是多租户共享库那种无界增长。要做压实时在这里补实现,消费点(reaper)零改动。
58
+ */
59
+ reapExpired?(nowMs: number): Promise<number>;
50
60
  }
51
61
  /** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
52
62
  * 窄到一张泄漏的票不会常驻。运维旋钮暂不开(没有部署形要求它可调;要开时走 config.ts 的既有姿势)。 */
@@ -147,6 +157,20 @@ export interface PersistedRuleListing {
147
157
  rows: PersistedRuleWireRow[];
148
158
  rev: number;
149
159
  }
160
+ /**
161
+ * 撤销面两口寻址的**桶**(#203 收官件,core 5.24.0 提货批 [3427])。
162
+ *
163
+ * 裸串 = principal 简写(既有调用方逐字不变);结构形 {@link RuleOwner} 多出 `{kind:"local-owner"}`
164
+ * —— 身份缺席的单机形自己那只桶。这不是 server 发明的第二种寻址:core 的
165
+ * `PermissionRuleStoreProvider.forLocalOwner()`(design/182 §4.5)一直就有这只桶,缺的是**撤销入口
166
+ * 叫不出它的名字** —— 我方 [3399]① 请托、core 5.24.0 把 `removePersistedRule` 的 `principal` 入参扩成
167
+ * `string | RuleOwner` 兑现(d.ts 那段注逐字写着「downstream request, 2026-08-10」)。
168
+ *
169
+ * 🔴 **绝不用哨兵串**表示身份缺席(core 的 `RuleOwner` 头注就是这条纪律的出处):`forPrincipal("")` /
170
+ * `forPrincipal("local-owner")` 都会让一个**编出来的名字**流进身份面,而身份面上编的名字与真名在下游
171
+ * 不可分。判别式联合是唯一诚实的形。
172
+ */
173
+ export type RuleBucketRef = string | RuleOwner;
150
174
  export interface RuleConsentLane {
151
175
  /** 卡道兑付口:一次 ask 决议携规则确认 ⇒ prepare→confirm→redeem 一气呵成。 */
152
176
  persistCardRule(input: {
@@ -165,13 +189,15 @@ export interface RuleConsentLane {
165
189
  /** 导入道:原子消费票 → confirm → redeemRuleBatch。 */
166
190
  redeemImport(principal: string, ticket: string): Promise<RuleImportRedeemed>;
167
191
  /**
168
- * 撤销面读半场:列出一位 principal 名下**活着**的规则(墓碑已折算)。
192
+ * 撤销面读半场:列出一只桶名下**活着**的规则(墓碑已折算)。
169
193
  *
170
194
  * 排序 = (scope, rule) 字典序,**确定性**:分页游标是 keyset 形,而 keyset 的全部前提就是「同一份
171
195
  * 数据每次以同一个顺序出现」。core 的 `list()` 不承诺顺序(SQL 形按写入顺序、File 形按文件内顺序),
172
196
  * 所以序在这里定,不在店里。
197
+ *
198
+ * 入参裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形另开 local-owner 桶。
173
199
  */
174
- listRules(principal: string): Promise<PersistedRuleListing>;
200
+ listRules(bucket: RuleBucketRef): Promise<PersistedRuleListing>;
175
201
  /**
176
202
  * 撤销面写半场。**恒经 core `removePersistedRule`**(design/203 §2 裁定)——它产墓碑,而墓碑是
177
203
  * `screenRuleSyncState` 的筛子保证「别的副本不把这条规则回灌回来」的唯一凭据。直接删 SQL 行/文件行
@@ -180,9 +206,12 @@ export interface RuleConsentLane {
180
206
  * 返回值逐字是 core 的 `RemoveResult` 三态(removed / no-op / failed),**不塌**:
181
207
  * `removed.stillLive:true` 意思是「墓碑落了,但本次调用期间又落了一次新的批准,规则按 add-wins
182
208
  * 仍然活着」——那是一个**有名字的真结果**,不是异常,更不是「撤销完成」。
209
+ *
210
+ * `principal` 裸串 = principal 简写(既有调用方逐字不变);{@link RuleBucketRef} 的结构形让
211
+ * local-owner 桶**真可撤**(core 5.24.0 起 `removePersistedRule` 的入参 union 扩,#203 收官件)。
183
212
  */
184
213
  removeRule(input: {
185
- principal: string;
214
+ principal: RuleBucketRef;
186
215
  rule: string;
187
216
  scope: RuleScope;
188
217
  }): Promise<RemoveResult>;
@@ -87,6 +87,42 @@ export function parseRuleScope(text) {
87
87
  // (空前缀包含一切)——一条项目内规则就此悄悄升成全局规则(cc-import 那侧同判据,同一条 codex 发现)。
88
88
  return root === "" ? undefined : { kind: "project", root };
89
89
  }
90
+ /**
91
+ * 桶引用 → 具体的规则店。
92
+ *
93
+ * 🔴 **穷举判别,未知形一律抛**(CLAUDE.md 词表闭集律 + codex 对抗复审 R3-[medium],验真后改)。
94
+ * 第一稿写的是 `kind === "principal" ? 甲 : 乙` 的二分:TS 那侧是穷举的,**运行期不是** —— 本车道是
95
+ * 一个**导出**的束,调用方可以是任何一层(HTTP 路由今天只递裸串,但这条口正是为了将来递结构形才开的)。
96
+ * 于是 `{kind:"principle"}`(拼错)或 `{}` 会静默落进 local-owner 那支 = 一次**跨桶读**;更坏的是
97
+ * 同一个坏值递给 core 的 `removePersistedRule` 时它按 `kind === "local-owner"` 反着判,落到
98
+ * `forPrincipal(undefined)`(空店)—— 列举和撤销**指向两只不同的桶**,而两边都不报错。
99
+ * 身份面上「不认识的形」只有一个正确答案:响亮拒。
100
+ *
101
+ * 🔴 local-owner 桶而 provider 没有 `forLocalOwner` ⇒ 同样**抛**,不静默回落到别的桶。回落到
102
+ * `forPrincipal(…)` 会把「读/删身份缺席桶」变成「读/删某个 principal 的桶」——那是一次跨桶的读或写,
103
+ * 是本车道最不该出的错。报文与 core 的 `removePersistedRule` 同支同措辞(那侧回 `failed` 带
104
+ * `forLocalOwner` 字样),两侧一起读日志时对得上。
105
+ */
106
+ function storeForBucket(provider, ref) {
107
+ // 裸串 = principal 简写(core `removePersistedRule` 的同款归一,两侧对同一个输入判同一只桶)。
108
+ if (typeof ref === "string")
109
+ return provider.forPrincipal(ref);
110
+ // `ref.kind` 在类型上是闭集;`switch` 的 default 是**运行期**的那道门(不是死码)。
111
+ switch (ref.kind) {
112
+ case "principal":
113
+ return provider.forPrincipal(ref.principal);
114
+ case "local-owner":
115
+ if (provider.forLocalOwner === undefined) {
116
+ throw new Error("this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be listed or removed through it");
117
+ }
118
+ return provider.forLocalOwner();
119
+ default: {
120
+ // core 给 `RuleOwner` 加员时这里是**编译**红(`never` 赋值失败),不是静默继承某一支。
121
+ const unknown = ref;
122
+ throw new Error(`unrecognised permission-rule bucket reference (${JSON.stringify(unknown)}) — refusing to guess which bucket it names`);
123
+ }
124
+ }
125
+ }
90
126
  export function createRuleConsentLane(stores, opts) {
91
127
  const deps = { provider: stores.provider, approvals: stores.approvals };
92
128
  const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
@@ -222,10 +258,10 @@ export function createRuleConsentLane(stores, opts) {
222
258
  return await indeterminate(err instanceof Error ? err.message : String(err));
223
259
  }
224
260
  },
225
- async listRules(principal) {
261
+ async listRules(bucket) {
226
262
  // `list()` 已经把墓碑折算掉(`applyTombstones`)⇒ 这里拿到的就是**活着**的规则,治理面看到的
227
263
  // 与引擎放行时看到的是同一份。空桶 = 空表 + rev 0,不是错误(「这个人没有规则」是合法状态)。
228
- const stored = await stores.provider.forPrincipal(principal).list();
264
+ const stored = await storeForBucket(stores.provider, bucket).list();
229
265
  const rows = stored.rules.map((r) => ({ rule: r.rule, scope: serializeRuleScope(r.scope), tool: r.tool, match: r.match, command: r.command, adds: r.adds }));
230
266
  rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0));
231
267
  return { rows, rev: stored.rev };
@@ -233,6 +269,11 @@ export function createRuleConsentLane(stores, opts) {
233
269
  async removeRule(input) {
234
270
  // 🔴 一行也不自己写:OCC 重试、墓碑铸造、`stillLive` 的读回全在 core 那条原语里,而它的语义
235
271
  // (add-wins、observed-remove、失败必须由读回确认「什么都没写」)是 core 的单一属主。
272
+ // 桶引用**原样**递下去(裸串走 core 的 principal 简写臂,结构形走 owner 臂)——我方不在这里
273
+ // 归一成 owner 对象:core 5.24.0 的 `removePersistedRule` 自己就做那次归一(`typeof === "string"`),
274
+ // 多归一一次等于把上游的简写语义抄一份到下游,抄件会漂。local-owner 而 provider 无 `forLocalOwner`
275
+ // 的那支也归 core:它回的是 `failed` + 报文含 `forLocalOwner`(C13 判据),比抛错更适合这条
276
+ // 三态返回的口。
236
277
  return await removePersistedRule({ rule: input.rule, scope: input.scope, principal: input.principal, provider: stores.provider });
237
278
  },
238
279
  };
package/dist/run-local.js CHANGED
@@ -582,6 +582,11 @@ export async function runLocal(argv, deps = {}) {
582
582
  */
583
583
  const sharedRunnerDeps = createSharedRunnerDeps({
584
584
  config,
585
+ // 交接件⑤:commit 尾注署名座进**共享基座** ⇒ 主 runner 与 subRunner 同源。
586
+ // [931]① clay 拍:run-local = Sema 品牌本地形态,commit 尾注接 Sema 署名(core 1.300 缺省已翻转不署)。
587
+ // 修前它只写在下面主 runner 的差异键里 ⇒ **委派出去的子代提交不带署名**,而它提交进的是同一个仓、
588
+ // 代表的是同一个部署。那不是一次有理由的分歧(差异键注释块逐条列了理由,唯独没提它),是漏配。
589
+ hands: { commitCoAuthor: "Sema <noreply@vivi-ai.com>" },
585
590
  brain,
586
591
  pricing,
587
592
  tracer: createTracer(metrics),
@@ -601,8 +606,7 @@ export async function runLocal(argv, deps = {}) {
601
606
  const runnerDeps = {
602
607
  ...sharedRunnerDeps,
603
608
  // ── 以下为主 runner 的差异键(不在共享基座;逐个有因)──────────────────────────────────────
604
- // [931]① clay 拍:run-local=Sema 品牌本地形态,commit 尾注接 Sema 署名(core 1.300 缺省已翻转不署)
605
- hands: { commitCoAuthor: "Sema <noreply@vivi-ai.com>" },
609
+ // (`hands` 已随共享基座展开 —— 交接件⑤;这里手写同名键会 override 展开、把 subRunner 重新甩开。)
606
610
  sessionStore,
607
611
  ...(memoryEngine ? { memoryBackend: memoryEngine.backend, memoryEngineDir: memoryEngine.root } : {}),
608
612
  checkpointStore: fileBackend.checkpointStore,
@@ -93,6 +93,39 @@ export declare function compileCommandPolicy(rules: CommandRule[] | undefined):
93
93
  * - `auto` / `undefined` → `{}` (no extra tightening; still subject to the deployment baseline + commandPolicy).
94
94
  */
95
95
  export declare function autonomyOverrides(autonomy: Autonomy | undefined): Partial<TaskSpec>;
96
+ /**
97
+ * #220:**部署级**判据 —— 本部署的治理层自己是否把 shellGate 抬到 `"always"`。
98
+ *
99
+ * 用途 = 持久门(park 行)的出身归因。活卡腿判「这只 ask 是治理层产的」靠 ALS 标记表(见本文件
100
+ * `createGovernanceAskMarkingPolicy` / `createGovernanceShellGateMarkPolicy`),而 park 腿天然跨副本、
101
+ * 跨重启,进程内的表在那里结构上够不着;行上唯一的取证格是 core 写的
102
+ * `gate.riskDescriptor.shellGateDoctrine`。
103
+ *
104
+ * 🔴 **为什么单看行上那一格不够**:有效档 `"always"` **不止治理层一个产地** —— `resolve-spec` 的 SUP 路由
105
+ * 姿态(`supPostureOverrides`)在 governance **之后**叠,同样产 `"always"`(本文件 `manualModeShellGate`
106
+ * 合成段的注释里已如实记着这条时序)。只看行 ⇒ 一条 SUP 路由出来的门会被谎报成「运维治理层强制」,
107
+ * 而那恰是这个信号要回答的那个问题。所以归因取**合取**:行上是 `always` **∧** 本部署的治理层本来就
108
+ * 要求 `always`(后者成立时,治理层就是一个真成因,SUP 是否也抬过不改变这句话的真假)。
109
+ *
110
+ * 判据逐字对着两个产地(与 `applyRuntimeGovernance` 里 `overrides.shellGate` 的取值同源):
111
+ * `autonomyOverrides(autonomy).shellGate`(`AUTONOMY=ask`)与 `MANUAL_MODE_SHELL_GATE` 旋钮,取「有一个
112
+ * 是 always」。`commandPolicy` 不入判据:它产的是 `toolPolicy` 的 ask,不抬 shellGate 档,与本格无关。
113
+ *
114
+ * ⚠️ **已知残留(codex 对抗复审 2026-08-11 逮到,如实登记而不是掩盖)**:本判据读的是**当下**的
115
+ * config,而 `config.autonomy` 是**热改**的(`config-center/apply-effective.ts` 就地写 `config.autonomy`)。
116
+ * 于是失真是**双向**的,不是我原先写的「只往缺席方向」:
117
+ * · 缺席向(常见):mint 之后运维把 `AUTONOMY=ask` 撤了 ⇒ 老 park 行归不出治理出身,少标一个;
118
+ * · **在场向**(窄):一条**纯 SUP 路由**产的门(`ROUTER_ENABLED` 开 ∧ 路由判 supervisor ∧ 当时治理层
119
+ * 并不要求 always),之后运维把 autonomy 热改成 `ask`,那条老行会被标成治理出身 —— 五个前提要**同时**
120
+ * 成立,且该行仍未决。
121
+ * 根治要**在 mint 那一刻把出身写进行里**(core 的 `CheckpointGate` 属主面,不是本仓能单方面做的);
122
+ * 在那之前这一位的定位是**分诊提示**,不是裁决输入 —— 它不参与任何门/CAS/resume 判定,消费端也只拿它
123
+ * 渲染徽标。登记在此,不加特征化测试(把已知残留钉成契约是另一种病)。
124
+ */
125
+ export declare function governanceMandatesShellGateAlways(governance: {
126
+ autonomy?: Autonomy;
127
+ manualModeShellGate?: "always" | "classify";
128
+ }): boolean;
96
129
  /**
97
130
  * Apply the operator's runtime governance (autonomy + commandPolicy + manualModeShellGate) onto a base
98
131
  * `TaskSpec`, TIGHTEN-ONLY, in a SINGLE {@link tightenTaskSpec} call: commandPolicy compiles to a `toolPolicy`
@@ -219,6 +219,38 @@ export function autonomyOverrides(autonomy) {
219
219
  return {};
220
220
  }
221
221
  }
222
+ /**
223
+ * #220:**部署级**判据 —— 本部署的治理层自己是否把 shellGate 抬到 `"always"`。
224
+ *
225
+ * 用途 = 持久门(park 行)的出身归因。活卡腿判「这只 ask 是治理层产的」靠 ALS 标记表(见本文件
226
+ * `createGovernanceAskMarkingPolicy` / `createGovernanceShellGateMarkPolicy`),而 park 腿天然跨副本、
227
+ * 跨重启,进程内的表在那里结构上够不着;行上唯一的取证格是 core 写的
228
+ * `gate.riskDescriptor.shellGateDoctrine`。
229
+ *
230
+ * 🔴 **为什么单看行上那一格不够**:有效档 `"always"` **不止治理层一个产地** —— `resolve-spec` 的 SUP 路由
231
+ * 姿态(`supPostureOverrides`)在 governance **之后**叠,同样产 `"always"`(本文件 `manualModeShellGate`
232
+ * 合成段的注释里已如实记着这条时序)。只看行 ⇒ 一条 SUP 路由出来的门会被谎报成「运维治理层强制」,
233
+ * 而那恰是这个信号要回答的那个问题。所以归因取**合取**:行上是 `always` **∧** 本部署的治理层本来就
234
+ * 要求 `always`(后者成立时,治理层就是一个真成因,SUP 是否也抬过不改变这句话的真假)。
235
+ *
236
+ * 判据逐字对着两个产地(与 `applyRuntimeGovernance` 里 `overrides.shellGate` 的取值同源):
237
+ * `autonomyOverrides(autonomy).shellGate`(`AUTONOMY=ask`)与 `MANUAL_MODE_SHELL_GATE` 旋钮,取「有一个
238
+ * 是 always」。`commandPolicy` 不入判据:它产的是 `toolPolicy` 的 ask,不抬 shellGate 档,与本格无关。
239
+ *
240
+ * ⚠️ **已知残留(codex 对抗复审 2026-08-11 逮到,如实登记而不是掩盖)**:本判据读的是**当下**的
241
+ * config,而 `config.autonomy` 是**热改**的(`config-center/apply-effective.ts` 就地写 `config.autonomy`)。
242
+ * 于是失真是**双向**的,不是我原先写的「只往缺席方向」:
243
+ * · 缺席向(常见):mint 之后运维把 `AUTONOMY=ask` 撤了 ⇒ 老 park 行归不出治理出身,少标一个;
244
+ * · **在场向**(窄):一条**纯 SUP 路由**产的门(`ROUTER_ENABLED` 开 ∧ 路由判 supervisor ∧ 当时治理层
245
+ * 并不要求 always),之后运维把 autonomy 热改成 `ask`,那条老行会被标成治理出身 —— 五个前提要**同时**
246
+ * 成立,且该行仍未决。
247
+ * 根治要**在 mint 那一刻把出身写进行里**(core 的 `CheckpointGate` 属主面,不是本仓能单方面做的);
248
+ * 在那之前这一位的定位是**分诊提示**,不是裁决输入 —— 它不参与任何门/CAS/resume 判定,消费端也只拿它
249
+ * 渲染徽标。登记在此,不加特征化测试(把已知残留钉成契约是另一种病)。
250
+ */
251
+ export function governanceMandatesShellGateAlways(governance) {
252
+ return autonomyOverrides(governance.autonomy).shellGate === "always" || governance.manualModeShellGate === "always";
253
+ }
222
254
  /**
223
255
  * [2942]/[2943] `governanceForced` 的**写侧** —— 把内层策略(治理层自己合成的那些)产的 `ask` 记进标记表。
224
256
  *
@@ -61,6 +61,38 @@ export interface ToolApprovalFrame {
61
61
  * `governance-ask-marks.ts` 顶注。
62
62
  */
63
63
  governanceForced?: true;
64
+ /**
65
+ * #144(core 5.25.0,[3438] 接力契约 / [3443] 主件;**ADDITIVE**,`"tool_approval"` only)——
66
+ * 这只 ask **命中了**调用方的一条持久 allow 规则,而那条规则**没能清掉它**。值 = 被越级的那条规则
67
+ * **原文**(core `AskRequest.persistedRuleShadowed`,四个 mint 点全带;core 侧已过 `inlineUntrusted`)。
68
+ *
69
+ * 🔴 **它存在的理由是一次真实的用户面回归**:core 5.25.0 收窄了消音边界(裁定逐字:「Allow rules
70
+ * silence the CLASSIFIER's questions, never a MANDATED one」)—— `shellGate:"always"` 的部署、工具自带
71
+ * egress/irreversible mark、以及既有的 governance 三类门下,用户此前被规则消掉的 ask **重新出现**。
72
+ * 没有这个键,人看到的是「我明明点过『不再询问』,它怎么又问」,唯一合理的结论是「我的规则坏了/没存上」。
73
+ * 有了它,壳能渲「你的规则仍在,只是这次调用被(运维令 / 工具本性)要求逐次确认」。
74
+ *
75
+ * 🔴 **缺席 ≠「你没有规则」**:本键只在「有规则命中 ∧ 规则清不掉这只 ask」时在场。绝大多数 ask
76
+ * 压根没有规则命中(缺席),而**命中且清掉了**的那些根本不会变成 ask(它们被消音了,没有卡)。
77
+ * 消费端禁把缺席读成「你在这条命令上没有规则」。
78
+ *
79
+ * 与 {@link ToolApprovalFrame.governanceForced} **刻意分列、不合并**:那个键回答「门是谁下的」
80
+ * (运维治理层),本键回答「你那条规则怎么了」。governance 只是不可消音的三个来源之一,另两个
81
+ * (doctrine `always` / 工具自带 mark)不打 governance 标 —— 合并会让后两类的 ask 要么谎报治理出身、
82
+ * 要么丢掉规则解释。出身的完整推法见 [3438]:`gate.safetyAxis` + `riskDescriptor.shellGateDoctrine` +
83
+ * `realApproval.origin` 组合读,server 不新铸出身键。
84
+ *
85
+ * 值是**用户内容族**(规则原文是人写的文本)⇒ 与 `message`/`sourceAgentName` 同待遇:`redactSecrets`
86
+ * 后上帧,UNTRUSTED-for-display。空串**不铸键**(core 只在真有命中时带它,空串是坏值不是「空规则」)。
87
+ *
88
+ * 耐久路(park 行)的对偶是 `gate.riskDescriptor.shadowedRule` —— 我方对 `riskDescriptor` 是**整体透传**
89
+ * (SQL 双生落整只 JSON 列、`listPending` 整只回读、两条 durable 读面整行上 wire),所以那一路**键集**零施工;
90
+ * 唯一的施工是**内容**:那两条读面(`GET /v1/approvals` + `/v1/approvals/stream`)在读边界对这一格补
91
+ * `redactSecrets`(core 只中和不脱敏,而运维队列是跨租户可见的那一条 —— 见
92
+ * `http/routes/approvals-assistant.ts` 的 `redactPendingDisclosures` 顶注)。
93
+ * 两路的钉见 `test/approval-shadowed-rule-wire.test.ts` 与 db-integration 的「#209 件4」格。
94
+ */
95
+ persistedRuleShadowed?: string;
64
96
  /**
65
97
  * #154 车二(core 5.18.0 design/179):`"tool_approval"` only —— 引擎为这次 ask 铸的**规则候选**
66
98
  * (`AskRequest.ruleSuggestions`,逐字透传:闭词表 `match` + 引擎铸的文本,server 不重铸不重排)。