@sema-agent/server 7.30.0 → 7.31.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 (104) hide show
  1. package/dist/boot/session-faces.js +31 -1
  2. package/dist/http/route-ctx.d.ts +6 -1
  3. package/dist/http/routes/approvals-assistant.js +14 -2
  4. package/dist/http/routes/sessions-list.js +8 -4
  5. package/dist/http/routes/sessions.js +11 -5
  6. package/dist/http/server.js +44 -3
  7. package/dist/observability/fail-open.d.ts +4 -0
  8. package/dist/observability/fail-open.js +4 -0
  9. package/dist/plugins/caching-session-store.d.ts +1 -0
  10. package/dist/plugins/file-run-store.d.ts +11 -3
  11. package/dist/plugins/file-run-store.js +28 -7
  12. package/dist/plugins/local-session-store.d.ts +2 -0
  13. package/dist/plugins/local-session-store.js +2 -2
  14. package/dist/plugins/memory-run-store.d.ts +13 -2
  15. package/dist/plugins/memory-run-store.js +17 -5
  16. package/dist/plugins/permission-rule-store-file.d.ts +3 -3
  17. package/dist/plugins/permission-rule-store-file.js +8 -7
  18. package/dist/plugins/permission-rule-store-sql.d.ts +24 -2
  19. package/dist/plugins/permission-rule-store-sql.js +29 -7
  20. package/dist/plugins/pg-session-storage.d.ts +1 -0
  21. package/dist/plugins/pg-session-storage.js +2 -1
  22. package/dist/plugins/retention-store-sql.d.ts +9 -1
  23. package/dist/plugins/retention-store-sql.js +13 -8
  24. package/dist/plugins/run-store-sql.d.ts +31 -7
  25. package/dist/plugins/run-store-sql.js +42 -13
  26. package/dist/plugins/store-backend.d.ts +9 -2
  27. package/dist/plugins/store-backend.js +30 -7
  28. package/dist/plugins/tidb-session-store.d.ts +1 -0
  29. package/dist/plugins/tidb-session-store.js +3 -1
  30. package/dist/rules-consent.d.ts +7 -4
  31. package/dist/rules-consent.js +77 -2
  32. package/dist/security.d.ts +52 -1
  33. package/dist/security.js +51 -0
  34. package/dist/wall-clock-jump-guard.d.ts +91 -0
  35. package/dist/wall-clock-jump-guard.js +118 -0
  36. package/package.json +1 -1
  37. package/dist/boot/lexical-path-env.d.ts +0 -10
  38. package/dist/boot/lexical-path-env.js +0 -88
  39. package/dist/capabilities/oa-tools.d.ts +0 -15
  40. package/dist/capabilities/oa-tools.js +0 -54
  41. package/dist/finance/cost-taxonomy.d.ts +0 -34
  42. package/dist/finance/cost-taxonomy.js +0 -26
  43. package/dist/plugins/approval-store-sql.d.ts +0 -116
  44. package/dist/plugins/approval-store-sql.js +0 -151
  45. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  46. package/dist/plugins/file-workflow-journal-store.js +0 -12
  47. package/dist/plugins/pg-approval-store.d.ts +0 -9
  48. package/dist/plugins/pg-approval-store.js +0 -9
  49. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  50. package/dist/plugins/pg-breaker-state.js +0 -8
  51. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  52. package/dist/plugins/pg-checkpoint-store.js +0 -10
  53. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  54. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  55. package/dist/plugins/pg-image-bake.d.ts +0 -12
  56. package/dist/plugins/pg-image-bake.js +0 -11
  57. package/dist/plugins/pg-image-index.d.ts +0 -12
  58. package/dist/plugins/pg-image-index.js +0 -11
  59. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  60. package/dist/plugins/pg-outcome-ledger.js +0 -11
  61. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  62. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  63. package/dist/plugins/pg-run-store.d.ts +0 -9
  64. package/dist/plugins/pg-run-store.js +0 -9
  65. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  66. package/dist/plugins/pg-session-policy-store.js +0 -7
  67. package/dist/plugins/pg-session-store.d.ts +0 -12
  68. package/dist/plugins/pg-session-store.js +0 -12
  69. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  70. package/dist/plugins/pg-tool-result-store.js +0 -9
  71. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  72. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  73. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  74. package/dist/plugins/pg-workflow-run-store.js +0 -9
  75. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  76. package/dist/plugins/tidb-approval-store.js +0 -8
  77. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  78. package/dist/plugins/tidb-breaker-state.js +0 -7
  79. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  80. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  81. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  82. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  83. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  84. package/dist/plugins/tidb-image-bake.js +0 -11
  85. package/dist/plugins/tidb-image-index.d.ts +0 -12
  86. package/dist/plugins/tidb-image-index.js +0 -11
  87. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  88. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  89. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  90. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  91. package/dist/plugins/tidb-run-store.d.ts +0 -10
  92. package/dist/plugins/tidb-run-store.js +0 -9
  93. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  94. package/dist/plugins/tidb-session-policy-store.js +0 -7
  95. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  96. package/dist/plugins/tidb-tool-result-store.js +0 -10
  97. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  98. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  99. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  100. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  101. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  102. package/dist/plugins/workflow-journal-limits.js +0 -12
  103. package/dist/sema-registry.d.ts +0 -41
  104. package/dist/sema-registry.js +0 -40
@@ -679,6 +679,18 @@ export class SqlRuleApprovalRecordStore {
679
679
  return affected === 1;
680
680
  }
681
681
  }
682
+ /**
683
+ * [4232]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
684
+ *
685
+ * 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
686
+ * 若 GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
687
+ * 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [4232]③ 的真实构造)。
688
+ *
689
+ * 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
690
+ * 存单调基准,而单调基准跨进程无意义(core [4235] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
691
+ * ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
692
+ */
693
+ export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
682
694
  /** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
683
695
  export function buildRulePayloadHash(candidates) {
684
696
  return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
@@ -726,8 +738,14 @@ export class SqlRuleImportTicketStore {
726
738
  * expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
727
739
  * 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
728
740
  */
