@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
@@ -39,7 +39,7 @@ export function createSessionFaces(ctx) {
39
39
  : undefined;
40
40
  // §0.5 E21 — DELETE /v1/sessions/:id purge coordinator. Deletes the session abstraction's CONVERSATION HISTORY
41
41
  // (sessionStorage.deleteSession → session_meta/session_event) PLUS the service-owned operational rows for that
42
- // session: the runs ledger (runStore.deleteBySession → task_run/task_active/task_event, owner-guarded), the
42
+ // session: the runs ledger (runStore.deleteBySession → task_run/task_active/task_event, session_id-scoped), the
43
43
  // durable checkpoints + resume-ctx (checkpointStore.deleteBySession, present only under DURABLE_APPROVAL), and
44
44
  // offloaded tool results (toolResultStore.deleteBySession). Wired only when the session store can actually
45
45
  // delete (a DB backend).
@@ -74,6 +74,36 @@ export function createSessionFaces(ctx) {
74
74
  // window it returns `{ active }` and deletes nothing → we bubble it up so the route 409s, BEFORE touching
75
75
  // any other table (no partial purge of a live session). Each leg propagates its error so a child failure
76
76
  // aborts the purge BEFORE the history-delete gate is removed (keeping the idempotent retry convergent).
77
+ // 🔴 run 账本按 session_id 单键删,**没有**行级属主门(F-A 修,2026-08-17 合并码扫描):`task_run.owner`
78
+ // 是每次提交的 principal,与会话的 canonical owner 会分叉(无主存量会话被带 principal 的壳续聊 ⇒ 新行
79
+ // owner="default",而这里递的是会话行的 owner=null)。行级门于是只删一半、把具名行连同 task_event 留成
80
+ // 孤儿(session_meta 已删 ⇒ 留存腿的 sessionCandidates 永远枚举不到它们),路由却回 {deleted:true} ——
81
+ // 与下面 anchors 腿注里那句 "a right-to-delete violation reported as success" 是同一个病,理由也同一条:
82
+ // 会话所有权已在路由的 DELETE 门证过。
83
+ // 🔴 **化身围栏**(codex R1-[high] 验真后加):属主门下来了,「路由验过权」这句话就必须还**成立到此刻**。
84
+ // 会话 id 由调用方自选、删除后可被别人 `register` 重新登记 ⇒ 两只并发 purge 交错时(A 与 A' 同时过门,
85
+ // A' 跑完整条 purge、租户 B 用同一 uuid 重登记并跑完一次 run),慢的那只会按 session_id 把 **B 的**行删掉。
86
+ // 复读一次 canonical owner,不齐即整条中止(一行不删、回 {deleted:false} —— 属于调用方的那个化身已经不在)。
87
+ // 这一读是**所有后端**都有的那道围栏;SQL 双生在 store 事务里还有一道 `session_meta … FOR UPDATE` 的
88
+ // 原子复核(那才是真正消灭竞态的一道),local 车道靠它 + 数据根独占 BootLock。
89
+ // 「行不在」(undefined)与「换了主」一律中止(codex R2 采纳):meta 不在 = 调用方验过权的那个化身
90
+ // 已经没了,再按 session_id 往下删,删到的只可能是**别人**重新登记后写的行。E21 的次序契约保证
91
+ // meta 是**最后**删的(子腿失败就不删 meta,让幂等重试收敛)⇒ 「meta 不在却还有本会话的孤儿子行」
92
+ // 在协调器这条路上构造不出来,中止不会丢掉任何该删的东西。
93
+ // ⚠️ `ownerOf` **方法缺席**的后端(可选 seam)与「行不在」必须分开判:合起来会让那些后端的删除
94
+ // 整条恒中止(fail-closed 到把功能关掉),那是另一种缺陷。
95
+ if (ownerAware.ownerOf) {
96
+ const stillOwner = await ownerAware.ownerOf(sessionId);
97
+ if (stillOwner !== owner)
98
+ return { deleted: false };
99
+ }
100
+ // 📌 **残余,如实登记**(codex R2 判真但**射程外**,不在本批修):本围栏钉住的是「purge 开始那一刻
101
+ // 化身是对的」+ run 腿的事务内原子复核。其余按 session_id 单键删的腿(anchors / exemption / policy /
102
+ // attachment / snapshot / tool_result 的 provenance 形)在**本批之前就是**这个 scoping(理由逐字见下方
103
+ // anchors 腿注,可追到公开快照那一版),它们各自开始的时刻理论上仍可能落在一次化身漂移之后。
104
+ // 要真正消灭它得让**整条**级联化身原子(SQL 单事务横跨十余张表 + 对象存储,即留存腿那个形),而在线
105
+ // 删除面刻意不是那个形:它要跨事务崩溃收敛(meta 最后删 ⇒ 幂等重试),还要动 MinIO 字节。那是设计级
106
+ // 改动(属主裁决 + 三问),不是复审批能顺手做的事;已作后续件登记,别把这条注读成「已解」。
77
107
  const runs = runStore ? await runStore.deleteBySession(sessionId, owner) : { removed: 0 };
78
108
  if ("active" in runs)
79
109
  return { active: runs.active };
@@ -163,7 +163,12 @@ export interface RouteLegs {
163
163
  body: object;
164
164
  }>;
165
165
  /** assistant plan_review 三态腿。 */
166
- resumePlanReview(sessionId: string, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined): Promise<{
166
+ resumePlanReview(sessionId: string, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined,
167
+ /** [4277]/[4288] 件5:`decide_404` 判别日志的附加位(只进日志,不上 wire;身份只记有无不记值)。 */
168
+ log?: {
169
+ taskId?: string;
170
+ principalPresent?: boolean;
171
+ }): Promise<{
167
172
  status: number;
168
173
  body: object;
169
174
  }>;
@@ -451,6 +451,12 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
451
451
  sendError(res, 401, "principal_unverified", "principal proof required");
452
452
  return;
453
453
  }
454
+ // [4268]/[4274] 件5(#43 P0 联调观测件):**只进日志,不上 wire**。两支 404 在 wire 面必须逐字同形
455
+ // (反枚举:「不存在」与「不属于你」不可区分是本门的要求,不是疏漏),代价是服务端自己也说不清撞的
456
+ // 是哪一支 —— 联调一次 404 只能靠猜。判别位放在服务端日志:运维/联调拿得到成因,枚举者拿不到。
457
+ // `principalPresent` 只记**有无**([4288]):principal / owner 两个身份串一个都不许进日志 ——
458
+ // 日志的读者面比 wire 宽,把值写进去等于在日志面重建一条本来被封住的枚举通道。
459
+ deps.logger?.info?.("decide_404", { reason: "no-row", taskId, principalPresent: principal !== undefined });
454
460
  sendError(res, 404, "not_found.run", "task not found");
455
461
  return;
456
462
  }
@@ -472,10 +478,16 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
472
478
  const operators = deps.config.operatorPrincipals;
473
479
  const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
474
480
  if (!explicitOperator && run.owner !== null && run.owner !== deciderPrincipal) {
481
+ // no existence oracle (verified principal on a direct worker) —— wire 面与上面的「查无此行」逐字同形;
482
+ // 判别位同样只进服务端日志(件5,理由见上一处注)。刻意**不记** owner/principal 两个值:判别成因用
483
+ // reason 一个词就够,把两个身份写进日志等于在日志面重建一条本来被封住的枚举通道。
484
+ deps.logger?.info?.("decide_404", { reason: "owner-mismatch", taskId, principalPresent: principal !== undefined });
475
485
  sendError(res, 404, "not_found.run", "task not found");
476
486
  return;
477
- } // no existence oracle (verified principal on a direct worker)
478
- const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req);
487
+ }
488
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req,
489
+ // [4277]/[4288] 件5:让四个 reason 的判别日志共用同一个 id 字段 + 同一个 principalPresent 口径。
490
+ { taskId, principalPresent: principal !== undefined });
479
491
  sendJson(res, out.status, out.body);
