/** * [ref] 裁 (c) **审计半场**([ref]①;设计稿 = sema-internal * `server/designs/2026-08-31-s38-local-stream-approval-v1.md`,v1 三臂 + v1.1 §6 两件增益)—— * local 车道(`DB_BACKEND=local`)的 **File ask 审计店**。SINGLE-FILE 店([ref] A12 单文件店纪律, * 形照 `plugins/memory-optout-grant-store-sql.ts`:一个实现文件、数据形/偏离/坏值姿态全部成文在头注; * 崩溃安全机制照 `orchestration/workflow-notify-journal.ts` 的 File 形:append-only JSONL、fsync 逐行、 * 重放容忍撕尾行)。 * * ## 它治的病([ref] / [ref]① 车定界钉;⚠️ **病因描述是 [ref] 当时的事实,S-384 起已变**) * 当时:local 车道 ask 账进程内易失(`InMemoryApprovalAskStore`),活卡窗内崩溃 = **零痕迹**:重启后 * `/v1/approvals` 两数组空,那次审批连一条审计行都没有。**S-384 起 local 的 ask 账已是持久的** * (`FileApprovalAskStore`),所以「零痕迹」那句不再成立;本店今天还在的理由收窄成它**独有**的那一格: * `crashConverged` 这个 local-only 的 additive 读面(孤儿行的 `orphanState` / `resumeSafe` 两位), * ask 账本身没有「boot 收敛孤儿」这条动词。两本同根账并存是已登记的抽象欠账。本店把 ask **铸造 / 决议 / 腿闭**三类事件 * append 进磁盘账本(挂线点 = `ToolApprovalCoordinator`,见 `askAudit` ctor opt),boot 时收敛器把 * 孤儿行标 `crashed_before_park` 终态成因注,`GET /v1/approvals` 的 additive 键 `crashConverged` 供 * 操作员追溯。 * * ## 它**不**做的事(裁 (c) 的边界,与稿 §1 (a) 臂的语义论证同源) * - **不翻 `streamApproval` 能力位**([ref] 当时的裁 (c);⚠️ **这一位已由 S-384 翻真,不是被本店翻的**): * 当年不翻的理由是「该位在 SQL 车道隐含 durable park 兜底,local 给不了」+「local 的 ask 账是进程内 * 易失形」。后半条已由 `FileApprovalAskStore` 消掉(持久账落地);前半条的答案一直在谓词里 —— * `resolveStreamApprovalGate` 的 `parkFacility` 合取项要求 `backend.checkpoint` 在场 ∧ `DURABLE_APPROVAL`, * 所以位报 true 的 local 部署**必然**配齐了 File 形 checkpoint 店(`durable-approval-local-e2e` 第一格 * 实证:park 行跨双 kill 存活、重启后 decide 驱动 resume 真跑完)。本店与那一位自此无关:它治的是 * **活卡窗内崩溃**(park 还没落盘)那一格的零痕迹病,而那一格无论位真位假都不复活 run。 * - **不复活 run**:审计行不是 park 行,崩掉的 ask 不进 pending/livePending。恢复闭环真形 = * core resume 既有补偿腿(悬空 tool_use 闭合「中断,工具未执行」⇒ 模型自然重发重弹卡),读面用 * `resumeSafe` 位指路(v1.1 §6 第二件)。 * * ## 账本形(closed set,`kind` 判别;每行一个 JSON 对象 + '\n',fsync 逐行) * `ask` 铸造点(呈卡帧 register 后、emit 前):wire approvalId / 出处 taskId / owner / * toolName / `legTracked`(出处腿是否在协调器 `runWithContext` 视野里 —— decided * 臂的收敛前提,见下)/ 窗死线 / 铸造时刻。 * `settle` 决议点(`settle()` 公共咽喉,五臂全过):outcome(allowed/denied/expired)+ * `parkRouted`(窗到期/断连等 park 路由臂如实标注 —— 该行的续命在 durable park 面, * 收敛器**不**把它当孤儿)+ 宿主自报(在场才带)。 * `leg_closed` 腿闭点(`runWithContext` finally;只为铸过审计行的 taskId 落,防账本膨胀): * 「决议之后腿有没有闭」是 decided 臂唯一的机械证据面 —— 没有它,「批完跑完」与 * 「批了、执行窗内崩」在账本字节上同形,收敛器只能二选一地撒谎。 * `crash_converged` 收敛记录(boot 收敛器落,幂等锚):orphanState 两值分臂。 * `late_settle` **迟到人工结算点**([ref];两条 HTTP decide 腿在**权威受理之后**落,受理谓词 = * `HTTP 200 ∧ errorCode ≠ "cancelled"`,[ref] 件②起 —— 见下): * 窗到期已转 durable park 的那条 ask,人**事后**在 * `POST /v1/approvals/:sessionId/decide`(或 [ref] 赎回席)上按下的允许/拒绝。 * 身份 = 从 checkpoint 锚反查到的 `approvalId`(反查不唯一 ⇒ `null` + `unresolved` * 词,**不静默**);行恒携 checkpoint 锚(`sessionId` / `toolCallId` / * `checkpointTokenHash`)供取证 join。判据见下段。 * 🔴 **落行时机**(codex R1-F1 采):不是 CAS 挂点,而是那条腿**返回 200 且 * `errorCode ≠ "cancelled"`** 之后([ref] 件② 把「只判 200」收窄成这条谓词)—— * CAS 只证明「行归我了」,core 的 pre-CAS 守卫(D-1 绑定 / answer 语义 / preflight) * 与 checkpoint resolve CAS 都在其后,赎回腿更要等 `execute` 才知道 drive 有没有真 * 发起。挂 CAS 点 ⇒ 一次被拒、什么都没放行的 decide 也留下「人批了且生效了」= * 伪造审计事实。判据属主 = `http/route-ctx.ts` 的 `DriveResumeArgs.acceptEarly` 顶注。 * 🔴 **这条谓词是有已知失准的近似,取证时别把它读成充要**([ref] R2 改真话;判据 * 全文在 `http/server.ts` `noteLateSettleIfAccepted` 顶注):core `checkpoint.resume_aborted` * 有 pre-CAS / post-CAS 两半铸点,wire 上同为 `200 cancelled`,分不开 ⇒ 本店取 * 「宁漏勿伪」,**post-CAS 的取消(决定已提交)也不落行**(契约格 [ref]-R2-d)。 * ⇒ 「没有 `late_settle` 行」只等于「本账本没记」,**不等于**「引擎没受理」—— * 要与 run 面/事件流对读,单凭缺行不得推出「没人批过」。根治在 core([ref])。 * * ## `expired ∧ parkRouted` ⇔ **窗到期已转 durable,非终局**([ref] 成文;[ref] 裁定①) * `settle` 行上的 `outcome:"expired"` 是**流窗**结算词(那一刻活卡的等待结束了),不是「这次调用被拒」; * 与它并存的 `parkRouted:true` 说的是「这条 ask 本身已转 durable park、行还在 `GET /v1/approvals` 的 * `pending[]` 里、`/decide` 回决得了」([ref] R-13 起的成文设计;WIRE 契约的锚 = `outcome: "expired"` 那一条 + `crashConverged` 段第 5 条。 * ⚠️ 锚**刻意不写行号**:[ref] 低危顺修查实原先的 `§915/§1209` 两个指针都已漂移——§1209 今天落在 * workflow `agents[]` 段,与本条毫无关系。行号指针在活文档里必漂,写锚文本)。两位并存的行因此是 * **非终局**的。后来会发生什么,账本能记的只有一半: * · 人**迟到**决议且被引擎**受理** ⇒ 本店的 `late_settle` 行(允许/拒绝 + 出处); * · 无人再来、durable 行被 reaper 收割 ⇒ checkpoint `pending→expired` CAS + run `failed('approval.expired')` * (`file-run-store.ts` / SQL 孪生;本店**不**记这一支 —— 它不经协调器,痕迹在 run 行/事件流上)。 * 🔴 **`late_settle` 说的是「受理」,不是「从此结束」**(codex [ref]-R2-F1 部分采,验真后改口):受理点 * 之后那条腿仍可能以 reopen 类失败告终(`resume.tool_unavailable` 等 ⇒ core 已消费 checkpoint 又重开 * park,HTTP 仍回 `200 {retriable:true}`,卡可以再决)。那种情况下**本行照留**(那次决定真的发生过、也 * 真的被引擎收下了),而下一次决议会**再追加一行** —— append-only 账本上同一 `approvalId` 出现多条 * `late_settle` 是合法且有信息的形,不是重复。腿本身的成败读 run 面/事件流,本店不答那个问题。 * 读账本的人据此不得把 `expired ∧ parkRouted` 读成「这次审批结束了」;没有后继 `late_settle` 也**不** * 等于没人批过(部署可能根本没装本店 —— SQL 车道恒无),只等于**本账本**没有那条人工痕迹。 * * ## boot 收敛判据(v1.1 §6 两分臂;行序 = 语义序,append-only 保证) * - 有 `ask` 无 `settle` ⇒ **pending 臂**:审批门在执行之前 ⇒ 工具零执行可机械证明 ⇒ * `resumeSafe:true`(壳可默认自动续跑)。 * - `settle.outcome === "allowed"` ∧ `ask.legTracked` ∧ 决议行之后无同 taskId 的 `leg_closed` ⇒ * **decided 臂**:批了、执行窗内崩,工具可能有半截副作用 ⇒ `resumeSafe:false`(留人工确认)。 * 判据方向刻意**保守**:腿闭是「本进程对该腿的守护结束」,晚于工具真完成 —— 误差方向是「把可能 * 干净的批注成待人工确认」,绝不反向。 * - **腿闭反证门**(codex R1-F1 部分采):有 `ask`、无 `settle`、却有本 taskId 的后继 `leg_closed` ⇒ * 这不是崩溃孤儿(settle 恒先于 ask() 返回、腿闭恒在其后),只能是「settle 行写丢在一条跑完了的 * 腿上」的审计断档组 —— **不收敛**(标 pending = 把可能已执行的批注成 resumeSafe:true,方向反; * 标 decided = 凭空断言人批了),`inconsistent` 计数进 boot 日志。 * - 其余(denied / expired / park 路由 / 已收敛)⇒ 不收敛。三条成文残留(fail-safe 方向,宁漏不误): * ① `legTracked:false` 的 decided 行拿不到腿闭信号 ⇒ decided 臂对它不收(pending 臂不受此限)。 * 两形同标 false:bg 腿(出处腿不在本协调器 runWithContext 视野)与**委派/bg 子代**(codex * R1-F2:继承链闭包携宿主身份,origin.taskId = 宿主 run,宿主腿退场的 leg_closed 会把「批了、 * 子代执行中崩」误反证成「跑完了」—— 铸造点 `!child` 合取如实标 false); * ② settle(expired, parkRouted) 与引擎真铸出 checkpoint 之间的毫秒窗内崩 ⇒ 行停在 park 路由态、 * durable 面无行 —— 审计痕迹仍在(比修前的零痕迹强一个量级),自动判别需 checkpoint 店 join, * 误判方向是把活的 park 行标成崩溃,比漏标更坏,故不做; * ③b **[ref] 同族残留**(codex [ref]-R1-F2 采后如实登记):`ask` / `settle(expired,parkRouted)` 的 * append 写丢时,那条事实**不进** late 反查索引(行的每一句断言都必须能被同一个账本文件复核) * ⇒ 审计盘坏窗内发生的迟到人工决议**无痕**。取舍方向与本店一贯一致:宁可少一行,不可写一条 * 盘上查无前置事实的「人批过」——那条行会被当证据引。坏窗本身由 append-failed 三件套显形。 * ⚠️ 由此带来一处**运行期/重放期不对称**(codex [ref]-R2-F2 验真,方向判为可接受故只成文不改): * 字节全落、只有 `fsync` 抛错的那一形,本进程按失败处理(不进索引),而那行事实上可能随下一次 * 成功的 fsync 一起落盘 —— 重启后重放会认它。⇒ 同一份账本「重启后比重启前认得更多前置事实」。 * 两边都**不会**写出无据的行(重放只认文件里真有的行),差别只在保守程度,故不为它引入提交协议 * (那要改动 append 这条被五种记录共用的路,风险大于收益); * 不对称本身登记 [ref]; * ③ **双故障窗**(codex R1-F1 的不可约核):settle(allowed) 的 append 写丢**且**进程在腿闭落盘前 * 崩 ⇒ 账本只剩 ask 行,pending 臂会把一只可能已执行的批收成 resumeSafe:true。腿闭反证门收掉 * 「盘恢复、腿跑完」的形;「盘持续坏 + 崩」的形在不阻塞审批于审计盘的前提下**不可约**(阻塞 = * 裁 (c) 明禁的行为面受伤),补偿 = append-failed 三件套让坏窗在遥测显形 + wire 契约明写 * 「resumeSafe 以账本完整为前提」。三条均登记 [ref]。 * 收敛记录本身 append + fsync ⇒ 幂等跨 boot(第二次 boot 读到 `crash_converged` 即不重收)。 * **`late_settle` 对收敛判据零影响**([ref] 硬约束):它被重放解析器**接受**(不计 `corruptLines`), * 但**不**进 {@link ReplayedGroup}(`settle` 取首条那条判据一个字不动)—— 一条 `expired ∧ parkRouted` * 的组无论后面跟不跟 `late_settle`,收敛结果逐字相同(两者都落在「其余 ⇒ 不收敛」那一支)。理由: * 收敛器答的是「上一世崩没崩、崩时工具跑没跑」,而 `late_settle` 答的是「人后来批没批」—— 把后者喂进 * 前者会让一条**活得好好的** park 行因为有人决过而改变孤儿判定。 * * ## `crashed_before_park` 与 FAIL-OPEN census 的关系(稿未闭环②,施工时裁定,成文于此) * **不入词表**:census 词表登记的是「走了兜底而外面看不出」的**臂**;`crashed_before_park` 是一条 * **终态成因注**(收敛器 fail-closed 地把孤儿收成 DENIED 同码并在读面显形),它的存在恰恰是响亮化 * 本身,不是静默降级。本店唯一的静默降级臂 = 运行期 append 失败(审批可用性不押在审计盘上),已按 * [ref] 律走 `recordFailOpen("server.approval-ask-audit.append-failed")`(census 登记行同批)。 * boot 期(mkdir/重放)失败则**拒启**(与 File 店族同律:`FileStorageBackend` ctor 同 root 同姿态)—— * 一台「审计写不进却继续批」的机器 = [ref] 换个皮回来。 * * ## 装配(为什么不在 `plugins/store-backend.ts`) * 本店是协调器侧的审计 sink,不是跨后端 store 契约的一员(SQL 车道有真 ask 店,永不装本店)—— * 装配点在 `boot/coordinators.ts`(判据:`toolApprovalEnabled` ∧ `backend.kind === "local"` ∧ * `config.localDataRoot` 在场;真实 boot 恒铸 `localDataRoot`,缺席只发生在 stub-harness 上,判据 * 理由照 `StreamApprovalGateInput.streamApprovalEnabled` 顶注的同款论证)。root 与 `LocalBackend` * 同源(`store-backend.ts:780` 的 `config.localDataRoot`),数据落 `/approval-ask-audit/`。 * * ## 体量与保留 * 只在真出卡时写(人批一次 = 3 行),量级 = 人的手速;账本只增不删,压缩是未来件(workflow-notify * journal 同款成文)。读面列表按收敛时刻新前旧后、上限 {@link CRASH_CONVERGED_LIST_MAX}(有界 wire)。 */ import type { HostDecision } from "./host-decision.js"; /** 数据子目录名(挂在 local 数据根下,与 `workflow-notify/` 同层同形)。 */ export declare const APPROVAL_ASK_AUDIT_DIR = "approval-ask-audit"; /** 崩溃收敛行的读面上限(新前旧后截断;有界 wire —— 读面不许随月份线性长)。 */ export declare const CRASH_CONVERGED_LIST_MAX = 200; /** 铸造点入参(协调器 askBroadcast 的 register-before-emit 之后挂线)。 */ export interface AuditAskInput { /** wire 身份 = 呈卡帧上那把 `approvalId`(respond/queue 同源可达;不是本地 pending 键)。 */ approvalId: string; /** 出处 run 的 taskId(`origin.taskId`,不是投递连接的)。 */ taskId: string; sourceTaskId?: string; sessionId?: string; owner: string | null; toolName: string; toolCallId?: string; /** 出处腿此刻是否在协调器 `runWithContext` 的广播目标里(⇒ 腿闭信号可达)。decided 臂的收敛前提。 */ legTracked: boolean; expiresAtMs: number; mintedAtMs: number; } /** 决议点入参(`settle()` 公共咽喉挂线;五臂全过,不漏窗到期/断连/取消)。 */ export interface AuditSettleInput { approvalId: string; outcome: "allowed" | "denied" | "expired"; /** 走了 park 路由(windowRouteUnavailable)—— 该行的续命在 durable park 面,收敛器不当孤儿。 */ parkRouted?: boolean; /** 宿主自报来源(HostSettledBy;在场才带)。收敛器不消费,纯审计。 */ settledBy?: string; settledAtMs: number; } /** * [ref] **迟到人工结算点**入参:调用方(两条 HTTP decide 腿)只交 **checkpoint 锚 + 这次结算的事实**, * `approvalId` 的反查与「算不算迟到」的判据**属主在店内**(一处成文,见 {@link FileApprovalAskAuditStore.recordLateSettle}) * —— 路由层没有账本索引,让它自己推会造出第二份判据。 */ export interface AuditLateSettleInput { /** checkpoint 锚①:被决议那条 durable 行的 sessionId(`/v1/approvals/:sessionId/decide` 的那把; * parked 子代形下 = **子会话 id**,与 ask 行的 `sourceTaskId` 同值)。 */ sessionId: string; /** checkpoint 锚②:`pendingAction.toolCallId`(tool_approval 门恒在场;别的审批门型可缺席)。 */ toolCallId?: string; /** checkpoint 锚③:resume 凭据的**哈希**({@link buildCheckpointAnchorHash};token 本身是 token-as-auth * 的能力凭据,**绝不**落审计盘 —— 账本 0o600 也不是把凭据写进去的理由)。 */ checkpointTokenHash?: string; /** 这次人工结算的结果(approve/deny 逐字映射;`expired` 不是本记录的合法值 —— 它是流窗的词)。 */ outcome: "allowed" | "denied"; /** * **谁结束了这次等待** —— 调用方陈述的事实,店**不派生**(集中推导正是「窗到期被报成另一个人拒绝」 * 的成因,[ref] 件6③ 同律)。`"person"` = 运维经审批通道做的决定;`"sla_timeout"` = 宿主自己的 SLA 扫 * 在行的 deadline 上判的。今天两条 HTTP decide 腿恒 `"person"`,但真值以调用方为准。 * * 🔴 S-136(core 7.6.0 [ref] S6-A):这一格此前记的是 core 的**结算词**(`ApprovalSettledBy`, * 已删)。改记**事实**而不是词,理由是 core 的 `@contract settlement.single_mint` 逐字写着「宿主永不 * 铸词」—— 账本要是记一个自己拼出来的 `human_refused`,server 就成了同一套词表的**第二个铸点**, * 而两个铸点的分歧一定是静默的。词的属主留在 core,取证要词就去读行的 `resolvedOutcome.gateOutcome` * 或那次调用的 `tool_end.gate`(同一条记录的两张脸);本账本记的是**本服务观察到的那半件事**。 */ decidedBy: HostDecision["decidedBy"]; /** 审批通道自报的**结算方标识**(登录名/邮箱/账号 id)。缺席 = 通道没报名字 —— **永不**读成 * 「没人批」或「有人批」。调用方已按 core 的 `screenApproverAttribution` 筛过形。 */ approver?: string; /** 记录时刻 = 那条腿**权威受理**的时刻(不是请求到达的时刻)—— 落行点在 200 之后,见记录种类表。 */ settledAtMs: number; } /** 协调器消费的窄写口(协调器只 import type,不拖实现依赖)。 */ export interface ApprovalAskAuditSink { recordAsk(input: AuditAskInput): void; recordSettle(input: AuditSettleInput): void; /** 腿闭点(`runWithContext` finally)。只为铸过审计行的 taskId 真落行(店内去重)。 */ noteLegClosed(taskId: string): void; /** [ref] 迟到人工结算点(HTTP decide 两腿)。**纯观察**:自吞一切失败,不返回受否,绝不改变结算面。 */ recordLateSettle(input: AuditLateSettleInput): void; } /** * [ref]:decide 两腿消费的**更窄写口** —— 只有迟到结算这一个动词。 * 刻意不把整个 {@link ApprovalAskAuditSink} 递给 HTTP 层:铸造 / 决议 / 腿闭三点的属主是协调器, * 路由层拿到它们只会长出第二个写者(与 {@link ApprovalAskAuditReadFace} 同一条窄口纪律)。 */ export type ApprovalLateSettleSink = Pick; /** * checkpoint resume 凭据 → 审计行可落的锚(sha256 前 16 hex)。 * 单向、不可逆、不足以拿去 resume;够用来把审计行 join 回 checkpoint 平面(取证时对手上的 token 现算)。 * 形与长度的唯一属主在此(两条腿都调它,不各自 `createHash`)。 */ export declare function buildCheckpointAnchorHash(token: string): string; /** v1.1 §6 两分臂(闭集)。 */ export type CrashOrphanState = "pending" | "decided"; /** `GET /v1/approvals` additive 键 `crashConverged` 的行形(wire 键集刻意窄:args/卡体不上列表, * 与 `livePending` 行同纪律 —— 不开第二个脱敏面)。 */ export interface CrashConvergedRow { approvalId: string; toolName: string; taskId: string; sessionId?: string; /** 铸造时刻(键名照 `livePending[].ts`)。 */ ts: number; expiresAtMs: number; /** 终态 DENIED 同码(稿 §2 (c) 施工形;SQL 车道收敛器的 fail-closed DENIED 同向)。 */ decision: "denied"; cause: "crashed_before_park"; orphanState: CrashOrphanState; /** decided 臂:人当时批的是 approve(这句话不许被终态同码吞掉)。 */ originalDecision?: "approve"; decidedAtMs?: number; convergedAtMs: number; /** v1.1 §6 指路位:pending 臂 = 工具零执行可机械证明 ⇒ re-run(core resume 补偿腿)安全,壳可 * 默认自动续跑;decided 臂 = 可能半截副作用 ⇒ 留人工确认。 */ resumeSafe: boolean; } /** 读面窄口(HTTP deps 消费;写口/收敛不外漏)。 */ export interface ApprovalAskAuditReadFace { listCrashConverged(scope: string | undefined): CrashConvergedRow[]; } /** * [ref]:`late_settle` 行上 `approvalId === null` 的**成因词**(闭集;缺席 = 反查唯一命中,身份是真的)。 * 「反查不到不得静默」:身份说不清的时候写的是「说不清 + 为什么」,不是不写、也不是编一个 id。 * 闭集纪律([ref] + 本店 `crash_converged.orphanState` 先例):重放读到**词表外**的值 ⇒ 整行折坏行计数, * 不折 `undefined` —— 折了就等于把「说不清且原因是 X」静默降级成「说不清、没原因」。 * 与身份字段的**交叉不变式**(写口自带,重放侧同样强制):`approvalId === null` ⇔ 本词在场;身份说不清 * 时 `taskId` 必须缺席(挑一条铸造行填 = 张冠李戴)。 */ export type LateSettleUnresolved = "ambiguous_anchor"; export declare class FileApprovalAskAuditStore implements ApprovalAskAuditSink, ApprovalAskAuditReadFace { /** 账本绝对路径(测试/取证按文件字节对账 —— 崩溃语义的真源是文件,不是内存投影)。 */ readonly ledgerPath: string; private fd; /** codex R2-F1(采):上一次 append 抛出时可能已落**部分**字节(半写)—— 置位后,下一次成功写必须 * 先落一个孤立换行把残尾隔离并 fsync(与 boot 修尾同一形,只是发生在同进程内),否则新记录会与残尾 * 并成一条坏行、重放双双丢失(丢 settle+leg_closed 会让 ask-only 组冒充 pending 孤儿)。定界自己 * 失败 ⇒ 脏标保持,本条记录走留痕臂 —— 绝不往可能的残尾上直接续字节。 */ private tailDirty; /** 写字节口(测试注入席:半写故障的确定性构造;生产恒缺省 = `fs.writeSync`)。 */ private readonly writeBytes; /** boot 重放快照(冻结于构造时刻 = 前世账本;本进程新写的行不进它 —— 收敛只对前世行)。 */ private readonly replayed; /** 前世账本里的腿闭事件(taskId + 行序;decided 臂按「决议行之后」判)。 */ private readonly replayedLegClosed; /** 已收敛行(读面数据源;含前世已收敛 + 本次 boot 新收敛)。owner 只做 scope 门,不上 wire。 */ private readonly convergedRows; /** 本进程铸过审计行、且腿闭信号可达的 taskId(腿闭去重 + 防无关腿刷账本)。 */ private readonly openLegTasks; private convergedOnce; /** * [ref] 反查索引 ①:**checkpoint 锚 → 铸造行身份**。与 {@link replayed} 刻意分家 —— 那张表是 * 「前世账本」快照(收敛只对前世行),而迟到决议要认的是**前世 + 本进程**两段账(人可以在重启前批, * 也可以在重启后批)。键形 = {@link FileApprovalAskAuditStore.callAnchorKey}(会话轴的两把键见 * {@link FileApprovalAskAuditStore.indexAsk});弱桶另见下一个字段。 */ private readonly askByCallAnchor; /** [ref] 反查索引 ①b:**无 toolCallId** 的铸造行(只能按会话认);与 A 桶分开是为了不让「有 callId 的 * 锚」误命中「另一只没写 callId 的 ask」—— 归并会把两条不同的 ask 折成一条。 */ private readonly askBySessionOnly; /** [ref] 反查索引 ②:走过 park 路由的窗到期(`settle{outcome:"expired", parkRouted:true}`)—— * 「这次决议算不算迟到」的**唯一**判据来源。 */ private readonly parkExpired; /** [ref] 反查索引 ③:approvalId → 铸造坐标(命中后回填 late 行的 `taskId`)。 */ private readonly askTaskId; /** * boot 期 fail-stop:mkdir/重放失败**拒启**(File 店族同律)。root = local 数据根 * (`config.localDataRoot`;与 `LocalBackend` 同源,见头注装配段)。 * 0o700 目录 + 0o600 账本:行携租户身份(owner/sessionId),不得组/他人可读 * (`workflow-notify-journal.ts` 同姿态)。 */ constructor(root: string, internals?: { writeBytes?: (fd: number, buf: Buffer, off: number, len: number) => number; }); /** 重放前世账本。撕尾/坏行 = 未提交态,跳过(计数,一条 warn 级 stderr 契约由调用方日志承担 —— * 这里不持 logger 席,与 journal 同姿态;坏行数暴露给收敛日志)。 */ private corruptLines; /** 尾部无终止换行(崩在 append 半途)—— ctor 在首次 append 前补孤立换行隔离之(codex R1-F3)。 * ⚠️ 判据是**换行缺席**不是「解析失败」:崩在「JSON 全落、换行未落」的字节窗时撕尾能 parse 成功, * 但不修尾照样会与下一条记录并行。 */ private tornTail; private replay; private group; /** append + fsync(逐行落盘:进程猝死不丢已记的行 —— 本店的全部价值就在崩溃时刻)。 * 运行期失败**响亮不阻断**:审批可用性不押在审计盘上(盘满/只读时人还得能批),但丢行必须在遥测 * 显形([ref] 三件套,census 登记行同批)。 * 🔴 返回**这条记录是否真的落盘并 fsync 成功**([ref] codex R1-F2 采):迟到结算的 late 判据只认 * 落了盘的前置事实 —— 调用方据此决定要不要把这条记录喂进反查索引(见 {@link recordAsk}/ * {@link recordSettle})。没有这个返回值,索引会把一条**只在内存里**的 ask/park 事实当证据, * 后来铸出的 `late_settle` 行在同一个账本文件里无法复核。 */ private append; /** [ref] 锚键 A(精确):会话 + 工具调用 id。会话轴登记**两把** —— 见 {@link indexAsk}。 */ private static callAnchorKey; /** * [ref]:把一条铸造行放进反查索引。 * 会话轴登记 `sessionId` 与 `sourceTaskId` **两把**:parked 后台子代的 checkpoint 上 * `cp.sessionId` 是**子会话 id**(= ask 行的 `sourceTaskId`),而 ask 行的 `sessionId` 是**宿主** * 会话(铸造点取的是 `origin.sessionId`,继承链闭包携宿主身份)—— 只登记一把会让委派子代那条腿 * 的迟到决议恒反查不到。顶层 run 上两者同值,登记去重后是同一把。 */ private indexAsk; /** [ref]:决议行进 late 判据索引(只认 `expired ∧ parkRouted` —— 别的终局都不是「转 durable 等人」)。 */ private indexSettle; recordAsk(input: AuditAskInput): void; recordSettle(input: AuditSettleInput): void; noteLegClosed(taskId: string): void; /** * [ref] **迟到人工结算点**(判据的唯一属主;两条 HTTP decide 腿只交锚,不自己推)。 * * 三步: * 1. **反查**:checkpoint 锚 → 铸造行身份。**锚带 `toolCallId` 时,`(会话, toolCallId)` 精确桶是 * 唯一权威**;两侧都没有调用锚时才退到会话弱桶(= 该会话下没写 toolCallId 的铸造行)。会话轴 * 登记两把(宿主会话 / 子会话,见 {@link indexAsk})。 * 🔴 **弱桶绝不与精确桶合并**(codex R1-F3 采,两条真路径):①同会话有一条**无调用锚**的历史 * park 行、而本次决的那只 checkpoint 从未在本账本留过铸造行(headless park / 换过账本,头注明写 * 这是合法形)⇒ 合并后唯一候选是那条历史行,行会写成「approvalId/taskId = 历史那只、toolCallId = * 这一只」的自相矛盾审计行;②精确唯一命中被同会话弱候选拖成假 `ambiguous_anchor`,一个**本可 * 确定**的身份被降级。弱证据不足以证明「这一次决的就是它」,证不出就不写(步骤 2 同一条纪律)。 * 2. **late 门**:候选里筛出账本已有 `settle{outcome:"expired", parkRouted:true}` 的那些。 * 筛空 ⇒ **一行不写**。理由是不编事实:证不出「窗到期已转 durable」,这次决议就只是一次普通的 * durable decide(headless 提交的 park、SQL 车道搬来的账本、或本店根本没记过这只 ask), * 写 `lateAfterExpiry:true` 会把推断当成事实上账 —— 那比缺一行更坏(审计行是要被当证据引的)。 * 3. **落行**:唯一命中 ⇒ 带真 `approvalId` + 铸造 `taskId`;多条同锚命中 ⇒ `approvalId:null` + * `unresolved:"ambiguous_anchor"`(**不静默**:身份说不清照样留行,锚全在行上给人去 join)。 * * 纯观察:自吞一切失败(append 的响亮留痕在 {@link append} 里),不返回受否 —— 结算面绝不因审计写不动 * 而改变(与 `recordAsk`/`recordSettle` 同姿态,裁 (c) 的行为面红线)。 */ recordLateSettle(input: AuditLateSettleInput): void; /** * boot 崩溃收敛器(装配层在构造后、协调器开始服务前调一次;幂等跨 boot)。 * 判据 = 头注「boot 收敛判据」段(一处成文,不复述)。返回两分臂计数 + 坏行数供 boot 日志。 */ convergeCrashedAsks(): { pending: number; decided: number; inconsistent: number; corruptLines: number; }; private projectRow; /** scope 门与 `listLivePending` 逐字同律:`undefined` = operator 全量(含 owner null 行);串 = 只见 * `owner === scope` 的行(null-owner 行对 principal 不可见,fail-closed 宁少列不超范围;`"__none__"` * 未鉴权哨兵自然恒空)。 */ listCrashConverged(scope: string | undefined): CrashConvergedRow[]; /** 收 append fd(优雅关闭腿 best-effort;SIGKILL 下不跑也不丢 —— 每行已 fsync)。 */ close(): void; } //# sourceMappingURL=approval-ask-audit-store.d.ts.map