729
- async consume(ticketId, principal) {
730
- const nowMs = this.now();
741
+ async consume(ticketId, principal, opts) {
742
+ // 🔴 [4232]③/[4235]③ 两只钟分家(**判定** vs **记账**),缺一不可:
743
+ // · `decisionNowMs` = 判定钟。缺席 ⇒ 墙钟(既有行为逐字不变);在场 ⇒ 调用方给的**单调锚投影**
744
+ // (铸票时刻的墙钟 + 自铸票起单调钟走过的量)。它只进那条 `expires_at_ms > ?` 的 WHERE。
745
+ // · `stampMs` = 记账钟,恒为真墙钟。写进 `consumed_at_ms` 的必须是「这件事在挂钟上什么时候发生」,
746
+ // 审计行不许被判定侧的投影污染(任务书自审第三镜:记账钟不动,只改判定钟)。
747
+ const stampMs = this.now();
748
+ const decisionNowMs = opts?.decisionNowMs ?? stampMs;
731
749
  const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
732
750
  // 预读:①取不可变的 approvalId/payloadHash(于是认领之后零 I/O);②给四类拒绝一个准确归因。
733
751
  const row = await this.readRow(ticketId);
@@ -737,9 +755,9 @@ export class SqlRuleImportTicketStore {
737
755
  return { ok: false, reason: "wrong-principal" };
738
756
  if (row.consumedAtMs !== undefined)
739
757
  return { ok: false, reason: "consumed" };
740
- if (row.expiresAtMs <= nowMs)
758
+ if (row.expiresAtMs <= decisionNowMs)
741
759
  return { ok: false, reason: "expired" };
742
- const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [nowMs, ticketId, ownerKey, nowMs]);
760
+ const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ? WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = $1 WHERE ticket_id = $2 AND owner_key = $3 AND consumed_at_ms IS NULL AND expires_at_ms > $4`), [stampMs, ticketId, ownerKey, decisionNowMs]);
743
761
  // 认领成功 ⇒ 直接用预读那份不可变值返回,**不再发任何一条语句**(这正是本次修复的全部内容)。
744
762
  if (affected === 1)
745
763
  return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
@@ -761,13 +779,14 @@ export class SqlRuleImportTicketStore {
761
779
  * 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
762
780
  * 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
763
781
  */
764
- async release(ticketId, principal, mustRemainValidMs = 0) {
782
+ async release(ticketId, principal, mustRemainValidMs = 0, opts) {
765
783
  // 🔴 codex 交叉复审 round6 [medium](验真后修):`expires_at_ms` 也进 WHERE,而且比的是
766
784
  // **now + 通告的重试窗**,不是 now。理由:调用方拿本方法的返回值去决定要不要对客户端说
767
785
  // 「稍后拿同一张票重试」——若这张票在那几秒里就会过期,那句话是**必然兑现不了**的承诺
768
786
  // (客户端等完 Retry-After 回来只会收到 404,而且这一轮的确认已经丢了)。撑不过窗 ⇒ 如实回 false,
769
787
  // 调用方于是按不可重试报(四类同形 404),不给一条注定走死的路。
770
- const nowMs = this.now();
788
+ // `decisionNowMs`(判定钟)与 `consume` 同源同义 —— 本语句只有判定,没有记账列可写。
789
+ const nowMs = opts?.decisionNowMs ?? this.now();
771
790
  const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NOT NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = $1 AND owner_key = $2 AND consumed_at_ms IS NOT NULL AND expires_at_ms > $3`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), nowMs + mustRemainValidMs]);
772
791
  return affected === 1;
773
792
  }
@@ -832,7 +851,10 @@ export function createSqlPermissionRuleStores(db, now) {
832
851
  const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`,
833
852
  // PG 的 DELETE 没有 LIMIT ⇒ 用 ctid 子查询限行(PG 侧的标准写法;`ctid` 是物理行号,
834
853
  // 子查询里带 LIMIT 才是被支持的那一形)。两方言的**语义**相同:本轮最多删这么多行。
835
- `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]);
854
+ `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})`),
855
+ // [4232]③:减去 {@link RULE_IMPORT_TICKET_GC_GRACE_MS} —— 判定侧走单调锚之后,
856
+ // 「按墙钟已过期、按单调钟还在窗内」的票是可兑付的,GC 不能抢在它前面把行收走。
857
+ [nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
836
858
  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]);
837
859
  return deadTickets.affected + orphanPending.affected;
838
860
  },