480
492
  return;
481
493
  }
@@ -1,4 +1,4 @@
1
- import { isUuidV7 } from "../../security.js";
1
+ import { isUuidV7, legacyUnownedAmnestyOpen } from "../../security.js";
2
2
  import { sendJson, sendError } from "../send.js";
3
3
  export async function handleSessionsList(req, res, url, ctx) {
4
4
  const miss = { fell: false };
@@ -30,7 +30,11 @@ async function handleSessionsListBody(req, res, url, ctx, miss) {
30
30
  sendError(res, 501, "capability.session_store_required", "session list requires a durable session store (SESSION_BACKEND=tidb)");
31
31
  return;
32
32
  }
33
- await handleSessionList(res, lister, new URL(req.url ?? "", "http://x").searchParams, gateOwner);
33
+ // [4198] 窄互认第二臂(列表面)—— 条件①③ 在此判,条件②(`owner === null`) store 的
34
+ // `owner = ? OR owner IS NULL` 谓词承载(见 legacyUnownedAmnestyOpen 的注)。fleet-wide 面不经本臂:
35
+ // 它本来就不钉 owner ⇒ 无主行早已在结果集内,多带位是噪声。
36
+ const includeUnowned = !fleetWide && legacyUnownedAmnestyOpen(deps.config.requirePrincipal, gateOwner);
37
+ await handleSessionList(res, lister, new URL(req.url ?? "", "http://x").searchParams, gateOwner, includeUnowned);
34
38
  return;
35
39
  }
36
40
  // GET /v1/sendfile-links — the SendUserFile ledger's thin read face (multi-tenant governance: which links
@@ -130,7 +134,7 @@ function decodeSessionCursor(s) {
130
134
  return undefined;
131
135
  }
132
136
  }
