docs(rectification): add decision-authority refactor task brief
Independent Staging Quality Gate / validate (push) Successful in 36m36s
Independent Staging Quality Gate / publish (push) Failing after 15m31s

The engine receipt says acceptance_allowed/selection_allowed false with
reasons insufficient_events and insufficient_domain_diversity, yet the
top-level projection flips both to true and renders adoptable candidates
after a single piece of evidence. Records the four structural causes:
dual decision authority where overlayPublicDecision lets TS override the
engine, insufficient-evidence sharing an exit with exhausted, capability
flags hardcoded across eleven branches, and several fail-open paths
including holdout "unavailable" granting adoption.

Also folds in the skill bump (D-2) after verifying that existing cases
bind their own skill_version and deprecated packages still resolve, so
no case migration is required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
This commit is contained in:
Jesse_Chen
2026-08-31 14:37:26 +00:00
parent 67101f0397
commit 9aebd0474a
@@ -0,0 +1,255 @@
# 任务书 · 生时校正决策权威归一与停止语义重构(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:1529 分钟),前四名支持度 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`
```ts
return {
...snapshot, // 引擎结果
...fields, // TS 决策 —— 无条件盖掉引擎
...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),
```
唯一的 fail-closed 是候选投影不一致(`inconsistent`)。引擎自己写的 `acceptance_allowed: false` **完全不在考虑范围内**
### 病灶 2 · "证据不足"与"已经查完"共用一个出口
`core/rectification-decision.ts:186-188`
```ts
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`
```ts
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 方向。
---
## 硬红线
1. **撤销上一轮任务书的红线 3。** `TASK-rectification-billing-20260831.md` 写的"不得修改 `core/rectification-decision.ts`,与本次故障无关"——就计费故障而言成立,但它正是本轮的根因所在。**本轮必须改它。**
2. **引擎是门禁的唯一上限权威。** TS 层对 `acceptance_allowed` / `selection_allowed` / `propose_allowed` / `confirmation_allowed` **只能收紧,永远不能放宽**。引擎说 false,最终必须是 false,没有例外。
3. **一切 fail-closed。** 引擎字段缺失、holdout 不可用、候选投影不一致、快照过期 —— 一律按"不允许采用"处理。不确定时禁止交付,不是放行。
4. **不得为了让重构通过而放宽任何既有门槛。** `MIN_STANDALONE_DATED_EVENTS=3` / `MIN_STANDALONE_DATED_DOMAINS=2` / 4-3 确认门 / `MIN_SEPARATION_LEAD` 数值一律不动。本轮改的是**这些判断如何流向能力标志**,不是判断本身。
5. **不得修改既有测试断言** —— 除非该断言锁住的正是本轮要修的缺陷本身(预计 `rectification-convergence-budget.test.ts` 等会有若干条锁住了错误行为)。那种情况必须在断言上方注明"原值是什么、为什么它是错的",并在 PROGRESS 里逐条单列。**发现需要改的断言超过 5 条时停下来先汇报**,那意味着方案偏了。
6. **任务 0 是门控。** 不变量测试必须先写、先失败,才允许动实现。
7. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`** —— 新建 worktree 未 `npm install` 时 npx 会装到空包 `tsc@2.0.4`
8. `tests/rectification-*.test.ts` 不得低于基线 **705**,且 `fail=0`。全量 `npm test` 需要 Docker;无 Docker 时必须逐条比对失败清单确认无新增。
9. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:**不得放行不该放行的采用 > 数据正确性 > 功能与测试不回归 > 可诊断性 > 代码整洁**。
## 开工前置
```bash
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` 里补一组**不变量**测试。这些断言与实现无关,重构前后都必须成立:
1. **引擎上限不可逾越**:对任意输入组合,`decision.canAdopt` 为 true ⟹ 引擎 `acceptance_allowed !== false``selectionAllowed` / `proposeAllowed` / `canConfirmExactMinute` 同理。
2. **证据不足永不放行**`datedEventCount < MIN_STANDALONE_DATED_EVENTS``datedDomainCount < MIN_STANDALONE_DATED_DOMAINS``canAdopt === false``selectionAllowed === false`
3. **分不开就不能采用**`separation.tiedForFirst === true``canAdopt === false`(用户主动停止的路径除外,但那条必须带 `completionStatus: "provisional_range_user_stopped"`)。
4. **holdout 不可用不得放行**`holdoutValidation === "unavailable"` 且非 `userStopped``canAdopt === false`
5. **覆盖只能收紧**`overlayPublicDecision` 的输出对四个门禁字段,必须是引擎值与 TS 值的逻辑与。
建议用组合枚举(笛卡尔积)而非手挑用例覆盖输入空间 —— 病灶 3 的教训就是单点用例挡不住分支链回归。
**跑一遍,确认 1/2/3/4/5 里至少 4 条是红的。** 全绿说明测试没写对,停下来重写。
---
## 任务 A(P0)· 停止原因分类化,与交付能力解耦
`EvidenceStopReason` 升级为带出口语义的分类。建议形状(可自行调整命名,但**语义必须三分**):
```ts
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` 的**唯一**产出处:
```ts
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 一并处理。
---
## 任务 CP1)· 前端 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/SKILL.md` | 正文约束 + frontmatter 的 `version: 10.0.14` |
| 2 | `skills/skill-package-registry.json` | 新增 10.0.14 条目 `status: "active"`10.0.13 改 `"deprecated"` |
| 3 | 同上条目的 `sha256` | 用 `computeSkillPackageSha256()``skill-package-registry.ts:289` 已导出)对**整个包目录**重算 |
| 4 | `frontend/src/lib/rectification-agentic/v9/case-status.ts:92` | `RECTIFICATION_SKILL_VERSION = "10.0.14"` |
| 5 | `frontend/tests/skill-registry.test.ts` | 该文件**硬编码了 sha256**,必须同步 |
两个坑:
- sha256 覆盖整个目录(递归遍历,每个文件写入长度前缀的 path / executable bit / bytes)。**改 frontmatter 也会改 sha** —— 顺序必须是"先改完所有字节,最后统一重算"。
- `case-service.ts:301` 校验 `resolveActiveSkillPackage(...).version === RECTIFICATION_SKILL_VERSION`registry 与常量不一致会让**新建 case 直接抛 `skill_registry_version_mismatch`**。第 2、4 处必须同时改。
前车之鉴:`797a423a` 就是"改了 skill 字节但忘了刷新 registry hash"的补救提交。**改完务必跑 `npx tsx --test tests/skill-registry.test.ts` 验证。**
`skills/jyotish-birth-time-rectification/versions/` 目录存有历史版本快照,按既有惯例处理。
---
## 验收标准
1. `cd frontend && ./node_modules/.bin/tsc --noEmit` exit 0
2. `cd frontend && npx tsx --test tests/rectification-*.test.ts` 不低于 705 条且 `fail=0`
3. 任务 0 的 5 条不变量全绿
4. **回归实证**:用事故 case 的真实输入(1 条证据 / 1 个 domain / 9 个候选 12,12,12,12,11,11,10,10,10)跑 `decideRectification`,断言 `canAdopt === false``selectionAllowed === false``nextAction` 回到收集类,并把前后输出贴进 PR
5. B-1 的 `grep` 自证:四个能力字段在决策分支函数里无字面量赋值
6. `npx tsx --test tests/skill-registry.test.ts` 通过,且 registry / frontmatter / `RECTIFICATION_SKILL_VERSION` / 测试内 sha256 四处一致
7. 新建一个 case 能正常开场(验证 `skill_registry_version_mismatch` 未被触发),且 opening 轮问题槽文案不含"再"字预设
## 交付前必须说明
- 逐条列出改动的既有测试断言(红线 5),每条写清原值与为什么原值是错的
- 本任务书的病灶分析来自**静态溯源 + 本地 `decideRectification` 直接调用复现**,作者无 staging 凭据(仓库内无 `.env`),**未在真实环境验证**。你若同样无凭据,不要声称已验证。