@@ -75,6 +75,7 @@ export declare class PgSessionStore implements SessionStore {
75
75
  /** @see TiDBSessionStore.listSessions — session identity from session_meta (owner-filtered), task_run LEFT JOIN for preview. */
76
76
  listSessions(opts: {
77
77
  owner?: string;
78
+ includeUnowned?: boolean;
78
79
  cursor?: {
79
80
  lastActivityAt: string;
80
81
  sessionId: string;
@@ -292,8 +292,9 @@ export class PgSessionStore {
292
292
  return `$${params.length}`;
293
293
  };
294
294
  const where = [];
295
+ // [4198] 窄互认第二臂的第三条件(TiDB 孪生逐字同义):`includeUnowned` ⇒「本人的 ∪ 无主的」。
295
296
  if (opts.owner)
296
- where.push(`sm.owner = ${ph(opts.owner)}`);
297
+ where.push(opts.includeUnowned ? `(sm.owner = ${ph(opts.owner)} OR sm.owner IS NULL)` : `sm.owner = ${ph(opts.owner)}`);
297
298
  // `?q=` — the PG twin of the TiDB EXISTS predicate (LOWER-LIKE + ESCAPE, dialect-mirrored).
298
299
  if (opts.q)
299
300
  where.push(`EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = sm.session_id AND LOWER(tr.objective_preview) LIKE LOWER(${ph(`%${escapeLike(opts.q)}%`)}) ESCAPE '\\')`);
@@ -419,7 +419,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
419
419
  * 顺序的两条承重理由(其余是可读性):
420
420
  * · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
421
421
  * `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
422
- * · `task_event` / `task_active` 经 `task_run` 的属主门连删 ⇒ 必须排在 `task_run` 删除**之前**。
422
+ * · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。
423
423
  * 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
424
424
  * 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
425
425
  * (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
@@ -428,6 +428,14 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
428
428
  * 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
429
429
  * `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
430
430
  * 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
431
+ * 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。
432
+ *
433
+ * 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17
434
+ * 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量
435
+ * 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta`
436
+ * 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是
437
+ * "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta
438
+ * 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。
431
439
  */
432
440
  protected purgeSessionTrees(conn: SqlTxConn, domain: string, ids: readonly string[]): Promise<number>;
433
441
  /**
@@ -627,7 +627,7 @@ export class SqlRetentionStore {
627
627
  * 顺序的两条承重理由(其余是可读性):
628
628
  * · `checkpoint` / `checkpoint_ctx` 的属主门是 `session_meta.owner` 的 EXISTS 子查询 ⇒ 它们**必须**排在
629
629
  * `session_meta` 删除**之前**(meta 一没,门就恒假、一行都删不掉);
630
- * · `task_event` / `task_active` 经 `task_run` 的属主门连删 ⇒ 必须排在 `task_run` 删除**之前**。
630
+ * · `task_event` / `task_active` 经 `task_run` 连删 ⇒ 必须排在 `task_run` 删除**之前**。
631
631
  * 在线协调器那边「子表先删、meta 最后」的次序还额外承担**跨事务崩溃**语义(半途失败时 meta 仍在 ⇒ 幂等
632
632
  * 重试能收敛);本方法整段在一个事务里,那条理由在这里不成立,但次序照抄不改 —— 两条腿的等价断言
633
633
  * (E21)比的就是"同一条契约的两个执行体",让次序分家等于自愿放弃那条判据。
@@ -636,25 +636,30 @@ export class SqlRetentionStore {
636
636
  * 留存腿传的是**域键**(字符串,`""` 表示无主桶)⇒ 用 {@link SqlRetentionStore.domainWhere} 表达
637
637
  * `(owner IS NULL OR owner = '')`。对有主域两者逐字等价(`col = ?` ⟺ `col <=> ?`,参数非 NULL);
638
638
  * 对无主域,`<=>` 拿字符串 `""` 去比 NULL 会**一行都不匹配** —— 那正是「无主会话永远清不掉」的病。
639
+ * 这条改写只对 **`session_meta.owner` 这一族门**(meta / checkpoint / checkpoint_ctx)成立。
640
+ *
641
+ * 🔴 运行账本(task_event / task_active / task_run)**没有**属主门,按 `session_id` 单键删(F-A,2026-08-17
642
+ * 合并码扫描;与在线协调器同刀改):`task_run.owner` 是**每次提交的 principal**,与会话主会分叉(无主存量
643
+ * 会话被带 principal 的壳续聊 ⇒ 新行 owner="default")。行级属主门于是只删得掉半棵树,而 `session_meta`
644
+ * 在同一事务里已经删掉 ⇒ {@link SqlRetentionStore.sessionCandidates} 从此**永远枚举不到**这些行:它们不是
645
+ * "下一轮再清",是**没有任何清理路径**的孤儿。域隔离不靠这道门承担 —— `ids` 已经是**本域** session_meta
646
+ * 里选出来并 `FOR UPDATE` 锁住的候选集,行级门去掉之后越域仍然结构上不可能。
639
647
  */
640
648
  async purgeSessionTrees(conn, domain, ids) {
641
649
  /** 每条腿都要一份新的谓词(PG 的 `$n` 编号随参数位置变)。 */
642
650
  const set = (col, pgIndex) => this.idSet(col, ids, pgIndex);
643
- // ① 运行账本:task_event → task_active → task_run( task_run 的属主门;与协调器逐字同序)
651
+ // ① 运行账本:task_event → task_active → task_run(session_id 单键,无行级属主门;与协调器逐字同序)
644
652
  {
645
653
  const s = set("tr.session_id", 1);
646
- const w = this.domainWhere("tr.owner", domain, 2);
647
- await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
654
+ await conn.query(this.q(`DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE ${s.sql}`, `DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND ${s.sql}`), [...s.params]);
648
655
  }
649
656
  {
650
657
  const s = set("tr.session_id", 1);
651
- const w = this.domainWhere("tr.owner", domain, 2);
652
- await conn.query(this.q(`DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE ${s.sql} AND ${w.sql}`, `DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND ${s.sql} AND ${w.sql}`), [...s.params, ...w.params]);
658
+ await conn.query(this.q(`DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE ${s.sql}`, `DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND ${s.sql}`), [...s.params]);
653
659
  }
654
660
  {
655
661
  const s = set("session_id", 1);
656
- const w = this.domainWhere("owner", domain, 2);
657
- await conn.query(`DELETE FROM task_run WHERE ${s.sql} AND ${w.sql}`, [...s.params, ...w.params]);
662
+ await conn.query(`DELETE FROM task_run WHERE ${s.sql}`, [...s.params]);
658
663
  }
659
664
  // ② checkpoint + checkpoint_ctx(属主门 = session_meta.owner 的 EXISTS;**必须**在 meta 删除之前)
660
665
  {
@@ -187,6 +187,7 @@ export declare class SqlRunStore {
187
187
  * 🔴 Same builder divergence as {@link listRuns} — see its header note. */
188
188
  listSessions(opts: {
189
189
  owner?: string;
190
+ includeUnowned?: boolean;
190
191
  cursor?: {
191
192
  lastActivityAt: string;
192
193
  sessionId: string;
@@ -198,12 +199,35 @@ export declare class SqlRunStore {
198
199
  private rowToSession;
199
200
  /**
200
201
  * E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
201
- * event log), task_active (the single-active-run claim), and task_run (the run rows). Owner-guarded (null-safe
202
- * compare, single-DB defense-in-depth, mirrors {@link requestCancel}) so even a buggy HTTP gate can't purge
203
- * another tenant's runs. Idempotent returns `{ removed }` (the count of task_run rows removed; 0 = nothing to
204
- * purge). The conversation HISTORY (session_meta/session_event) is purged separately by the session store; the
205
- * checkpoint/tool-result rows by their stores this method owns only the runs-ledger tables. Done in one TX so a
206
- * partial purge can't leak a task_active claim with no run row (a permanent session lock).
202
+ * event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
203
+ * `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
204
+ * (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
205
+ * their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
206
+ * task_active claim with no run row (a permanent session lock).
207
+ *
208
+ * 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
209
+ * 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
210
+ * 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
211
+ * 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
212
+ * 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
213
+ * 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
214
+ * 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
215
+ * 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
216
+ * `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
217
+ *
218
+ * 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
219
+ * **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
220
+ * (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
221
+ * 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
222
+ * purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
223
+ * 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
224
+ * ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
225
+ * 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
226
+ * `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
227
+ * 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
228
+ * ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
229
+ * 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
230
+ * 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
207
231
  *
208
232
  * 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
209
233
  * 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
@@ -217,7 +241,7 @@ export declare class SqlRunStore {
217
241
  * 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
218
242
  * `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
219
243
  */
220
- deleteBySession(sessionId: string, owner: string | null): Promise<{
244
+ deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
221
245
  removed: number;
222
246
  } | {
223
247
  active: string;
@@ -350,7 +350,8 @@ export class SqlRunStore {
350
350
  const params = [];
351
351
  let innerWhere = "";
352
352
  if (opts.owner !== undefined) {
353
- innerWhere = " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
353
+ // [4198] 窄互认第二臂的第三条件(legacy task_run 聚合面的孪生;session_meta 面见 tidb-session-store.ts)
354
+ innerWhere = opts.includeUnowned ? " WHERE (owner = ? OR owner IS NULL)" : " WHERE owner = ?"; // "" is a valid exact owner, not "no filter" (B10)
354
355
  params.push(opts.owner);
355
356
  }
356
357
  const outer = ["rn = 1"];
@@ -382,7 +383,7 @@ export class SqlRunStore {
382
383
  params.push(v);
383
384
  return `$${params.length}`;
384
385
  };
385
- const innerWhere = opts.owner !== undefined ? ` WHERE owner = ${p(opts.owner)}` : ""; // "" is a valid exact owner, not "no filter" (B10)
386
+ const innerWhere = opts.owner !== undefined ? (opts.includeUnowned ? ` WHERE (owner = ${p(opts.owner)} OR owner IS NULL)` : ` WHERE owner = ${p(opts.owner)}`) : ""; // "" is a valid exact owner, not "no filter" (B10)
386
387
  const outer = ["rn = 1"];
387
388
  // `?q=` — the PG twin of the TiDB EXISTS predicate above.
388
389
  if (opts.q)
@@ -420,12 +421,35 @@ export class SqlRunStore {
420
421
  }
421
422
  /**
422
423
  * E21 (§0.5 session delete) — purge the service-owned RUNS LEDGER for one session: task_event (the replayable
423
- * event log), task_active (the single-active-run claim), and task_run (the run rows). Owner-guarded (null-safe
424
- * compare, single-DB defense-in-depth, mirrors {@link requestCancel}) so even a buggy HTTP gate can't purge
425
- * another tenant's runs. Idempotent returns `{ removed }` (the count of task_run rows removed; 0 = nothing to
426
- * purge). The conversation HISTORY (session_meta/session_event) is purged separately by the session store; the
427
- * checkpoint/tool-result rows by their stores this method owns only the runs-ledger tables. Done in one TX so a
428
- * partial purge can't leak a task_active claim with no run row (a permanent session lock).
424
+ * event log), task_active (the single-active-run claim), and task_run (the run rows). Idempotent — returns
425
+ * `{ removed }` (the count of task_run rows removed; 0 = nothing to purge). The conversation HISTORY
426
+ * (session_meta/session_event) is purged separately by the session store; the checkpoint/tool-result rows by
427
+ * their stores — this method owns only the runs-ledger tables. Done in one TX so a partial purge can't leak a
428
+ * task_active claim with no run row (a permanent session lock).
429
+ *
430
+ * 🔴 Scoped by `session_id` ALONE — deliberately NO per-row owner guard (F-A, 2026-08-17 合并码扫描;同一条
431
+ * 判断 `boot/session-faces.ts` 的 anchors 腿早就写过,run 账本这条腿当时没跟上):`task_run.owner` 是**每次
432
+ * 提交的 principal**,它与会话的 canonical owner 会分叉 —— 一条 `session_meta.owner = NULL` 的存量会话被
433
+ * 带 principal 的壳续聊后(REQUIRE_PRINCIPAL=false),新 run 行的 owner 是 `"default"`,而 DELETE 路由递进来
434
+ * 的是**会话行**的 owner(NULL)。行级 null-safe 属主门(`<=>` / `IS NOT DISTINCT FROM`)于是只删得掉 NULL
435
+ * 那批,具名那批连同它们的 task_event 全部存活 —— 而 `session_meta` 已经删了,留存腿的 sessionCandidates
436
+ * 再也枚举不到这棵树 ⇒ 孤儿行**没有任何清理路径**,路由却回 `{deleted:true}`。那就是一次**报成成功的
437
+ * 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
438
+ * `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
439
+ *
440
+ * 🔴 **化身围栏**(`expectedSessionOwner`;codex 对抗复审 R1-[high],验真后修)—— 属主门下来了,但会话
441
+ * **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
442
+ * (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
443
+ * 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
444
+ * purge 把 meta 删掉,租户 B 用同一个 uuid 重新登记并跑完一次 run,慢半拍的 A 才走到这里 ⇒ 按 session_id
445
+ * 单键删就把 **B 的**行删了(修前的行级属主门恰好挡住这一形,因为 B 的行 owner≠A 的会话主)。
446
+ * ⇒ 事务头部 `SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE`,行不在或 owner 与路由验过的
447
+ * 那一位不符 ⇒ **一行不删**回 `{removed:0}`(诚实:属于调用方的那个化身已经不在了;协调器据此回
448
+ * `{deleted:false}`,不会拿别人的行数冒充成功)。锁序与留存腿逐字一致(先 session_meta 后 task_*),
449
+ * 不引入新的死锁环;锁在本事务提交时释放,其后的 checkpoint / history 两腿各自带属主门。
450
+ * ⚠️ 本围栏只有 SQL 双生做得到 —— File/Memory 孪生的账本里没有 session_meta(local 车道的会话表在另一只
451
+ * 店里),它们的同名参数因此只作**契约对齐**用,真围栏由协调器那条 pre-delete 复读 + local 数据根的
452
+ * 独占 BootLock(一个数据根恒一个进程)承担;逐字见 memory-run-store 的同名方法注。
429
453
  *
430
454
  * 🔴 Active-run re-assert INSIDE the TX (adversarial-review LOW — TOCTOU): the route's pre-purge `getActiveTaskId`
431
455
  * 409 check is a non-transactional SELECT — a concurrent createRun can claim `task_active(session_id)` in the
@@ -439,19 +463,24 @@ export class SqlRunStore {
439
463
  * 🔴 The multi-table DELETEs are dialect-divergent SQL FORMS (not just placeholders): TiDB's
440
464
  * `DELETE te FROM te JOIN tr ON …` vs PG's `DELETE FROM te USING tr WHERE …`.
441
465
  */
442
- async deleteBySession(sessionId, owner) {
466
+ async deleteBySession(sessionId, expectedSessionOwner) {
443
467
  let result = { removed: 0 };
444
468
  await this.tx(async (conn) => {
469
+ // 化身围栏(见头注):路由验过的那个会话化身必须**此刻仍在**,否则一行不删。
470
+ const { rows: meta } = await conn.query(this.q("SELECT owner FROM session_meta WHERE session_id = ? FOR UPDATE", "SELECT owner FROM session_meta WHERE session_id = $1 FOR UPDATE"), [sessionId]);
471
+ const metaRow = meta[0];
472
+ if (metaRow === undefined || (metaRow.owner ?? null) !== expectedSessionOwner)
473
+ return; // {removed:0}
445
474
  // Transactional no-active-run gate (FOR UPDATE serializes against createRun's task_active claim).
446
475
  const { rows: active } = await conn.query(this.q("SELECT task_id FROM task_active WHERE session_id = ? FOR UPDATE", "SELECT task_id FROM task_active WHERE session_id = $1 FOR UPDATE"), [sessionId]);
447
476
  if (active[0]) {
448
477
  result = { active: String(active[0].task_id) };
449
478
  return; // abort — delete nothing; the route 409s (don't purge a live run's rows)
450
479
  }
451
- // task_event has no owner column → scope it through the owner-guarded task_run set for this session.
452
- await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ? AND tr.owner <=> ?", "DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND tr.session_id = $1 AND tr.owner IS NOT DISTINCT FROM $2"), [sessionId, owner]);
453
- await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ? AND tr.owner <=> ?", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.session_id = $1 AND tr.owner IS NOT DISTINCT FROM $2"), [sessionId, owner]);
454
- const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ? AND owner <=> ?", "DELETE FROM task_run WHERE session_id = $1 AND owner IS NOT DISTINCT FROM $2"), [sessionId, owner]);
480
+ // task_event has no session_id column → scope it through this session's task_run set (session_id alone).
481
+ await conn.query(this.q("DELETE te FROM task_event te JOIN task_run tr ON te.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_event te USING task_run tr WHERE te.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
482
+ await conn.query(this.q("DELETE ta FROM task_active ta JOIN task_run tr ON ta.task_id = tr.task_id WHERE tr.session_id = ?", "DELETE FROM task_active ta USING task_run tr WHERE ta.task_id = tr.task_id AND tr.session_id = $1"), [sessionId]);
483
+ const { affected } = await conn.query(this.q("DELETE FROM task_run WHERE session_id = ?", "DELETE FROM task_run WHERE session_id = $1"), [sessionId]);
455
484
  result = { removed: affected };
456
485
  });
457
486
  return result;
@@ -28,6 +28,9 @@ import { type AdoptionLogStore } from "./adoption-log-sql.js";
28
28
  import { type SqlRetentionStore, type SqlRetentionStoreOptions } from "./retention-store-sql.js";
29
29
  import { type RetentionLaneStore } from "./retention-lane-store-sql.js";
30
30
  import type { PermissionRuleStoreBundle } from "../rules-consent.js";
31
+ /** boot 期的一条日志座(级别由**座位**决定,不由参数携带):`createStoreBackend` 的 `onWarn` / `onError`
32
+ * 两参与 `LocalBackend` 的同名字段共用此形。装配层把它们分别接到 `logger.warn` / `logger.error` 上。 */
33
+ export type BootLogSink = (msg: string, meta?: Record<string, unknown>) => void;
31
34
  export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
32
35
  /** E18 resume-at eventId→entryId anchor map — tidb/pg/local 3-backend (works LOCAL; needs only the session tree +
33
36
  * this map, no cloud-only checkpoint). Union = the three nominal twins; consumers use put/resolve/deleteBySession.
@@ -278,8 +281,11 @@ export declare function counterStoreLabel(store: unknown, kind: string | undefin
278
281
  *
279
282
  * `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
280
283
  * permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
281
- * answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one. */
282
- export declare function createStoreBackend(config: ServiceConfig, onWarn?: (msg: string, meta?: Record<string, unknown>) => void): StoreBackend | undefined;
284
+ * answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
285
+ * `onError` (additive, optional; F-D) is the **error-level** twin of that seat 件6 的
286
+ * `file_run_store_hydrate_unreadable` 按成文承诺是 error 级,warn 座满足不了那句话。两座都是**加项**:
287
+ * 既有一/二参调用方逐字不变(缺席 ⇒ 该披露退回 warn 座 / 只剩 fail-open 遥测那一半)。 */
288
+ export declare function createStoreBackend(config: ServiceConfig, onWarn?: BootLogSink, onError?: BootLogSink): StoreBackend | undefined;
283
289
  /** boot 期打开 store 后端的**统一入口**:构造 + ensureSchema 同罩一层降级臂。
284
290
  *
285
291
  * 为什么必须把 `createStoreBackend()` 也罩进来(2026-07-28 修):clay 1.292 拍的口径是「裸 boot
@@ -292,6 +298,7 @@ export declare function createStoreBackend(config: ServiceConfig, onWarn?: (msg:
292
298
  * operator 要了 durable,静默丢=丢数据。 */
293
299
  export declare function openStoreBackendWithFallback(config: ServiceConfig, logger: {
294
300
  warn: (msg: string, meta?: Record<string, unknown>) => void;
301
+ error?: (msg: string, meta?: Record<string, unknown>) => void;
295
302
  }): Promise<{
296
303
  backend: StoreBackend | undefined;
297
304
  degraded: boolean;
@@ -217,8 +217,13 @@ class LocalBackend {
217
217
  /** boot 期的告警口(`openStoreBackendWithFallback` 把它接到 logger.warn 上)。可选:直接 `new` 一只
218
218
  * LocalBackend 的测试/工具不必装配日志面 —— 缺席时 core 的披露仍留在 core 那侧,只是本仓不复述。 */
219
219
  onWarn;
220
- constructor(root, config, onWarn) {
220
+ /** boot 期的 **error 级**口(F-D):`FileRunStore.hydrate` 的 readdir 失败按三处成文(本文件下方接线处 /
221
+ * `fail-open.ts` 的 tag note / 普查表第 29 行)承诺的是 **error 级**披露,而 warn 座满足不了那句话 ——
222
+ * 按 `level >= error` 配告警的运维会一条都收不到。可选:缺席时退回 {@link onWarn}(降一级好过无声)。 */
223
+ onError;
224
+ constructor(root, config, onWarn, onError) {
221
225
  this.onWarn = onWarn;
226
+ this.onError = onError;
222
227
  // The fileBackend ctor mkdirs `root` + takes the boot pidfile lock — a same-host SECOND instance FAILS FAST.
223
228
  // Re-throw with a clear operator message (the raw core error is "another instance owns this data dir").
224
229
  //
@@ -270,7 +275,17 @@ class LocalBackend {
270
275
  this.sessionStore = new LocalSessionStore(new FileSessionRepo(this.fileBackend.root), this.fileBackend.root);
271
276
  // P0.5 variant-1: file-backed runs ledger + E18 anchors over the SAME canonical root (under root/runs + root/
272
277
  // resume-anchors). Hydrate the in-memory index from disk on construction → restart-visible.
273
- this.runStore = new FileRunStore(this.fileBackend.root);
278
+ // 件6(#43 [4281]):hydrate 的 readdir 失败要**响亮**——把 boot 告警口接过去(缺席则只走 fail-open
279
+ // 遥测那一半)。包一层箭头而不是直接传方法引用:后者会把方法从接收者上摘下来,带 `this` 的实现会在
280
+ // 第一次真披露时炸,而那一刻正是最不该炸的时候(本文件 :658 同族先例)。
281
+ // 🔴 F-D(2026-08-17 合并码扫描):这一座必须接 **error** 口。件6 三处成文(FileRunStore 那条 catch 注 /
282
+ // `fail-open.ts` 的 tag note / 普查表第 29 行)承诺的都是「一行 **error 级**启动日志」,而修前它被绑到
283
+ // `onWarn` 上、且 `openStoreBackendWithFallback` 的 logger 参数类型里压根没有 error 通道 ⇒ 生产恒 warn,
284
+ // 按 `level >= error` 配告警的运维一条都收不到,而这条债的全部价值就是「恒 miss 不再被当成本来就没有」。
285
+ // `onError` 缺席(只装配了 warn 座的调用方)⇒ 退回 warn 座:降一级好过无声。
286
+ this.runStore = new FileRunStore(this.fileBackend.root, {
287
+ error: (msg, fields) => void (this.onError ?? this.onWarn)?.(msg, fields),
288
+ });
274
289
  this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
275
290
  this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
276
291
  this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
@@ -377,10 +392,13 @@ export function counterStoreLabel(store, kind) {
377
392
  *
378
393
  * `onWarn` (additive, optional) is the boot-time warning sink the LOCAL backend needs for core's
379
394
  * permission-rule DISCLOSURE face (#203 §1): an unreadable / checksum-mismatched / symlinked rule file
380
- * answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one. */
381
- export function createStoreBackend(config, onWarn) {
395
+ * answers ZERO rules and says so — swallowing that turns a fail-closed degrade into a silent one.
396
+ * `onError` (additive, optional; F-D) is the **error-level** twin of that seat — 件6 的
397
+ * `file_run_store_hydrate_unreadable` 按成文承诺是 error 级,warn 座满足不了那句话。两座都是**加项**:
398
+ * 既有一/二参调用方逐字不变(缺席 ⇒ 该披露退回 warn 座 / 只剩 fail-open 遥测那一半)。 */
399
+ export function createStoreBackend(config, onWarn, onError) {
382
400
  if (config.dbBackend === "local")
383
- return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn);
401
+ return new LocalBackend(config.localDataRoot ?? join(homedir(), ".ai-agent"), config, onWarn, onError);
384
402
  if (config.dbBackend === "pg") {
385
403
  if (!config.pg)
386
404
  return undefined;
@@ -402,7 +420,11 @@ export function createStoreBackend(config, onWarn) {
402
420
  * 降级条件不变(两形同一姿势):`sessionBackend=auto`(配置的 DB 不可达,S5 原形)或**默认推导**的
403
421
  * local(`dbBackend=local && !dbBackendExplicit`)。**显式** DB_BACKEND=local / tidb 仍 fail-loud——
404
422
  * operator 要了 durable,静默丢=丢数据。 */
405
- export async function openStoreBackendWithFallback(config, logger) {
423
+ export async function openStoreBackendWithFallback(config,
424
+ // 🔴 `error` 是**可选加项**(F-D):件6 的 hydrate 披露按成文承诺是 error 级,而本参数此前只有 warn 通道
425
+ // ⇒ 那句承诺在生产链上结构性地兑不了(注释说 error、代码只出 warn,正是本文件下方 :659 自陈的病族)。
426
+ // 仍可选:只装配 warn 座的调用方(测试/工具)逐字不变,它们的 hydrate 披露退回 warn 座。
427
+ logger) {
406
428
  // 判据按后端分岔(2026-07-28 同批修):原先写的是 `sessionBackend === "auto" || (local && !explicit)`,
407
429
  // 而 `auto` 是 SESSION_BACKEND 的**默认值** ⇒ 显式 `DB_BACKEND=local` 的部署照样落进第一条臂被静默降级,
408
430
  // 「显式仍 fail-loud」这句话同样从未生效。local 形只看 DB_BACKEND 是不是显式;SQL 形维持 S5 原义
@@ -412,7 +434,8 @@ export async function openStoreBackendWithFallback(config, logger) {
412
434
  try {
413
435
  // 包一层箭头而不是直接传 `logger.warn`:后者会把方法从它的接收者上摘下来,一个带 `this` 的
414
436
  // logger 实现会在第一次真披露时炸——而那一刻正是最不该炸的时候(本仓 fail-loud 病族先例)。
415
- backend = createStoreBackend(config, (msg, meta) => logger.warn(msg, meta));
437
+ // error 座同款包法;调用方没有 error 通道时**不传**(LocalBackend 那侧据此退回 warn )
438
+ backend = createStoreBackend(config, (msg, meta) => logger.warn(msg, meta), logger.error === undefined ? undefined : (msg, meta) => logger.error(msg, meta));
416
439
  // connects + creates schema (also the reachability probe).
417
440
  if (backend)
418
441
  await backend.ensureSchema();
@@ -55,6 +55,7 @@ export declare class TiDBSessionStore implements SessionStore {
55
55
  */
56
56
  listSessions(opts: {
57
57
  owner?: string;
58
+ includeUnowned?: boolean;
58
59
  cursor?: {
59
60
  lastActivityAt: string;
60
61
  sessionId: string;
@@ -120,7 +120,9 @@ export class TiDBSessionStore {
120
120
  const params = [];
121
121
  const where = [];
122
122
  if (opts.owner) {
123
- where.push("sm.owner = ?"); // idx_owner the per-user view (a principal is PINNED to its own at the route)
123
+ // [4198] 窄互认第二臂的第三条件在此:`includeUnowned` 时谓词放宽到「本人的 无主的」。
124
+ // 仍然**不是** `owner IS NOT DISTINCT FROM ?`——具名的别人一行都不多给。
125
+ where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?"); // idx_owner
124
126
  params.push(opts.owner);
125
127
  }
126
128
  if (opts.q) {
@@ -1,6 +1,6 @@
1
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
- import { type RuleImportTicket, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
3
+ import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./plugins/permission-rule-store-sql.js";
4
4
  /**
5
5
  * 本车道**真正**依赖的三面(**结构**接口,不是 SQL 类)。
6
6
  *
@@ -28,11 +28,12 @@ export interface RuleConsentStores {
28
28
  }>;
29
29
  ttlMs: number;
30
30
  }): Promise<RuleImportTicket>;
31
- /** 一次**认领**(不是「已完成」)。语义与「认领之后怎么办」见 {@link RuleConsentStores.tickets.release}。 */
32
- consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
31
+ /** 一次**认领**(不是「已完成」)。语义与「认领之后怎么办」见 {@link RuleConsentStores.tickets.release}。
32
+ * `opts.decisionNowMs` = [4232]③ 的判定钟覆写(记账列不受影响),见 {@link RuleTicketDecisionClock}。 */
33
+ consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
33
34
  /** 把认领**放回去**(认领之后遇到不确定/瞬时失败时)。`mustRemainValidMs` = 放回之后这张票至少还要
34
35
  * 能用多久 —— 撑不过就**不算放回成功**(理由逐字见 store 侧 `release` 的头注)。 */
35
- release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
36
+ release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
36
37
  };
37
38
  }
38
39
  /**
@@ -219,5 +220,7 @@ export interface RuleConsentLane {
219
220
  export declare function createRuleConsentLane(stores: RuleConsentStores, opts?: {
220
221
  ticketTtlMs?: number;
221
222
  newTicketId?: () => string;
223
+ /** [4232]③ 的单调钟 seam。缺省 `performance.now()` —— 本仓既定单调域。 */
224
+ monotonic?: () => number;
222
225
  }): RuleConsentLane;
223
226
  //# sourceMappingURL=rules-consent.d.ts.map
@@ -127,6 +127,63 @@ export function createRuleConsentLane(stores, opts) {
127
127
  const deps = { provider: stores.provider, approvals: stores.approvals };
128
128
  const ticketTtlMs = opts?.ticketTtlMs ?? RULE_IMPORT_TICKET_TTL_MS;
129
129
  const newTicketId = opts?.newTicketId ?? (() => randomUUID());
130
+ const monotonic = opts?.monotonic ?? (() => performance.now());
131
+ /**
132
+ * [4232]③/[4235]③ —— **本进程铸出的票**的单调锚台账。
133
+ *
134
+ * 病灶(test 真机两条读数):`expiresAtMs = 铸票时刻的墙钟 + TTL`。墙钟在铸票与兑付之间被拨动 ⇒
135
+ * ·(前拨)真实只过 3 秒的票被判过期;
136
+ * ·(铸票时钟本身慢了 11 分钟,随后被 NTP 纠正)票**出生即过期** —— `expiresAtMs` 换算出的时刻
137
+ * 比这张票真实铸出的时刻还早一分钟。
138
+ * core [4235]③ 的判词「从票据内部无解」是对的:`mintedAt` 与 `expiresAt` 同源同错,行里没有第二个基准。
139
+ * 但**铸票与兑付发生在同一个进程**时,进程自己有第二个基准 —— 单调钟。于是判定钟改成
140
+ * `判定 now = 铸票时刻的墙钟 + (单调钟现在 − 单调钟铸票时)`
141
+ * 即「签发与判定同源单调锚」。它**给不了票额外寿命**:投影严格按单调钟前进,真实走满 TTL 就必然越过
142
+ * `expiresAtMs`(负控钉在 db-integration 里)。记账列(`consumed_at_ms`)一律仍是真墙钟。
143
+ *
144
+ * 🔴 **射程边界 —— 未闭合的残余,如实登记**(codex 对抗复审 R1 [high] 四,验真后**接受**):本锚只活在
145
+ * 当前进程的 Map 里。prepare 与 redeem 被负载均衡分到**不同副本**时(不需要故障,常规轮询就够),
146
+ * `decisionClock` 返回 undefined、store 退回该副本的墙钟 —— 那台副本若有钟偏,有效票**仍会**出生即过期。
147
+ * 也就是说本修把缺陷从「所有兑付」缩小到「跨副本兑付」,**没有消灭它**;正确性因此隐含依赖了一条
148
+ * 没人声明过的会话粘滞。不要把这条注读成「已解」。
149
+ *
150
+ * 收口方向(下一批,不在本小件批射程):把判定权威换成**两副本共享的那口钟 —— 数据库自己的钟**:
151
+ * 铸票时 `expires_at_ms = <db now> + ttl`、兑付时 `WHERE expires_at_ms > <db now>`
152
+ * (MySQL `CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED)` / PG `(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint`)。
153
+ * 那一形对**所有**副本同时成立,还能把本台账整个删掉。记账列仍走进程墙钟(两钟分家的语义不变)。
154
+ */
155
+ const anchors = new Map();
156
+ /** 台账只服务「本进程铸、本进程兑」的窗,所以按单调钟自清:超过一个 TTL 的锚已经不可能改变任何判定。
157
+ * 另加一个硬帽,免得一条只 prepare 不 redeem 的路径把 Map 撑大(每条锚两个 number,帽住即够)。 */
158
+ const ANCHOR_CAP = 4096;
159
+ const pruneAnchors = (nowMono) => {
160
+ for (const [id, a] of anchors) {
161
+ if (nowMono - a.mintedAtMono > ticketTtlMs)
162
+ anchors.delete(id);
163
+ }
164
+ if (anchors.size <= ANCHOR_CAP)
165
+ return;
166
+ // 仍然超帽 ⇒ 丢最老的(Map 迭代序 = 插入序 = 铸票序)。丢掉只是退回墙钟判定,不产生错判方向。
167
+ const excess = anchors.size - ANCHOR_CAP;
168
+ let dropped = 0;
169
+ for (const id of anchors.keys()) {
170
+ if (dropped++ >= excess)
171
+ break;
172
+ anchors.delete(id);
173
+ }
174
+ };
175
+ /**
176
+ * 本进程铸的票 ⇒ 单调投影;别处铸的 ⇒ `undefined`(store 退回墙钟,既有行为逐字不变)。
177
+ *
178
+ * 🔴 `Math.ceil` 不是装饰:①`performance.now()` 是**小数**毫秒,而 `expires_at_ms` 是 BIGINT 列 ——
179
+ * 小数直接绑进 PG 会 `invalid input syntax for type bigint`(真双库当场逮到,mysql2 那侧则会静默接受,
180
+ * 正是「一个方言绿一个方言红」的经典形);②取整方向必须**保守**:判定式是 `expires_at_ms > 判定 now`,
181
+ * 判定 now 取小 = 票更晚过期,所以向上取整才不会白送半毫秒寿命。
182
+ */
183
+ const decisionClock = (ticketId) => {
184
+ const a = anchors.get(ticketId);
185
+ return a === undefined ? undefined : { decisionNowMs: Math.ceil(a.mintedAtWall + (monotonic() - a.mintedAtMono)) };
186
+ };
130
187
  return {
131
188
  async persistCardRule(input) {
132
189
  // 🔴 候选**由引擎铸**(core `prepareCardApproval` 不收调用方候选;那条入口上「写自己的规则」
@@ -179,12 +236,19 @@ export function createRuleConsentLane(stores, opts) {
179
236
  }
180
237
  return { ok: false, reason: "too-many-candidates", candidates: preview.candidates.length };
181
238
  }
239
+ // [4232]③:铸票**当拍**同时钉下两只钟 —— 墙钟锚取 `minted.expiresAtMs - ttlMs`(= store 自己用的
240
+ // 那个铸票时刻,不另取一次 `Date.now()`:两处各取一次会让锚与行差出一跳的量),单调锚取本进程单调钟。
241
+ const mintedAtMono = monotonic();
182
242
  const minted = await stores.tickets.mint({ ticketId: newTicketId(), principal, approvalId, candidates: preview.candidates, ttlMs: ticketTtlMs });
243
+ pruneAnchors(mintedAtMono);
244
+ anchors.set(minted.ticketId, { mintedAtWall: minted.expiresAtMs - ticketTtlMs, mintedAtMono });
183
245
  return { ok: true, preview, ticket: minted.ticketId, expiresAtMs: minted.expiresAtMs };
184
246
  },
185
247
  async redeemImport(principal, ticket) {
186
248
  // ①②③ 一次原子**认领**同时回答(principal 绑定 / 未过期 / 未认领);四类拒绝在 wire 面同形。
187
- const consumed = await stores.tickets.consume(ticket, principal);
249
+ // [4232]③:判定钟走本进程的单调锚(本进程没铸过这张票 undefined store 退回墙钟)
250
+ const clock = decisionClock(ticket);
251
+ const consumed = await stores.tickets.consume(ticket, principal, clock);
188
252
  if (!consumed.ok)
189
253
  return { ok: false, reason: "ticket-unusable", detail: consumed.reason };
190
254
  // 🔴 codex round1 [high] 一(验真后修):认领**之后**的每一步都可能失败,而旧形把认领当终局 ⇒
@@ -197,7 +261,18 @@ export function createRuleConsentLane(stores, opts) {
197
261
  // 判据必须是「释放**确认**成功」;没确认就不许承诺重试(如实按不可重试报,四类同形 404)。
198
262
  const releaseClaim = async () => {
199
263
  try {
200
- return await stores.tickets.release(ticket, principal, RULE_IMPORT_RETRY_AFTER_SEC * 1000);
264
+ // 🔴 判定钟**当场重取**,不复用 `consume` 那次的读数(F-B,2026-08-17 合并码扫描)。同锚(同一张票的
265
+ // `mintedAtWall`/`mintedAtMono`)、同尺(同一只单调钟),但**读数必须是现在**:
266
+ // · `consume` 问的是向后看的问题(此刻窗还开着吗);`release` 问的是向前看的问题(这张票能不能
267
+ // 活过我即将通告的 Retry-After)。第二问只有在**释放这一刻**问才有意义。
268
+ // · capture 与这里之间隔着 consume 两往返 + approvals.get + confirmRuleApproval 的 CAS/OCC +
269
+ // 最多 MAX_IMPORT_CANDIDATES 条候选的串行兑付循环,而本闭包**只在店退化时**才被走到 ——
270
+ // 缺陷发作时这段恰恰最长(秒级 vs 2s 的窗),旧注那句「重取一次投影只会多走几微秒」说的是
271
+ // 重取的**成本**,却被当成了流逝量的上界,那是两件事。
272
+ // · 单调钟不回退 ⇒ 冻结读数恒 ≤ 新读数 ⇒ 误差全部落在 **permissive** 一侧,而 store 侧 `release`
273
+ // 的头注把这道门定义成保守门(「承诺一次必然兑现不了的重试比不承诺更坏」)。
274
+ // 本进程没铸过这张票时两处都回 undefined(store 退回墙钟),语义逐字不变。
275
+ return await stores.tickets.release(ticket, principal, RULE_IMPORT_RETRY_AFTER_SEC * 1000, decisionClock(ticket));
201
276
  }
202
277
  catch (err) {
203
278
  // 放不回去 = 回到「认领即烧票」的旧形(属主这一轮不能重试,票随 TTL 消失)。**不**把一次