133
- async function handleSessionList(res, lister, query, forceOwner) {
137
+ async function handleSessionList(res, lister, query, forceOwner, includeUnowned = false) {
134
138
  const owner = forceOwner != null ? forceOwner : (query.get("owner") ?? undefined);
135
139
  const limit = Math.min(100, Math.max(1, Number(query.get("limit") ?? 50) || 50));
136
140
  const cursor = decodeSessionCursor(query.get("cursor"));
@@ -138,7 +142,7 @@ async function handleSessionList(res, lister, query, forceOwner) {
138
142
  // synthesized preview). Trimmed + bounded (256 — a search box, not a payload); empty after trim = no filter.
139
143
  const qRaw = (query.get("q") ?? "").trim();
140
144
  const q = qRaw ? qRaw.slice(0, 256) : undefined;
141
- const rows = await lister({ ...(owner ? { owner } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
145
+ const rows = await lister({ ...(owner ? { owner } : {}), ...(includeUnowned ? { includeUnowned: true } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
142
146
  const hasMore = rows.length > limit;
143
147
  const page = hasMore ? rows.slice(0, limit) : rows;
144
148
  const last = page[page.length - 1];
@@ -1,6 +1,6 @@
1
1
  import { materializeA2aTools, materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
2
2
  import { a2aForScenario, mcpForScenario } from "../../config-center/facade.js";
3
- import { isUuidV7, isUuidShape } from "../../security.js";
3
+ import { isUuidV7, isUuidShape, legacyUnownedSessionOk } from "../../security.js";
4
4
  import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
6
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
@@ -602,13 +602,16 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
602
602
  }
603
603
  // Owner gate (no existence oracle for non-owners — 404, parity with runOwnerOk): a principal caller may
604
604
  // only fork its OWN session; a fleet-wide (system/trace) caller may fork any (ops).
605
- if (!scope.fleetWide && owner !== scope.gateOwner) {
605
+ // [4198] 窄互认第二臂:单机形下 default 主对**存量无主行**放行(三条件齐,见 legacyUnownedSessionOk)
606
+ if (!scope.fleetWide && owner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, owner)) {
606
607
  sendError(res, 404, "not_found.session", "session not found");
607
608
  return;
608
609
  }
609
610
  // New-session ownership — DELIBERATE (no security issue either way; documented per adversarial-review):
610
611
  // • principal caller → the NEW session is owned by the CALLER (scope.gateOwner): you fork your own session,
611
612
  // you own the copy.
613
+ // • [4198] 窄臂放行的 default 主 → 走 principal 支 ⇒ 副本铸给 `default`(具名行)。刻意如此:副本
614
+ // 离开无主池,本臂**不被 fork 传播延寿**,墓碑注(存量随生命周期消亡后可整删)仍然成立。
612
615
  // • fleet-wide (ops, fork-on-behalf) → the NEW session INHERITS the SOURCE owner (`owner ?? null`) so the
613
616
  // fork is TENANT-owned (an ops/system fork-on-behalf hands the copy to the same tenant as the source,
614
617
  // NOT to the ownerless/system pool) — a later attach is then governed by the normal authorizer for that
@@ -658,7 +661,9 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
658
661
  // Foreign-owner ⇒ the SAME response as missing (a 404 here vs 200 above was an existence
659
662
  // oracle — a prober could distinguish "absent" from "another tenant's"). deleted:false is also honest
660
663
  // (nothing was deleted) and keeps the route idempotent-shaped for both cases.
661
- if (!scope.fleetWide && owner !== scope.gateOwner) {
664
+ // [4198] 窄互认第二臂:不放行时,存量无主会话对 default 主是**静默假成功**(用户永远删不掉自己的历史)
665
+ // 放行后 purge 的 owner 守卫仍传**行的真 owner**(下面的 `owner`,即 null)——SQL 侧 null-safe 属主门不被放宽。
666
+ if (!scope.fleetWide && owner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, owner)) {
662
667
  sendJson(res, 200, { deleted: false });
663
668
  return;
664
669
  }
@@ -799,10 +804,11 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
799
804
  return;
800
805
  }
801
806
  const wsOwner = await ownerOf(wsSession);
802
- if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner)) {
807
+ // PULL 门:必须存在且属己,无 oracle。[4198] 窄互认第二臂同样覆盖本读面(存量会话的工作区浏览)
808
+ if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, wsOwner))) {
803
809
  sendError(res, 404, "not_found.session", "session not found");
804
810
  return;
805
- } // PULL 门:必须存在且属己,无 oracle
811
+ }
806
812
  const q = new URL(req.url ?? "", "http://x").searchParams;
807
813
  const keys = (await fs.listKeys(wsSession)).sort().reverse(); // uuidv7 entryId:字典序 desc = 时间序 新→旧
808
814
  const sub = wm[3];
@@ -21,6 +21,7 @@ import { HANDS_BAND_TOOL_NAMES } from "../capabilities/hands-lane.js";
21
21
  import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../fleet/fleet-bus.js";
22
22
  import { defaultSubagentTailBus, projectTailFrame } from "../fleet/subagent-tail-bus.js";
23
23
  import { createApprovalCardEmitter, isApprovalGateKind, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
24
+ import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
24
25
  import {} from "../observability/rate-limit.js";
25
26
  import { withPrincipal } from "../observability/principal-context.js";
26
27
  import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
@@ -2775,11 +2776,26 @@ export function createHttpServer(rawDeps) {
2775
2776
  * suspend (that would bypass the correct /decide allow-deny + action binding). Rejects PRE-CAS so a wrong-gate
2776
2777
  * call never consumes the checkpoint (core's resume gate-match is the fail-closed backstop).
2777
2778
  */
2778
- async function resumePlanReview(sessionId, decision, editedPlan, reason, req) {
2779
+ async function resumePlanReview(sessionId, decision, editedPlan, reason, req,
2780
+ /** [4277]/[4288] 件5:判别日志的两个附加位。调用方(plan_review 端点)手上有 taskId 与 gatedPrincipal,
2781
+ * 本函数只有 sessionId —— 穿进来是为了让 `decide_404` 的**四个 reason 共用同一套字段**(联调 grep 一条
2782
+ * msg 就拿到同一个 id 键与同一个 principalPresent 口径,不必按 reason 分两种形)。
2783
+ * 缺席 ⇒ id 退回 sessionId 并如实换键名(不假装那是 taskId)。 */
2784
+ log) {
2779
2785
  const cs = deps.checkpointStore;
2786
+ // [4268]/[4274]/[4277]/[4288] 件5:404 成因判别位——**只进服务端日志,不上 wire**(响应体一字不改,
2787
+ // 反枚举不破)。词表四值全在本函数与 approvals-assistant.ts 两处铸,合起来覆盖 plan_review 一条决裁
2788
+ // 路径的全部 404 出口。`principalPresent` 只记**有无**:身份串一个都不许进日志(日志读者面比 wire 宽)。
2789
+ const log404 = (r) => deps.logger?.info?.("decide_404", {
2790
+ reason: r,
2791
+ ...(log?.taskId !== undefined ? { taskId: log.taskId } : { sessionId }),
2792
+ ...(log?.principalPresent !== undefined ? { principalPresent: log.principalPresent } : {}),
2793
+ });
2780
2794
  const token = await cs.findPendingTokenBySession(sessionId);
2781
- if (!token)
2795
+ if (!token) {
2796
+ log404("no-pending-checkpoint");
2782
2797
  return { status: 404, body: { error: "no pending plan_review for this session (already decided or expired)", errorCode: "not_found.plan_review" } };
2798
+ }
2783
2799
  let cp;
2784
2800
  try {
2785
2801
  cp = await cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
@@ -2787,8 +2803,10 @@ export function createHttpServer(rawDeps) {
2787
2803
  catch (e) {
2788
2804
  return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
2789
2805
  }
2790
- if (!cp)
2806
+ if (!cp) {
2807
+ log404("checkpoint-missing");
2791
2808
  return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
2809
+ }
2792
2810
  // 🔴 GATE-KIND GUARD: a plan_review decision must ONLY resolve a plan_review gate. A human/irreversible_ask
2793
2811
  // (tool approval) or resource_limit/needs_review gate routed here would let approve/edit/reject bypass the
2794
2812
  // correct wire (a tool approval needs /decide's allow/deny + action binding; resource_limit needs /resume).
@@ -3080,10 +3098,33 @@ export function createHttpServer(rawDeps) {
3080
3098
  * pending and is retried next tick, with the terminal_at_ms backstop as the eventual abort if the deny never
3081
3099
  * succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
3082
3100
  */
3101
+ const denySweepClockGuard = createWallClockJumpGuard({
3102
+ slaMs: deps.config.approvalTimeoutSec > 0 ? deps.config.approvalTimeoutSec * 1000 : undefined,
3103
+ onJump: ({ jumpMs, graceMs }) => deps.logger?.warn?.("deny_sweep_wall_clock_jump", {
3104
+ jumpMs,
3105
+ graceMs,
3106
+ note: jumpMs > 0
3107
+ ? "the wall clock stepped FORWARD relative to the monotonic clock; the approval SLA deny-sweep stands down for graceMs of MONOTONIC time so no pending gate is denied earlier than one full SLA of REAL time ([4232]①/[4235]). The checkpoint terminal_at_ms backstop is unaffected — nothing can hang forever."
3108
+ : "the wall clock stepped BACKWARD relative to the monotonic clock; the deny-sweep needs no grace for this direction (a receding deadline only DELAYS a deny) but the step itself is an operational fact worth recording.",
3109
+ }),
3110
+ onSuppressed: ({ remainingMs }) => deps.logger?.info?.("deny_sweep_clock_grace", { remainingMs }),
3111
+ // codex R2 [high] 五:连续跳变撞上累计上限 ⇒ 守卫**响亮**恢复判定。这一行是运维必须看见的:
3112
+ // 它说的是「本机墙钟在持续抖,我不再为它让路了,接下来的 deny 判定基于一个不稳的钟」。
3113
+ onGraceCapped: ({ totalGraceMs }) => deps.logger?.warn?.("deny_sweep_clock_grace_capped", {
3114
+ totalGraceMs,
3115
+ note: "the wall clock kept stepping forward; the approval SLA deny-sweep has stood down for the maximum cumulative grace and now RESUMES on a demonstrably unstable clock — a fail-closed leg that never runs is worse than one running on a bad clock. Fix NTP on this host.",
3116
+ }),
3117
+ });
3083
3118
  async function denyExpiredApprovals(now) {
3084
3119
  const cs = deps.checkpointStore;
3085
3120
  if (!cs)
3086
3121
  return;
3122
+ // [4232]①/[4235]:**判定**钟的单调佐证。`now` 是墙钟(生产由 reaper 传 `Date.now()`),而
3123
+ // `checkpoint.deadline` 是 core 按铸卡当时的墙钟算的绝对时刻 —— 墙钟凭空前跳 ⇒ 判定钟凭空多走同样的量,
3124
+ // 人还没走开窗就没了(真机实测:SLA 45s,真实等 14s 即被自动拒)。守卫按单调钟宽限整拍让开;
3125
+ // **记账钟一律不动**(下面 resumeCheckpoint 的审计/时间戳仍是真墙钟,core 的 totalWaitMs 账面同样不碰)。
3126
+ if (!denySweepClockGuard.admit(now))
3127
+ return;
3087
3128
  const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
3088
3129
  // [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at_ms 窗内
3089
3130
  // 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
@@ -9,6 +9,10 @@ export interface FailOpenTagEntry {
9
9
  }
10
10
  /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
11
11
  export declare const FAIL_OPEN_TAGS: {
12
+ readonly "server.run-store.file-hydrate-unreadable": {
13
+ readonly cls: "P-DEBT";
14
+ readonly note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。";
15
+ };
12
16
  readonly "server.hitl.frame-undelivered-stream-closed": {
13
17
  readonly cls: "P-DEBT";
14
18
  readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
@@ -32,6 +32,10 @@
32
32
  import { appendFileSync } from "node:fs";
33
33
  /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
34
34
  export const FAIL_OPEN_TAGS = {
35
+ "server.run-store.file-hydrate-unreadable": {
36
+ cls: "P-DEBT",
37
+ note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。",
38
+ },
35
39
  "server.hitl.frame-undelivered-stream-closed": {
36
40
  cls: "P-DEBT",
37
41
  note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
@@ -40,6 +40,7 @@ export declare class CachingSessionStore implements SessionStore {
40
40
  * warm cache by design; a fork/delete must read/write durable state, not a possibly-stale cached view). */
41
41
  readonly listSessions?: (opts: {
42
42
  owner?: string;
43
+ includeUnowned?: boolean;
43
44
  cursor?: {
44
45
  lastActivityAt: string;
45
46
  sessionId: string;
@@ -53,7 +53,11 @@ export declare class FileRunStore {
53
53
  private readonly claimOwner;
54
54
  /** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
55
55
  private readonly claimLocks;
56
- constructor(root: string);
56
+ /** 件6(#43 案 [4281]):启动期告警座(可选)。既有调用方不传 ⇒ 只走 fail-open 遥测那一半,不炸。 */
57
+ private readonly logger;
58
+ constructor(root: string, logger?: {
59
+ error?(msg: string, fields?: Record<string, unknown>): void;
60
+ });
57
61
  private runDir;
58
62
  private runJsonPath;
59
63
  private eventsPath;
@@ -118,6 +122,7 @@ export declare class FileRunStore {
118
122
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
119
123
  listSessions(opts: {
120
124
  owner?: string;
125
+ includeUnowned?: boolean;
121
126
  cursor?: {
122
127
  lastActivityAt: string;
123
128
  sessionId: string;
@@ -125,8 +130,11 @@ export declare class FileRunStore {
125
130
  limit: number;
126
131
  q?: string;
127
132
  }): Promise<SessionSummary[]>;
128
- /** E21 (§0.5 delete) — purge the runs ledger for one session, owner-guarded; aborts (returns {active}) if a run is live. */
129
- deleteBySession(sessionId: string, owner: string | null): Promise<{
133
+ /** E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
134
+ * submitting principal and diverges from the session owner full 判据 see the SQL twin's head-note, F-A);
135
+ * aborts (returns {active}) if a run is live。`expectedSessionOwner` = SQL 双生的化身围栏入参,本孪生执行不了
136
+ * (账本里没有 session_meta),理由与替代围栏逐字见 {@link MemoryRunStore.deleteBySession}。 */
137
+ deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
130
138
  removed: number;
131
139
  } | {
132
140
  active: string;
@@ -104,7 +104,10 @@ export class FileRunStore {
104
104
  claimOwner = { nonce: randomBytes(12).toString("hex"), ...PROCESS_FACTS };
105
105
  /** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
106
106
  claimLocks = new Map();
107
- constructor(root) {
107
+ /** 件6(#43 案 [4281]):启动期告警座(可选)。既有调用方不传 ⇒ 只走 fail-open 遥测那一半,不炸。 */
108
+ logger;
109
+ constructor(root, logger) {
110
+ this.logger = logger;
108
111
  this.runsDir = join(root, "runs");
109
112
  this.activeDir = join(root, "runs", "active");
110
113
  this.tmpDir = join(root, "tmp"); // atomicWriteFile temp dir (same FS as runs → rename is atomic)
@@ -160,7 +163,20 @@ export class FileRunStore {
160
163
  try {
161
164
  entries = readdirSync(this.runsDir);
162
165
  }
163
- catch {
166
+ catch (err) {
167
+ // 🔴 件6(#43 案 [4281],静默降级纪律):旧形是**空 catch + return** —— data root 不可读时引擎以
168
+ // 空账起动、零告警,此后每一次 getRun 恒 miss,而「恒 miss」正是 #43 现场看到的形。方向错在这里:
169
+ // 吞掉之后被读成的是「**证不出有行 ⇒ 没有行**」,而这两句话不等价,一次环境故障就此被伪装成
170
+ // 「本来就没有这条 run」。
171
+ // **行为不变**(仍不拒启 —— 拒启是行为变更,另立),只从静默变响亮:一行 error 级日志带 runs 目录
172
+ // **绝对路径** + 异常文案(运维一眼能判是权限还是路径),外加登记进 fail-open 遥测让这条债显形。
173
+ const detail = err instanceof Error ? err.message : String(err);
174
+ this.logger?.error?.("file_run_store_hydrate_unreadable", {
175
+ runsDir: this.runsDir,
176
+ error: detail,
177
+ note: "the file run-store booted with an EMPTY ledger because its runs/ directory could not be listed — every getRun will miss until this is fixed. This is NOT 'no such run'; check permissions/mount on the path above.",
178
+ });
179
+ recordFailOpen("server.run-store.file-hydrate-unreadable", detail);
164
180
  return;
165
181
  }
166
182
  for (const name of entries) {
@@ -493,8 +509,9 @@ export class FileRunStore {
493
509
  async listSessions(opts) {
494
510
  const bySession = new Map();
495
511
  for (const r of this.runs.values()) {
496
- if (opts.owner !== undefined && r.owner !== opts.owner)
497
- continue; // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10)
512
+ // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10). `includeUnowned` = [4198] 窄臂第三条件(SQL `OR owner IS NULL`)
513
+ if (opts.owner !== undefined && r.owner !== opts.owner && !(opts.includeUnowned === true && r.owner === null))
514
+ continue;
498
515
  const arr = bySession.get(r.sessionId) ?? [];
499
516
  arr.push(r);
500
517
  bySession.set(r.sessionId, arr);
@@ -535,14 +552,18 @@ export class FileRunStore {
535
552
  .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt) || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0))
536
553
  .slice(0, opts.limit);
537
554
  }
538
- /** E21 (§0.5 delete) — purge the runs ledger for one session, owner-guarded; aborts (returns {active}) if a run is live. */
539
- async deleteBySession(sessionId, owner) {
555
+ /** E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
556
+ * submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
557
+ * aborts (returns {active}) if a run is live。`expectedSessionOwner` = SQL 双生的化身围栏入参,本孪生执行不了
558
+ * (账本里没有 session_meta),理由与替代围栏逐字见 {@link MemoryRunStore.deleteBySession}。 */
559
+ async deleteBySession(sessionId, expectedSessionOwner) {
560
+ void expectedSessionOwner; // 见上:围栏在协调器那一侧(local 无 session_meta)
540
561
  const claimed = this.active.get(sessionId);
541
562
  if (claimed !== undefined)
542
563
  return { active: claimed };
543
564
  let removed = 0;
544
565
  for (const [taskId, r] of [...this.runs]) {
545
- if (r.sessionId !== sessionId || !ownerEq(r.owner, owner))
566
+ if (r.sessionId !== sessionId)
546
567
  continue;
547
568
  this.dropRun(taskId);
548
569
  removed += 1;
@@ -161,6 +161,8 @@ export declare class LocalSessionStore implements SessionStore {
161
161
  */
162
162
  listSessions(opts: {
163
163
  owner?: string;
164
+ /** [4198] 窄互认第二臂的第三条件(SQL 孪生里是 `owner IS NULL` 谓词;此处是同义的 `owner === null` 放行)。 */
165
+ includeUnowned?: boolean;
164
166
  cursor?: {
165
167
  lastActivityAt: string;
166
168
  sessionId: string;
@@ -443,8 +443,8 @@ export class LocalSessionStore {
443
443
  const all = [];
444
444
  for (const meta of metas) {
445
445
  const owner = this.owners.has(meta.id) ? (this.owners.get(meta.id) ?? null) : null;
446
- if (opts.owner !== undefined && owner !== opts.owner)
447
- continue; // owner-scope (best-effort, process-local map)
446
+ if (opts.owner !== undefined && owner !== opts.owner && !(opts.includeUnowned === true && owner === null))
447
+ continue; // owner-scope (best-effort, process-local map) + [4198] 窄臂第三条件
448
448
  const firstActivityAt = meta.createdAt || new Date(0).toISOString();
449
449
  const lastActivityAt = this.lastActivity.get(meta.id) ?? firstActivityAt;
450
450
  all.push({
@@ -90,6 +90,7 @@ export declare class MemoryRunStore {
90
90
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
91
91
  listSessions(opts: {
92
92
  owner?: string;
93
+ includeUnowned?: boolean;
93
94
  cursor?: {
94
95
  lastActivityAt: string;
95
96
  sessionId: string;
@@ -97,8 +98,18 @@ export declare class MemoryRunStore {
97
98
  limit: number;
98
99
  q?: string;
99
100
  }): Promise<SessionSummary[]>;
100
- /** E21 (§0.5 delete) — purge the runs ledger for one session, owner-guarded; aborts (returns {active}) if a run is live. */
101
- deleteBySession(sessionId: string, owner: string | null): Promise<{
101
+ /**
102
+ * E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
103
+ * submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
104
+ * aborts (returns {active}) if a run is live.
105
+ *
106
+ * `expectedSessionOwner` 是 SQL 双生的**化身围栏**入参(事务内复核 `session_meta.owner`,防「A 验权后 A' 删完、
107
+ * 别人用同一 uuid 重新登记」那一形)。本孪生**执行不了**它:local 车道的会话表在另一只店里,这只账本没有
108
+ * session_meta 可读 —— 参数只作契约对齐(签名不齐会让 RunStore 联合类型的调用点直接不可调)。local 的替代
109
+ * 围栏是两条:①协调器在本调用**紧前**复读一次 canonical owner(不齐即整条中止);②数据根的独占 BootLock
110
+ * 保证一个数据根恒一个进程,交错只可能发生在同进程的 await 缝里。这条不对称是**明写**的,不是遗漏。
111
+ */
112
+ deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
102
113
  removed: number;
103
114
  } | {
104
115
  active: string;
@@ -207,8 +207,9 @@ export class MemoryRunStore {
207
207
  async listSessions(opts) {
208
208
  const bySession = new Map();
209
209
  for (const r of this.runs.values()) {
210
- if (opts.owner !== undefined && r.owner !== opts.owner)
211
- continue; // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10)
210
+ // exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10). `includeUnowned` = [4198] 窄臂第三条件(SQL `OR owner IS NULL`)
211
+ if (opts.owner !== undefined && r.owner !== opts.owner && !(opts.includeUnowned === true && r.owner === null))
212
+ continue;
212
213
  const arr = bySession.get(r.sessionId) ?? [];
213
214
  arr.push(r);
214
215
  bySession.set(r.sessionId, arr);
@@ -247,14 +248,25 @@ export class MemoryRunStore {
247
248
  .sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt) || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0))
248
249
  .slice(0, opts.limit);
249
250
  }
250
- /** E21 (§0.5 delete) — purge the runs ledger for one session, owner-guarded; aborts (returns {active}) if a run is live. */
251
- async deleteBySession(sessionId, owner) {
251
+ /**
252
+ * E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
253
+ * submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
254
+ * aborts (returns {active}) if a run is live.
255
+ *
256
+ * `expectedSessionOwner` 是 SQL 双生的**化身围栏**入参(事务内复核 `session_meta.owner`,防「A 验权后 A' 删完、
257
+ * 别人用同一 uuid 重新登记」那一形)。本孪生**执行不了**它:local 车道的会话表在另一只店里,这只账本没有
258
+ * session_meta 可读 —— 参数只作契约对齐(签名不齐会让 RunStore 联合类型的调用点直接不可调)。local 的替代
259
+ * 围栏是两条:①协调器在本调用**紧前**复读一次 canonical owner(不齐即整条中止);②数据根的独占 BootLock
260
+ * 保证一个数据根恒一个进程,交错只可能发生在同进程的 await 缝里。这条不对称是**明写**的,不是遗漏。
261
+ */
262
+ async deleteBySession(sessionId, expectedSessionOwner) {
263
+ void expectedSessionOwner; // 见上:本孪生无 session_meta 可复核,围栏在协调器那一侧
252
264
  const claimed = this.active.get(sessionId);
253
265
  if (claimed !== undefined)
254
266
  return { active: claimed };
255
267
  let removed = 0;
256
268
  for (const [taskId, r] of [...this.runs]) {
257
- if (r.sessionId !== sessionId || !ownerEq(r.owner, owner))
269
+ if (r.sessionId !== sessionId)
258
270
  continue;
259
271
  this.events.delete(taskId);
260
272
  this.runs.delete(taskId);
@@ -1,5 +1,5 @@
1
1
  import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope } from "@sema-agent/core";
2
- import { type RuleImportTicket, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
2
+ import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
3
3
  import type { PermissionRuleStoreBundle } from "../rules-consent.js";
4
4
  /**
5
5
  * `RuleApprovalRecordStore` 的 File 形。
@@ -52,10 +52,10 @@ export declare class FileRuleImportTicketStore {
52
52
  }): Promise<RuleImportTicket>;
53
53
  /** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
54
54
  * 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
55
- consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
55
+ consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
56
56
  /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
57
57
  * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
58
- release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
58
+ release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
59
59
  }
60
60
  /** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
61
61
  export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
@@ -294,7 +294,7 @@ export class FileRuleImportTicketStore {
294
294
  }
295
295
  /** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
296
296
  * 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
297
- async consume(ticketId, principal) {
297
+ async consume(ticketId, principal, opts) {
298
298
  this.gate.assertUsable();
299
299
  const row = this.rows.get(ticketId);
300
300
  if (row === undefined)
@@ -303,21 +303,22 @@ export class FileRuleImportTicketStore {
303
303
  return { ok: false, reason: "wrong-principal" };
304
304
  if (row.consumedAtMs !== undefined)
305
305
  return { ok: false, reason: "consumed" };
306
- const nowMs = this.now();
307
- if (row.expiresAtMs <= nowMs)
306
+ // [4232]③:判定钟(可被同意车道的单调锚覆写)与记账钟(恒真墙钟,写进日志/行)分家 —— 逐字同 SQL 形。
307
+ const stampMs = this.now();
308
+ if (row.expiresAtMs <= (opts?.decisionNowMs ?? stampMs))
308
309
  return { ok: false, reason: "expired" };
309
- this.log.append({ op: "consume", ticketId, atMs: nowMs }, true);
310
- row.consumedAtMs = nowMs;
310
+ this.log.append({ op: "consume", ticketId, atMs: stampMs }, true);
311
+ row.consumedAtMs = stampMs;
311
312
  return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
312
313
  }
313
314
  /** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
314
315
  * (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
315
- async release(ticketId, principal, mustRemainValidMs = 0) {
316
+ async release(ticketId, principal, mustRemainValidMs = 0, opts) {
316
317
  this.gate.assertUsable();
317
318
  const row = this.rows.get(ticketId);
318
319
  if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined)
319
320
  return false;
320
- if (row.expiresAtMs <= this.now() + mustRemainValidMs)
321
+ if (row.expiresAtMs <= (opts?.decisionNowMs ?? this.now()) + mustRemainValidMs)
321
322
  return false;
322
323
  this.log.append({ op: "release", ticketId }, true);
323
324
  delete row.consumedAtMs;
@@ -111,6 +111,28 @@ export type RuleTicketRedeemResult = {
111
111
  ok: false;
112
112
  reason: "unknown" | "wrong-principal" | "expired" | "consumed";
113
113
  };
114
+ /**
115
+ * [4232]③/[4235]③ —— 票的**判定钟**覆写(记账钟永远不覆写,见 `consume` 内注)。
116
+ *
117
+ * 缺席 = 墙钟,既有行为逐字不变。在场 = 调用方(同意车道)按**单调锚**投影出来的那个时刻:
118
+ * 铸票时刻的墙钟 + 自铸票起单调钟走过的量。它只能让判定更贴近**真实经过的时间**,给不了票额外寿命
119
+ * (投影按单调钟前进,走满 TTL 就必然越过 `expires_at_ms`)。
120
+ */
121
+ export interface RuleTicketDecisionClock {
122
+ decisionNowMs?: number;
123
+ }
124
+ /**
125
+ * [4232]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
126
+ *
127
+ * 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
128
+ * 若 GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
129
+ * 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [4232]③ 的真实构造)。
130
+ *
131
+ * 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
132
+ * 存单调基准,而单调基准跨进程无意义(core [4235] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
133
+ * ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
134
+ */
135
+ export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
114
136
  /** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
115
137
  export declare function buildRulePayloadHash(candidates: ReadonlyArray<{
116
138
  rule: string;
@@ -146,7 +168,7 @@ export declare class SqlRuleImportTicketStore {
146
168
  * expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
147
169
  * 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
148
170
  */
149
- consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
171
+ consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
150
172
  /**
151
173
  * **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
152
174
  *
@@ -163,7 +185,7 @@ export declare class SqlRuleImportTicketStore {
163
185
  * 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
164
186
  * 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
165
187
  */
166
- release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
188
+ release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
167
189
  private readRow;
168
190
  }
169
191
  /**