The registry packagePath points at versions/<version>/, but the root SKILL.md copy makes it easy to hash the package root instead. Hashing the root pulls the entire versions/ subtree into the digest and still returns a value, so the mistake surfaces only as a failing gate later. Records BUG-459 and corrects the task brief's D-2 table, which listed the root SKILL.md as the hash target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
17 KiB
任务书 · 生时校正决策权威归一与停止语义重构(2026-08-31)
基线:origin/staging @ 67101f03。
本轮是结构性重构,不是补分支。上一轮 3a4396a4(stop 语义)用"在 if 链里插一个分支"的方式改交付条件,直接造成了线上事故:引擎判定"证据不足、不许采用",界面却把 9 个几乎并列的候选摆出来让用户点"采用此时间"。 同类补丁不要再打了。
事故实证
真实 case 3fe32834-9068-4275-81b1-0a57e860e68e,用户只提供了 1 条证据(2016-09 上大学)。同一份 latest_result 内部自相矛盾:
| 字段 | 引擎 decisionReceipt(Python) |
顶层投影(前端读这个) |
|---|---|---|
selection_allowed |
false | true |
acceptance_allowed / can_adopt |
false | true |
propose_allowed |
false | true |
result_status |
insufficient_evidence | completed_with_range |
precision_stage |
d9_refine(需继续细化) | ready_to_adopt |
引擎把理由写得很完整:acceptance_reasons: ["insufficient_events","insufficient_domain_diversity"];四道闸门全挂 —— event_quality 1 件 < 最低 3、domain_diversity 1 个 < 最低 2、unique_top 有 3 个并列第一、diagnostic_quality margin 5.24% < 最低 10%;overall_confidence: "low"。
候选跨度 04:47–05:15(29 分钟),前四名支持度 12/12/12/12 完全并列。用户点任意一个,accepted_time 落库并出现"用这个时间看盘"入口 —— 拿引擎明说证据不足的时间去排盘。这是数据正确性事故,不是 UX 瑕疵。
已用真实分数复现(decideRectification 直接调用):
输入:datedEventCount=1, datedDomainCount=1, trainingGateOpen=false,
methodCoverageAll=false, snapshotCurrent=true, userStopped=false
输出:nextAction=complete_with_range stopReason=insufficient_dated_events
selectionAllowed=true canAdopt=true precisionStage=ready_to_adopt
separation.status=not_separated tiedForFirst=true
末行是关键:系统自己知道候选分不开(not_separated + tiedForFirst),仍然放行采用。
四个结构性病灶
本轮要根治的是这四条,不是上面那一个症状。
病灶 1 · 双重决策权威,且下游可单方面推翻上游
Python 引擎已经算出 selection_allowed / acceptance_allowed / propose_allowed / confirmation_allowed / result_status 并附完整 gates。TypeScript 的 decideRectification 又独立重算一遍同样的判断。两个权威源之间没有任何一致性约束。
覆盖点在 v9/decision-from-dossier.ts:619-635 的 overlayPublicDecision:
return {
...snapshot, // 引擎结果
...fields, // TS 决策 —— 无条件盖掉引擎
...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),
唯一的 fail-closed 是候选投影不一致(inconsistent)。引擎自己写的 acceptance_allowed: false 完全不在考虑范围内。
病灶 2 · "证据不足"与"已经查完"共用一个出口
core/rectification-decision.ts:186-188:
if (stopReason) {
return completeWithRange(separation, holdout, range, "exhausted", stopReason);
}
stopReason 有四个取值,语义截然相反却走同一出口:
| stopReason | 真实含义 | 正确出口 | 现状 |
|---|---|---|---|
insufficient_dated_events |
证据不够 | 继续收集 | 交付并允许采用 |
insufficient_domains |
领域不够 | 继续收集 | 交付并允许采用 |
tied_first |
问不动了 | 交付区间 | 交付区间 ✓ |
user_uncertainty_too_high |
问不动了 | 交付区间 | 交付区间 ✓ |
completeWithRange(kind: "exhausted") 的语义是"已穷尽、交付最终区间",terminal = true,并无条件设 canAdopt: true / selectionAllowed: true / precisionStage: "ready_to_adopt" / phase: "completed"(见 core/rectification-decision.ts:414-452)。
次生问题:这个 if (stopReason) 被插在 coverageBlocks 检查之前。coverageBlocks 本会因 trainingGateOpen=false 返回 collect() 继续收集 —— 被 stopReason 抢跑了。
病灶 3 · 能力标志散落在 11 个分支里各自硬编码
decideRectification 是一条 11 分支的顺序 if 链,canAdopt / selectionAllowed / proposeAllowed / canConfirmExactMinute 由 finish() / completeWithRange() / collect() / discriminate() / offerRangeWithoutAdopt() / holdoutValidation() 各自决定。没有任何单点能回答"什么条件下允许采用",也无法审计。上一轮的回归正是这种结构的必然产物。
病灶 4 · 多处 fail-open
core/rectification-decision.ts:222-233:
if (holdout === "unavailable") {
if (userStopped) { return completeWithRange(..., "user_stopped"); }
return finish("adopt_representative", { ..., canConfirmExactMinute: false });
}
holdout 的默认值就是 input.holdoutValidation ?? "unavailable"。独立核对不可用时反而放行采用,canAdopt: true。事故快照里 sealed holdout 的真实状态是 top_1_rate: 0.15、required_cases: 20、status: "not_ready" —— 基准根本没建起来,系统却据此放行。
同理 evidenceStopReason 首行 if (separation.ranked.length === 0) return null; —— 没有候选时不给任何停止原因,也是 fail-open 方向。
硬红线
- 撤销上一轮任务书的红线 3。
TASK-rectification-billing-20260831.md写的"不得修改core/rectification-decision.ts,与本次故障无关"——就计费故障而言成立,但它正是本轮的根因所在。本轮必须改它。 - 引擎是门禁的唯一上限权威。 TS 层对
acceptance_allowed/selection_allowed/propose_allowed/confirmation_allowed只能收紧,永远不能放宽。引擎说 false,最终必须是 false,没有例外。 - 一切 fail-closed。 引擎字段缺失、holdout 不可用、候选投影不一致、快照过期 —— 一律按"不允许采用"处理。不确定时禁止交付,不是放行。
- 不得为了让重构通过而放宽任何既有门槛。
MIN_STANDALONE_DATED_EVENTS=3/MIN_STANDALONE_DATED_DOMAINS=2/ 4-3 确认门 /MIN_SEPARATION_LEAD数值一律不动。本轮改的是这些判断如何流向能力标志,不是判断本身。 - 不得修改既有测试断言 —— 除非该断言锁住的正是本轮要修的缺陷本身(预计
rectification-convergence-budget.test.ts等会有若干条锁住了错误行为)。那种情况必须在断言上方注明"原值是什么、为什么它是错的",并在 PROGRESS 里逐条单列。发现需要改的断言超过 5 条时停下来先汇报,那意味着方案偏了。 - 任务 0 是门控。 不变量测试必须先写、先失败,才允许动实现。
- 推 staging 前必须
./node_modules/.bin/tsc --noEmit通过。不要用npx tsc—— 新建 worktree 未npm install时 npx 会装到空包tsc@2.0.4。 tests/rectification-*.test.ts不得低于基线 705,且fail=0。全量npm test需要 Docker;无 Docker 时必须逐条比对失败清单确认无新增。- 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:不得放行不该放行的采用 > 数据正确性 > 功能与测试不回归 > 可诊断性 > 代码整洁。
开工前置
git fetch origin --prune
git worktree add -b codex/rectification-decision-authority-20260831 \
../.worktrees/rectification-decision-authority-20260831 origin/staging
基线必须是 origin/staging。读 docs/research/pre_work_error_ledger.md,跑 scripts/pre_work_check.py,读 frontend/AGENTS.md。改前在 docs/BUG_HISTORY.md 检索同类记录(47b4b06b 刚补过一条)。
下面所有行号只是线索,请按符号名定位。
任务 0(门控)· 先写不变量测试,先让它红
在既有的 frontend/tests/rectification-decision-authority.test.ts 里补一组不变量测试。这些断言与实现无关,重构前后都必须成立:
- 引擎上限不可逾越:对任意输入组合,
decision.canAdopt为 true ⟹ 引擎acceptance_allowed !== false;selectionAllowed/proposeAllowed/canConfirmExactMinute同理。 - 证据不足永不放行:
datedEventCount < MIN_STANDALONE_DATED_EVENTS或datedDomainCount < MIN_STANDALONE_DATED_DOMAINS⟹canAdopt === false且selectionAllowed === false。 - 分不开就不能采用:
separation.tiedForFirst === true⟹canAdopt === false(用户主动停止的路径除外,但那条必须带completionStatus: "provisional_range_user_stopped")。 - holdout 不可用不得放行:
holdoutValidation === "unavailable"且非userStopped⟹canAdopt === false。 - 覆盖只能收紧:
overlayPublicDecision的输出对四个门禁字段,必须是引擎值与 TS 值的逻辑与。
建议用组合枚举(笛卡尔积)而非手挑用例覆盖输入空间 —— 病灶 3 的教训就是单点用例挡不住分支链回归。
跑一遍,确认 1/2/3/4/5 里至少 4 条是红的。 全绿说明测试没写对,停下来重写。
任务 A(P0)· 停止原因分类化,与交付能力解耦
把 EvidenceStopReason 升级为带出口语义的分类。建议形状(可自行调整命名,但语义必须三分):
type StopClass =
| { kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }
| { kind: "exhausted"; reason: "tied_first" | "user_uncertainty_too_high" }
| { kind: "user_stopped" };
keep_collecting不得进入completeWithRange,必须走collect()completeWithRange的kind参数删掉"exhausted"承接证据不足的用法;该函数今后只接受真正可交付的停止- 修正
if (stopReason)与coverageBlocks的先后:keep_collecting必须让位给coverageBlocks的收集分支
任务 B(P0)· 能力标志单一来源 + 引擎上限交集
B-1 抽出唯一计算点。 新增一个纯函数,成为 canAdopt / selectionAllowed / proposeAllowed / canConfirmExactMinute 的唯一产出处:
function deliveryCapability(input: {
stopClass: StopClass | null;
separation: CandidateSeparation;
holdout: HoldoutValidationStatus;
engineCeiling: EngineCapabilityCeiling;
accepted: boolean;
userStopped: boolean;
}): { canAdopt: boolean; selectionAllowed: boolean; proposeAllowed: boolean; canConfirmExactMinute: boolean }
改完之后,finish() / completeWithRange() / collect() / discriminate() / offerRangeWithoutAdopt() / holdoutValidation() 里不允许再出现这四个字段的字面量赋值,一律取自该函数。请在 PR 里贴出 grep 结果自证。
B-2 引擎上限。 新增 EngineCapabilityCeiling,从 decisionReceipt 提取 acceptance_allowed / selection_allowed / propose_allowed / confirmation_allowed。字段缺失或无法解析时一律视为 false(红线 3)。deliveryCapability 的输出必须与之取逻辑与。
B-3 修 overlayPublicDecision。 v9/decision-from-dossier.ts 里 ...snapshot 后跟 ...fields 的无条件覆盖改为对四个门禁字段取交集。保留既有的 inconsistent fail-closed。
B-4 修病灶 4 的 fail-open。 holdout === "unavailable" 且非 userStopped 不得再 finish("adopt_representative")。evidenceStopReason 在 ranked.length === 0 时的 fail-open 一并处理。
任务 C(P1)· 前端 fail-closed
src/components/rectification-agentic-chat.tsx:1073 的 showSelectionCards 目前只看 candidateResult?.selectionAllowed && candidateResult?.canAdopt。即便后端修好,这里也应有独立防线:渲染采用卡片前校验引擎 receipt 未否决。
这是纵深防御,不是后端修复的替代品 —— 后端必须独立正确,任务 0 的不变量测试不允许依赖前端。
任务 D(P0)· 问题槽单一文案源
第二个用户可见缺陷:opening 轮问题卡片显示"请再说一件记得大概时间的经历。"——但用户一件都还没说过,"再"字不通。
成因是两条独立的问题生成路径:模型在回复正文里自然地问了一个问题,服务端的 persistNextInterviewIfIdle 又确定性地生成了一个 focus。零证据时 buildMethodFollowupPlan 没有素材,spokenFollowupForUser 返回 null,落到 v9/answer-choice.ts:329,390 与 v9/method-followup.ts:951 的兜底句。
根本原则:一个回合只能有一个"当前问题",且只能有一个产出点。 本轮 D-1 与 D-2 都要做。
D-1 · 服务端成为唯一问题源
- 零证据的 opening 轮必须生成真正的第一个问题,而不是"请再说一件…"的兜底句
- 三处兜底文案(
answer-choice.ts:329、answer-choice.ts:390、method-followup.ts:951)要么删除,要么改成不预设"已经说过"的措辞 - 模型正文只做承接与解释,不再自带问句
D-2 · Skill 升版,禁止模型正文自带问句
在 skill prompt 里硬性约束:问题由服务端问题槽呈现,正文不得自带问句。
上一版任务书说这条"成本显著更高、需处理存量 case 的 skill_identity_mismatch"——那个判断是错的,已核实。 实际情况:
agent/route.ts:219的skillVersion取自 case 自己绑定的skill_version,不是全局常量resolveExact(skill-package-registry.ts:459)只在status === "blocked"时失败,deprecated正常放行- 因此存量 case 会继续跑它绑定的 10.0.13,不会 mismatch,不需要数据迁移
- registry 里 10.0.0 → 10.0.13 共 15 个版本、历史版本全部
deprecated,升版是本仓库的常规操作
升版必须同步改动以下 5 处,缺一即坏:
| # | 位置 | 改什么 |
|---|---|---|
| 1 | skills/jyotish-birth-time-rectification/versions/10.0.14/ |
以 versions/10.0.13/ 为基础新建;改 SKILL.md 正文约束 + frontmatter version: 10.0.14 |
| 2 | skills/jyotish-birth-time-rectification/SKILL.md |
根目录是最新版的副本,与上一行保持字节一致 |
| 3 | skills/skill-package-registry.json |
新增 10.0.14 条目 status: "active"、packagePath 指向 versions/10.0.14;10.0.13 改 "deprecated" |
| 4 | 同上条目的 sha256 |
用 computeSkillPackageSha256()(skill-package-registry.ts:289 已导出)只对 versions/10.0.14/ 计算 |
| 5 | frontend/src/lib/rectification-agentic/v9/case-status.ts:92 |
RECTIFICATION_SKILL_VERSION = "10.0.14" |
| 6 | frontend/tests/skill-registry.test.ts |
该文件硬编码了 sha256,必须同步 |
SHA 只对 registry packagePath 指向的目录计算,绝不对 Skill 根目录计算。 根目录含 versions/ 整棵子树,对它计算会把全部历史版本吃进哈希——而且能正常返回一个值、不会立刻报错,错了也发现不了。
两个坑:
- sha256 覆盖目标目录全部内容(递归遍历,每个文件写入长度前缀的 path / executable bit / bytes)。改 frontmatter 也会改 sha —— 顺序必须是"先改完所有字节,最后统一重算",不得边改边算。
case-service.ts:301校验resolveActiveSkillPackage(...).version === RECTIFICATION_SKILL_VERSION,registry 与常量不一致会让新建 case 直接抛skill_registry_version_mismatch,功能整个开不了场。上表第 3 与第 5 处必须同时改。
前车之鉴:797a423a 就是"改了 skill 字节但忘了刷新 registry hash"的补救提交。改完务必跑 npx tsx --test tests/skill-registry.test.ts 验证。
skills/jyotish-birth-time-rectification/versions/ 目录存有历史版本快照,按既有惯例处理。
验收标准
cd frontend && ./node_modules/.bin/tsc --noEmitexit 0cd frontend && npx tsx --test tests/rectification-*.test.ts不低于 705 条且fail=0- 任务 0 的 5 条不变量全绿
- 回归实证:用事故 case 的真实输入(1 条证据 / 1 个 domain / 9 个候选 12,12,12,12,11,11,10,10,10)跑
decideRectification,断言canAdopt === false、selectionAllowed === false、nextAction回到收集类,并把前后输出贴进 PR - B-1 的
grep自证:四个能力字段在决策分支函数里无字面量赋值 npx tsx --test tests/skill-registry.test.ts通过,且 registry / frontmatter /RECTIFICATION_SKILL_VERSION/ 测试内 sha256 四处一致- 新建一个 case 能正常开场(验证
skill_registry_version_mismatch未被触发),且 opening 轮问题槽文案不含"再"字预设
交付前必须说明
- 逐条列出改动的既有测试断言(红线 5),每条写清原值与为什么原值是错的
- 本任务书的病灶分析来自静态溯源 + 本地
decideRectification直接调用复现,作者无 staging 凭据(仓库内无.env),未在真实环境验证。你若同样无凭据,不要声称已验证。