fix(rectification): enforce trusted result identity and preserve receipt provenance
Independent Staging Quality Gate / validate (push) Successful in 10m4s
Independent Staging Quality Gate / publish (push) Successful in 10m26s

Unify minute and block cache identity, keep unverifiable historical results read-only across server tools and write entrypoints, and aggregate completed receipt sources chronologically through a compatible function migration.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-20 18:03:38 +08:00
co-authored by Claude Code
parent 6dd62207eb
commit 8d0359fc62
37 changed files with 5983 additions and 95 deletions
+8 -1
View File
@@ -1,6 +1,13 @@
# BLOCKED
## BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20)
## BUG-984:受控 staging 与真人验收尚未完成(2026-09-20)
- 本 agent 不调用线上、不读取凭据;没有本轮受控账号/浏览器会话证据。自动化仅使用明确虚构 fixture 与本地 Docker PostgreSQL,不能代替发布后登录态验收。
- 清单:`docs/testing/rectification-cross-midnight-fix-20260920.md`。推送、独立验收及部署由主会话负责,BUG-984 暂不标 resolved。
## ~~BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20)~~
- **已解除**:产品明确授权继续 B;本轮新增 `20260920010000_rectification_receipt_result_identity.sql`,只替换目标函数身份 SELECT,保留权限、owner/turn/attempt 与重试语义,不改表结构、已应用迁移或历史行。以下保留授权前停点事实。
- A 已局部实现:started/failed 不写算法身份,completed 只用实际结果来源。真实工具 + 原生 golden 的同 turn compare/diagnostics 组合可产生 scoring-9、scoring-10 两条成功回执;字符串 max 返回 scoring-9,不是最近成功 scoring-10。该测试证明缺陷仍存在,不是聚合修复通过。
- 既有 RPC 输出没有各 completed 的来源身份;历史 fingerprint 也不能还原。按补单须升 B(兼容函数体迁移),但当前执行授权禁止自行迁移,已停止进一步业务实施并报告。F1/F3/F4 pending;未改 SQL/UI/缓存、未推送。见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。
+5 -1
View File
@@ -1,6 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现,未发布)
## 2026-09-20 — 校正旧结果保留真实来源,无法核实时只读(未发布)
分钟与时段缓存统一核验算法、策略和输入指纹;无法取得可信当前身份时保留旧结果,提示「按旧算法产出」,不强制重算。候选交付、选择、采用和确认由服务端重新检查,历史仍按绑定 Skill 打开。新算响应与当前身份不一致也保留真实来源并只读;成功回执聚合改取选中 attempt 最近的 completed 来源,不再取版本字符串最大值。只新增兼容函数体迁移,不修改历史行或表结构。Skill 版本、评分和确认门不变;部署与真人验收另行完成。
## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现历史记录)
compare/diagnostics 开始或失败时不写算法身份,成功时只记录实际结果来源,不回退部署默认值。同轮多个成功结果仍可能被旧 SQL 字符串聚合选错,本轮因此停在迁移授权边界;缓存只读策略尚未实施,不宣称 BUG-984 已修。Skill 版本不变。
+4 -1
View File
@@ -13015,7 +13015,10 @@
- 相关记录:BUG-427、BUG-621、BUG-981。
- 复发自:未发现同症状既有记录;既有分钟算法身份缓存门不覆盖提前返回的时段分支,回执测试未组合新版 started 与旧缓存 completed。
- F2 补充实测:原生虚构输入 golden 经真实 compare/diagnostics 工具顺序执行,仅替换算法标签模拟滚动版本;同一 turn 可写 completed scoring-9 与 scoring-10,而字符串 max 为 scoring-9。既有聚合 RPC 不暴露各成功行身份,历史 fingerprint 无法还原,应用层 A 不足以闭环。定向身份 9/9(含缺陷诊断)、tsc 0;五文件回归 61 项中 57 pass / 4 Windows symlink EPERM,未冒称 DB 聚合已验。
- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 的 F2 A 局部实现,B 授权阻塞、未推送;缓存与聚合整体验收未通过。
- 授权恢复与修复:产品明确授权 B 后,新增兼容函数体迁移,仅将身份聚合改为选中成功/最新 attempt 的 completed 非空来源、按时间/id稳定排序;表/已应用迁移/历史行与权限不变。分钟/时段统一完整算法+策略pair与输入指纹;未知身份保留旧值只读且不强制重算,服务端采用/确认/候选选择独立重查。历史GET/刷新和工具均标注;新算响应与当前pair不一致也只读。可信当前版本已知的stale历史保留显式「重新比较」,未知身份没有入口,避免只读锁死升级。以上替代授权前局部状态,前文保留事故与停点历史。
- 恢复验证:真实native虚构golden的minute/block旧缓存重算及当前缓存复用、部分env冲突/接口超时与故障、缺source/非最新result、历史read→compare路径均回归;F1先红4项后绿。B真实PostgreSQL先红8≠7后绿,标准DB40/40(无skip);Python日期/bridge/memoization32/32。Linux完整基线3575/3575→最终diagnostics补丁快照3588/3588(0fail/skip),首页Static、完整28资源gzip+0.040085%,tsc/lint0error;主会话独立DB40/40、最终定向40/40+tsc通过。不提前算部署通过。
- diagnostics追加复核:旧failure回归未调用诊断工具,新增调用先得16绿/6红,确认未知身份仍会重计算;现未知身份+旧结果仅返回只读来源/notice,不执行诊断或方法,不允许分钟确认。可信身份下诊断新响应pair不一致也只读、确认false,成功来源仍是真实9/10;修后身份22/22、身份+spoken37/37;改后全量3588/3588与build已新快照复跑通过,旧delivery证据另外保留,主会话也已独立40/40及tsc0。
- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 本地实现,未推送;前置 `3f39bafc` 已由主会话确认run2819及web/API双SHA部署,本补丁仍待独立验收、发布与受控真人走查,保持 investigating。
## BUG-985 | 同日不变性回归写死浮点分数哈希导致跨机门禁失败
+6
View File
@@ -2,6 +2,12 @@
Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility.
## 2026-09-20 · BUG-984 Linux 标准复验与保真归档
- B 经产品明确授权后新增兼容函数体迁移;此前停点仍保留。Windows checkout 将 migration symlink 变成普通文本,标准扫描报 duplicate filename;Skill 注册表另有 symlink EPERM。不要改迁移扫描器或弱化安全断言,用 `git archive` 的真实 symlink 在 Linux 复验。
- 首轮 Linux 基线 3575 项中 4 项失败来自归档无 Git index、缺 PyYAML、缺 rsync。补工具,并用源树 `git ls-files -z` 精确恢复 tracked inventory(含原本已 tracked 但现在被 ignore 的文件,不能 `git add .` 收进日志),基线 3575/3575。临时仓只有 index,不伪称完整原 Git 历史。
- 覆盖 `/repo` 基线的同步命令被权限系统拒绝后未重试覆盖;改为创建全新快照目录,基线、失败轮和日志均保留。Docker `bug984-runner`、DinD `bug984-docker`、volume `bug984-validation` 留给独立复跑,不清理。
## 2026-09-20 · BUG-984 补单前置解除与聚合授权停点
- 前轮写认证 blocker 已由主会话解除:前置代码 `3f39bafc` 已推 staging 并核对;本执行树 fetch 到后续纯文档 `f09f3d80`。下面旧记录保留为历史,不再作为当前开工 blocker。
@@ -2,7 +2,7 @@
## 结论与授权边界
**blocked:F2 方案 A 已完成局部实现,实测证实同一 turn 可出现不同身份的多个 completed,按补单须升级 B;未获 SQL 迁移授权,依指令停止进一步业务实施。F1 / F3 / F4 未完成,不可交付为 BUG-984 修复。**
**本地实现与自动化完成:F1/F2/F3 已实现,diagnostics追加修复后的新快照全量3588/3588、定向37/37、tsc/lint通过;标准DB40/40,首页Static、完整28资源gzip+0.040085%。主会话独立DB40/40、diagnostics最终定向40/40+tsc通过。未推送、未部署,受控真人验收仍缺口,BUG-984保持investigating。** 以下保留授权前记录,最新状态以文末「授权恢复」为准。
- 执行树:`.worktrees/rectification-cross-midnight-fix-20260920`,分支 `codex/rectification-cross-midnight-fix-20260920`。
- 前置代码基线:`3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。主会话已成功推 staging 并核对 ls-remote,**前轮 Gitea 写认证 blocker 已解除**;保留旧失败历史,不把它当本轮阻塞。
@@ -89,3 +89,66 @@
- 独立实跑身份文件 9 / 9 通过;BUG-621 历史打开与 case-service 两文件 29 / 29 通过;`tsc --noEmit` 退出 0;前置 Python 跨午夜 bridge 9 / 9 通过。混版本项只证明缺陷可达,未执行数据库聚合验收。
- 前置 Gitea run2819(SHA `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`)的 validate 已 success,最后查询整轮仍 in_progress。staging `/login` 200、匿名 `/api/account` 401;健康响应的 web/API 部署均仍 `539d4daee4d0065f5ef3b974903ea8d639a249c6`,所以前置部署尚未确认完成。这些匿名检查不能代替缓存身份受控会话验收。
- 局部实现未推送;按任务书迁移授权停点报告,F1/F3/F4 保持 pending。
## 授权恢复与最终实现(2026-09-20)
产品明确回复继续并授权 B:只新增向后兼容函数体迁移、仅修回执聚合,不改表结构/已应用迁移/历史行,保留 owner/turn/attempt 与重试语义,真跑标准 test:db。恢复时 HEAD `6dd62207eb1ab04aaac8f6755146980b7abac8b2`。原停点是正确执行历史,不删除或改写。
主会话补报前置 run2819 completed/success,staging health 的 deployment.gitCommit 与 apiGitCommit 均等于 `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。前置部署确认完成;本 agent 未访问线上,这不是本补丁受控身份验收。
### F1 / F2 / F3
- minute 与 block_scan 共用算法+策略完整 pair、证据指纹、范围/基线资料指纹;完整 env 保留明确覆盖语义,部分 env 补查 native pair、冲突 fail closed,不拿前端默认常量冒充部署覆盖。时段来源 policy/range 指纹只写新 JSON,不回填历史。
- 真实原生虚构输入 golden 经 `normalize_rectification_request`→`api_service.block_scan` 生成;旧 -7/当前 -8 minute 和 late-night 缓存均实跑应用链,旧重算、当前命中。F1 红测 11 项中 4 红(partial/unknown/旧block/当前block未探测),修后全绿。
- B 迁移 `20260920010000_rectification_receipt_result_identity.sql` 与旧函数除注释外仅身份 SELECT 不同:选中 successful attempt,否则 latest attempt;仅 completed 非空身份,started_at DESC、id DESC。真实 PostgreSQL 先红(8≠7)后绿,覆盖9/10、失败重试、同时间稳定排序、历史不重标与owner/turn/权限。
- 未知当前身份:旧结果只读、cached=false、来源不重标,不重算、不跑 oracle、不写 focus/inference/候选/验证;历史 GET/刷新、read-case、compare 同样显示「按旧算法产出」。新算响应与可信 pair 不同也保存真实来源并只读。
- 服务端写门覆盖 API direct accept、service accept/confirm、offer候选、选择/推断、block推进/拒绝、widen、tie-break、focus denial及闲置/出口修复。重查 dossier+当前身份,不信客户端flag;stage/source交叉缺失、非最新resultId、无source具体采用一律拒绝,正常空Case收集不误拦。无生产引用的旧 session.ts helper未扩改。
- 独立review发现 stale 与 unknown 同锁composer会造成旧结果无法升级,已修:旧候选仍不可写,但可信当前pair已知时、非终止Case可点「重新比较」,复用既有message入口;未知身份不显示,不自动重算。测试覆盖历史投影→read-case提示compare→实际compare新来源,以及按钮handler的stale/unknown/terminal/busy派发边界。
- 独立review再发现 diagnostics 未经过身份门,原六种failure测试没有调用该工具。补调用后22项16绿/6红,证明未知身份仍请求了diagnostics;现入口在读取compute/诊断前短路:旧来源+notice/read_only、diagnostics=null、can_confirm_exact_minute=false、executed_methods=[],无source则engine_identity_unavailable。可信身份下仍独立诊断,新响应pair不匹配只读且不能确认,completed保持实际来源,9/10聚合用例保留;修后22/22。
- 不改Skill、input contract、评分/确认门、历史打开绑定,不删除或重标历史缓存;UI同步DESIGN,无Home状态增长。
### F4 证据与复跑环境
| 检查 | 已完成结果 |
| --- | --- |
| Linux基线全量(HEAD6dd62207应用代码) | 3575/3575,fail/skip=0 |
| 身份及历史重算定向 | 22项;含minute/block、无/完整/部分env、冲突、接口异常/超时、来源缺失与滚动响应 |
| 最终四文件业务定向(加入stale测试之前) | 112/112 |
| Python日期+bridge+memoization | 32/32;bridge重复收集已知,不称32个独立用例 |
| 标准 `npm run test:db` | 执行方40/40,主会话独立40/40(304634ms),fail/skip=0,真实PostgreSQL17;独立日志 `<临时目录>/bug984-independent-db.log` |
| 中间完整修复轮 | 3587项,3586通过/1源码断言失败:终止提示改为historyReadonly,已附三栏同步 |
| 中间build | 首页 `┌ ○ /`;25个首页直链JS/CSS gzip 655491→655584B,+93B/+0.0142% |
| 保留的旧delivery全量 | 3588/3588,fail/skip/cancelled=0,307886ms;不替代追加diagnostics后的复跑 |
| 最终diagnostics全量 | 3588/3588,fail/skip/cancelled=0,259596ms;比基线3575增加13项 |
| 最终diagnostics build/gzip | `┌ ○ /` Static;完整28资源666078→666345B,+267B/+0.040085%,在±2%内。读取index.html全部src/href的/_next/ JS/CSS,去query后去重,各文件gzipSync默认参数求和;旧25资源655491→655758B为遗漏3资源的局部统计,由本完整口径替代 |
| 最终diagnostics/按钮/身份/spoken定向 | 37/37;含22项身份、六种故障的diagnostics调用、实际handler派发与历史read→compare行为 |
| tsc/lint | 最终diagnostics后退出0;lint 0 error/119 warning,不顺修warnings |
| BUG-621及最终独立补验 | 主会话先前43/43;身份+spoken(含按钮handler)+BUG-621独立40/40、tsc退出0;日志 `<临时目录>/bug984-independent-final-targeted.log`;diagnostics补丁后再次独立40/40且tsc0 |
| 隐私门 | 明确stage全部37个本单文件(含新增fixture/SQL)后 `python -m pytest tests/test_repo_privacy_markers.py`:62/62;删除文档内两处HOME_PREFIX,日志以临时目录占位符保留 |
| 本轮部署/真人 | 未进行,BLOCKED与真人清单保留 |
Linux保真方式:`git archive HEAD` 保留真实symlink,增量文件在打包时替换;从不将Windows整个checkout覆到Linux。首轮基线3575/3571/4,失败为缺Git index/PyYAML/rsync;装备后3575全绿。源树`git ls-files -z`精确建立tracked index,临时repo无完整历史。基线目录带新增B迁移与原DB文件新增断言,但应用源码是HEAD;DB仍同一个test,应用基线总数不变。
- 保留 Docker runner `bug984-runner`(node22-bookworm)、DinD `bug984-docker`、volume `bug984-validation`;无线上数据/凭据。
- 基线 `/repo/frontend`;中间 `/repo/bug984-fixed/frontend`、`/repo/bug984-final/frontend`;最终 `/repo/bug984-delivery/frontend`。这些是容器内路径,主会话复验不要用Windows主检出路径。
- diagnostics追加修复使用新快照 `/repo/bug984-diagnostics/frontend`,旧delivery与其证据不覆盖;最终30个修改/新增frontend文件在stage前与新快照逐文件SHA256相同(0差异),含SQL与DB测试;stage后diff-check发现新增SQL/golden的CRLF,已仅转为LF(内容/JSON/SQL语义不变),最终字节比较28相同、这2项去CRLF后相同。快照文档为创建时点;之后只补最终数字/独立证据,不影响源码测试。新增三文件也按明确清单加入临时tracked index。
- 标准复跑:`MSYS_NO_PATHCONV=1 docker exec -w /repo/bug984-diagnostics/frontend bug984-runner npm test`;同命令末尾换 `npm run test:db`、`npm run build`、`./node_modules/.bin/tsc --noEmit`、`npm run lint`。SQL和DB测试在diagnostics补丁中未改,沿用两方实际40/40,不伪称重复跑DB。
- 日志(volume内绝对路径):`/repo/baseline-equipped-tests.log`、`/repo/baseline-build.log`、`/repo/baseline-gzip.json`;`/repo/fixed-standard-db.log`(标准40/40);`/repo/fixed-tests.log`、`/repo/final-tests.log`(中间失败留存);旧delivery `/repo/delivery-tests.log`、`/repo/delivery-build.log`、`/repo/delivery-gzip.json`;最终 `/repo/diagnostics-tests.log`、`/repo/diagnostics-targeted.log`、`/repo/diagnostics-lint.log`、`/repo/diagnostics-build.log`、`/repo/diagnostics-gzip.json`。
- 曾同步覆盖基线被权限系统拒绝,未重试覆盖,改为创建全新目录;首次final build抢在依赖copy完成前报next not found,等待copy完成再建成功。没有改业务/依赖声明来规避平台问题。
### 本轮既有断言/fixture调整(三栏)
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| 部分policy env返回algorithm null、旧cache可用、0fetch | native完整pair、旧cache不可用、1fetch | 部分身份不能绕过算法核验;完整pair仍0额外fetch |
| 版本接口失败旧cache reusable=true | false并只读 | 策略b,保留显示不等于当前缓存命中 |
| 业务test-support无显式部署身份 | 与其fixture一致的rectification-v5/policy-v2完整env | 正常业务回归有受控可信身份,故障测试单独清env;不放宽生产 |
| block业务fixture algorithm=test、无policy | 与受控env一致的v5/policy-v2 | 不改blocks/分数/选择预期,仅补真实身份语义 |
| 两个业务score替身algorithm误用event-contract-v2 | rectification-v5,event_contract_version保持原值 | 与test-support可信部署一致,不把滚动不一致误算正常业务 |
| 直接accept/confirm及collect替身没有dossier重读RPC | 补原candidate/dossier fixture | 新服务端写门必须重读,权限/确认/问题预期不变 |
| native policy滞后测试继承fixture env | 测试内清pair并恢复 | 必须真实走versions替身,不被完整env覆盖 |
| 无source采用返回candidate_not_found | RPC前result_identity_read_only | 提前拒绝不可核验具体结果,不能产生候选 |
| question/current_question/choice_card无只读分支,GET同步projection | 只读null,GET await可信projection | 历史直接刷新同样受保护,非删除断言 |
| 终止UI源码readonly && | historyReadonly && | 身份不可核验不等于Case已结束;不诱导强制新建 |
新增源形合同全部采用真实native golden;既有业务替身只是补身份或RPC,不声称为新合同golden。所有示例明确虚构,未使用用户账号或资料。
+1 -1
View File
@@ -293,7 +293,7 @@
| `TASK-rectification-validation-integrity-20260920.md` | `PROGRESS-rectification-validation-20260920.md` | **生时校正验证体系补缺(纯离线评测,不改打分不改产品)**:会议要求把「推断真实出生时间」与「用户认可的参考盘」分开证明。核对结论——**产品口径侧四条已落地**(`accepted`≠`confirmed` 两条写入路径、确认门 fail-closed 且 `holdout` 为 `not_ready` 使 `confirmation_allowed` 不可能为真、采用不写 `reported_birth_time`、无任何把采用率当准确率的指标;运行时也无按生日走捷径的分支);**缺口全在评测本身**。三条:① 封存契约 `rectification_sealed_holdout.v1.json` 的三个打分哈希互不相同(封存 `f41c298d` / 契约记录 `99730c84` / 基线实测 `b15d9ea1`),`official_eval_trial_count: 0`——当前实现**从未产出过一次有效官方盲测**,唯一跑过那次已被资料审计作废(top-1 `0.15`,发布门要 `0.60`),可见的 `0.45` 自带「不得当发布指标」标记(BUG-978);② 全部离线评测的候选窗**以真值为圆心**(`_candidate_moments()`、`request_from_case()` 的 `true_time`),生产以申报时间为圆心(`ENGINE_SEARCH_RADIUS_MINUTES = 15`)——「真值掉出窗外」这一失败模式从不可见(BUG-979);③ v3 封存但每例仅 3 事件、v4 有 7+ 事件却已被看过并用于调参,**无口径干净又贴近真实会话的封存集**;六题回放的 `0.80/0.55/0.35` 是「真值方向最优答」的上帝视角上界,±30/±60 仍低于发布门(BUG-980)。T1 申报偏差敏感性 sweep、T2 有效重跑 + 契约对齐 + **防复发新测试**、T3 只出 v5 采集协议、T4 记录。**硬红线:不得改 12 个打分文件、不得用封存集调参、不得把 `status` 改 `ready`。** 家庭信息(父母职业/兄弟姐妹)在拿到基线数字前不开工——现有七领域全是带日期事件,静态属性没有输入口。前置:owner-case-purge 三提交仍未合入 staging。BUG-978~980 | **已验收通过(2026-09-20,Claude 独立复算)**;独立盲测仍 blocked;门禁/部署待核验 | `932f2fff`。Claude 独立复核:T1 完整重跑 900 组合**逐例 8 字段 0 差异**、summary/specification 全等,另从 900 条逐例重算 45 格表与发布表逐位相同;T2 独立复跑 0.45/0.50/6.45/0/1.0/0.0 与哈希全序 0.05/0.10 全部一致;12 个打分文件字节未动、`status=not_ready`、`confirmation_coverage_rate=0`、实跑 `holdout_passed()`=False;新增/触及 Python 测试本机 38 条全绿;freshness 测试比对时排除 `frozen_at_utc`(BUG-693/694 教训已用上)。**执行方正确推翻任务书 T2.3**:「首次口径干净的官方盲测」与 BUG-428 防复发(已看过结果的案例不得再计入盲测)冲突,已核 BUG-428 原文,是任务书写错。环境缺口:本机 frontend 无 node_modules,tsc/lint/npm test/build 未能复核,采信执行方自报。遗留 → `TASK-rectification-cross-midnight-dasha-20260920.md`(BUG-981) |
| `TASK-rectification-cross-midnight-dasha-20260920.md` | — | **跨午夜候选的 Dasha 边界错一天(生产打分)**:`scoring_service.py` 调 `merge_transition_proximity()` 只传**一个** `birth_date`,该函数用它算全部候选的 Vimshottari / Narayana 起始日期,候选之间只靠 `_context_time()` 的 `HH:MM` 区分、**日期被丢掉**。窗口跨午夜时午夜后候选的 dasha 边界整体错一天。Claude 验收时在生产调用链独立复现:窗口 `23:50→00:10`、21 个候选,**恰好那 11 个跨日候选分数错、10 个同日候选逐位相同**(幅度 +0.0267 / −0.0133,本例头名未变)。算术上界 = `cap/kernel_width`,day 精度 0.067 分/件,18 件可累计约 1.2 分,而随分钟变化项总量仅约 2.1 分 —— **上界是推的不是实测,真实幅度本单必须实测**。踩中路径:`late_night` 时段 `23:00–03:59`、`unknown` `00:00–23:59`、23:45 后或 00:15 前申报的 ±15 窗。连带发现:`calculation_spec()` 不含打分实现身份,修复后同一 spec hash 对应不同分数,历史 Case 静默失去可复现性(同 BUG-427 类型)。**产品 2026-09-20 已就三点拍板:A 修、B 修完重新冻结并重跑 T1/T2、C 让新旧结果可区分。** C 的做法经查证已修正:`calculation_spec_hash` 全在 **V4** 链路、**V9 零引用**,原提案 bump `INPUT_CONTRACT_VERSION` 对真实历史无效已作废;改为随修复 bump `engine_version`(`v9EngineVersion()` 缺省串用于 started/failed;成功回执及 minute 缓存身份来自后端,旧「只写不比」假设作废),**不得动 `skill_version`**(BUG-621:open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开)。硬红线:只改「按候选日期取 dasha 起始」,不得动 kernel/cap/share 任一常数;确认门不变。BUG-981 | **核心修复与 BUG-985 已 review 通过,本地合并 b27d4de9;推 staging 认证失败,端到端仍受 BUG-984 阻塞** | 实现 `aa46da10`(分支 `codex/rectification-cross-midnight-20260920`,未合入 staging)。Claude 独立 review:修复后整窗打分与**逐候选独立重算 21/21 一致**;非跨午夜分数基线 vs 修复**逐位不变**(我测半径 10 六例 + 半径 60 三例,执行方证据覆盖 19 例 / 2299 候选全部 `bytes_equal`);kernel/cap/share/`PRECISION_WEIGHTS` 一个未动;`status=not_ready`、coverage 0、`holdout_passed()` False、官方试次 0;决策 C 执行正确(`ALGORITHM_VERSION` 7→8,`INPUT_CONTRACT_VERSION` 与 `skill_version` 未动,golden 里 `calculation_spec_hash` 不变可证);既有断言改动均带三栏说明且为加强。**P1 阻塞**:新增的 `test_same_day_public_aa_scores_keep_pre_fix_bytes` 写死 121 个浮点分数的 SHA-256,在 Claude 机器上 ordinal 2/3 红、**基线与修复分支同样红**(非修复所致),且经 `test_rectification_cross_midnight_gate.py` re-export 落入快速门 glob —— 同 glob 基线 214 passed/0 failed、`aa46da10` 4 failed,推 staging 会让门禁红。执行方自报未通过的 BUG-984 经独立确认成立且更重:`block_scan` 只比 evidence 指纹不读打分身份,而 `late_night`(299 min)/`unknown`(1439 min) 均 >120 走该分支、又恰是最易跨午夜的窗口 |
| `TASK-rectification-cross-midnight-gate-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-gate-fix-20260920.md` | **review 修复单:门禁级浮点哈希断言(`aa46da10` 的合入阻塞项)**:新增回归把 121 个分数的 SHA-256 写死成字面量,于是断言范围把跨机 libm/pyswisseph 差异也圈了进去。实测该组 121 个分数**全无浮点尾噪**(`repr(s)==repr(round(s,4))` 121/121),所以差异是第 4 位真的不同,靠改序列化消不掉。同一提交刚改过的 `test_rectification_engine_memoization.py` docstring 正好写着「跨机舍入已漂 1.1e-3,不得整体 `==` 比较」——教训被引用了又被踩。执行方自己的证据 JSON 用的却是正确做法(`scores_bytes_equal` = 同机基线 vs 当前)。**根因**:要证的是相对不变量(同机基线 vs 当前),却实现成绝对不变量(当前 vs 某台机器的历史哈希)。F1 改用生产代码已有的 legacy 回退路径做同机 A/B 对照(去掉 `candidate_at` 即修复前行为),验收要求**两台浮点环境不同的机器各跑一次**、且回退核心修复后该测试仍绿(证明它守的是同日不变性而非跨午夜回归的替身);F2 bridge 重复收集(4 failed = 2×2);F3 查证 `block_scan` 重算是否经过被修 helper —— 会则 BUG-981 在 late_night/unknown 路径等于没上线、BUG-984 升为阻塞项;F4 记录。**硬红线:不得删测试/skip/把哈希改成本机当前值消红,不得改已验收的打分代码。** 串行:本单 → `aa46da10` 合入 → BUG-984 补单。BUG-985 | **已 review 通过;BUG-985 resolved;合入推送被 Gitea 认证阻塞,远端仍 a3577ce2** | 实现 `25232ce4`(分支 `codex/rectification-cross-midnight-gate-fix-20260920`)。F1 改为同进程 A/B:在 `scoring_service.merge_transition_proximity` 调用边界剥掉 `candidate_at` 走生产既有 legacy 回退,对比 121 个分数与整份贡献矩阵的 canonical 字节;另加 `legacy_calls == [121]` 与 `static_contexts is contexts` 两道防空转保险。Claude 复核:写死哈希字面量 **0 残留**;**第三套环境(Linux + Python 3.13,与执行方 Windows 3.11.7 不同)定向 18 条全绿** → 两机验收闭环;**回退日期修复探针:同日 3 条全绿、跨午夜相关 4 条转红**,证明同日不变性与跨午夜正确性已真正分开;快速门 glob staging `1b646659` 200 passed/0 failed → `25232ce4` 216 passed/0 failed(+16,零新增失败,总数未降);相对合并点仅动 2 个测试文件 + 文档,`scripts/`、前端、golden、打分常数零改动。BUG-985 记录含我要求的防复发条,并正确认定为 BUG-733 同形复发。**建议 BUG-985 由 `blocked` 改 `resolved`**(证据即第三环境复跑)。环境缺口:完整快速门在 Claude 机器 120 秒超时被杀,6→4 那组数以执行方记录为准 |
| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **blocked:F2 A 局部完成,多 completed 混版本实测成立,等待 B 的 SQL 授权;F1/F3/F4 pending** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 |
| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **本地完成:diagnostics追加修复后全量3575→3588全绿,标准DB40/40,Static/完整28资源gzip+0.040085%,tsc/lint通过;主会话独立DB40与最终定向40全绿。未推送,真人/部署待主会话** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 |
## 命名与归档
@@ -1,18 +1,18 @@
# BUG-984 补单验收清单(2026-09-20)
当前 **blocked**:F2 A 局部实现,多 completed 混版本实测触发 B,SQL 未获授权;F1/F3 尚未实现。以下不是已通过清单,不能将本分支当完整修复部署。
B授权停点已解除,F1/F2/F3本地实现;标准DB40/40,完整结果以PROGRESS最终记录为准。下面区分自动化与尚未完成的受控staging真人验收,不能把本地通过等同已部署。
## 自动化续验
- [ ] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑。
- [ ] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。
- [ ] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。
- [ ] 失败后重试、成功后失败、不同 attempt、历史未带新 metadata 都覆盖,不能用当前 dossier 身份替代历史来源。
- [ ] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。
- [ ] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。
- [ ] 不经界面直接调所有采用/确认/候选选择写入口,旧只读结果均由服务端拒绝。
- [ ] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。
- [ ] tsc、lint、定向、全量、build `/` Static、首屏 gzip ±2%;实际平台失败与基线逐项对照,不削弱断言。
- [x] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑40/40。
- [x] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。
- [x] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。
- [x] 成功后失败重试、不同 attempt、历史未带新 metadata、latest failed-only均覆盖,不能用当前 dossier 身份替代历史来源。
- [x] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。
- [x] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。
- [x] 服务层采用/确认/推进、工具offer与compare/read/diagnostics、idle/repair行为回归,旧只读结果由服务端拒绝;真实HTTP伪造请求与浏览器交互留真人栏。
- [x] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。
- [x] diagnostics追加修复后tsc/lint0error、最终定向37/37、新快照全量3588/3588(基线3575/3575)、build `/` Static、完整28个首屏JS/CSS gzip+0.040085%;Windows平台问题以保真Linux补验,不削弱断言。
## 受控 staging 真人验收(由主会话安排)
@@ -20,9 +20,11 @@
- [ ] 先核对部署 gitCommit(允许其后纯文档差异),不是只看 HTTP 200。
- [ ] 依次测试分钟历史结果与 late-night 时段历史结果;记录是否缓存、实际来源版本、是否重算,不记个人资料。
- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝。
- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝;诊断工具不重计算,只读来源不变、can_confirm_exact_minute=false。
- [ ] 已生成历史回执来源保留,不显示 started 的部署版本;旧 Skill 会话仍可从侧栏打开。
- [ ] 恢复版本接口后当前同身份缓存可正常复用,身份变化按新规则重算,不删除或重标旧结果。
- [ ] 恢复版本接口后当前同身份缓存可正常复用;旧身份的非终止历史显示「重新比较」,点击确实启动一次既有流程,普通旧候选不能采用。打开/刷新本身不强制重算;接口再次失败则仍只读,不删除或重标旧结果。
- [ ] 历史首次打开与刷新即有只读说明,不必先compare;unknown时无重新比较按钮,不错误显示「该校正已结束」。
- [ ] 新算响应版本与可信当前pair不一致时保存真实来源,仍不可采用,不能被前端默认版本重标。
- [ ] 失败重试后活动与成功来源一致;多人/多个请求压力下没有把版本接口抖动放大成强制重计算。
当前没有浏览器级完成证据,本 agent 未调用线上。详见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。
+4
View File
@@ -2,6 +2,10 @@
This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract.
## 校正历史结果身份只读(2026-09-20)
历史打开、刷新与比较返回的结果若无法核实为当前算法及策略,消息区顶部复用 `rectification-pending-note`、`role="status"` 显示「按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。」保留原历史内容和来源,不出现强制重算入口;关闭旧结果选项、候选采用及普通输入,输入提示为「当前结果仅供查看」。可信当前身份已知但与旧结果不同,非终止会话可显式点「重新比较」,复用既有发送链路;身份未知时无此入口,服务端再次探测失败也不能重算。此状态不等同 Case 已结束,不显示误导的结束提示或额外「再次校正」按钮。真正终止的历史仍沿用原结束操作。服务端独立核验所有结果写入,不以界面禁用代替权限。无新颜色、加载动画或 Home 状态。
## 普通对话寒暄轮(2026-09-20)
服务端标记 `responseKind: smalltalk` 的回复仅展示正文与既有头像/消息操作,不展示思考面板、活动步骤、已完成步骤或技法证据;刷新后的历史也遵循此规则。分类尚未确定、且没有任何真实 activity/thinking/timeline 时,保留消息位置,不虚构「正在分析」或步骤,不另加 spinner。咨询收到真实活动后恢复既有呈现。完成后读取服务端账户余额,寒暄预留点数原额退回,净点数不变;不增加 Home 状态。
@@ -22,6 +22,7 @@ const acceptSchema = z.object({
function errorResponse(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const known = [
["result_identity_read_only", 409, "按旧算法产出,暂不能采用"],
["agentic_rectification_candidate_not_found", 404, "候选记录不存在或已过期"],
["agentic_rectification_candidate_expired", 409, "候选结果已过期,请重新比较"],
["agentic_rectification_candidate_selection_blocked", 409, "当前还不能采用候选"],
@@ -114,6 +115,8 @@ export async function POST(request: Request, context: RouteContext) {
const { publicDecisionFields } = await import(
"@/lib/rectification-agentic/core/rectification-decision"
);
const { assertV9ResultWritable } = await import("@/lib/rectification-agentic/v9/result-identity");
await assertV9ResultWritable(accounting, user.id, caseId, parsed.data.resultId);
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
@@ -12,7 +12,7 @@ import {
listV10ConversationFocuses,
} from "@/lib/rectification-agentic/v9/tool-service";
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
export const runtime = "nodejs";
@@ -77,7 +77,7 @@ export async function POST(request: Request, context: RouteContext) {
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json({
ok: true,
...dossierResponse(dossier, receipts, skillIdentity, { listed }),
...await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { listed }),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
@@ -13,7 +13,7 @@ import {
listV10ConversationFocuses,
safeToolErrorCode,
} from "@/lib/rectification-agentic/v9/tool-service";
import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response";
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
export const runtime = "nodejs";
@@ -96,7 +96,7 @@ export async function GET(request: Request, context: RouteContext) {
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, listed }));
return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { fullDetail, listed }));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const message = error.message;
@@ -64,6 +64,7 @@ import {
isAbortError,
rectificationConversationState,
rectificationInitialLiveLabel,
recompareHistoricalResult,
rectificationQuestionGapState,
rectificationReadonlyRangeCopy,
contrastProbesFromReceipt,
@@ -389,7 +390,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
readonly,
readonly: historyReadonly,
shouldStartOpening,
initialTurns,
initialSnapshot,
@@ -410,6 +411,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
headerSlot,
} = props;
const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null);
const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true);
const readonly = historyReadonly || Boolean(resultNotice);
const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const queued = useQueuedMessage();
@@ -583,6 +588,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} | null) => {
if (!payload) return;
const snapshot = payload as RectificationCaseSnapshotPayload;
setResultNotice(snapshot.case?.result_notice ?? null);
setCanRecompare(snapshot.case?.result_identity?.can_recompare === true);
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextQuestion = currentQuestionFromSnapshot(payload.current_question);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
@@ -693,7 +700,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>,
) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || readonly) return;
if ((action === "message" && !trimmed) || conversationReadonly) return;
if (!continuation && busy) return;
setError("");
setPending(true);
@@ -1018,7 +1025,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
lastRunOutcome.current = readonly ? "readonly" : runOutcome;
setPending(false);
}
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
}, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (busy) return;
@@ -1688,6 +1695,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
aria-busy={busy || regeneratingMessageKey !== null}
>
<div className="message-list">
{resultNotice && <p role="status" className="rectification-pending-note">{resultNotice}</p>}
{resultNotice && canRecompare && !historyReadonly && (
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
)}
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
@@ -1822,7 +1833,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
{historyReadonly && (
<div className="rectification-terminal-actions">
<p className="rectification-terminal-note">该校正已结束,只能查看历史。</p>
<Button type="button" onClick={onRestart}>再次校正</Button>
@@ -1839,8 +1850,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
inputRef={composer}
value={draft}
remainingId={composerRemainingId}
inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
placeholder={readonly
inputLabel={resultNotice ? "当前结果仅供查看" : readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
placeholder={resultNotice
? "当前结果仅供查看,暂不能选择或采用。"
: readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: showLiveChoiceCard
? "点上面的选项即可;想补一句细节再写"
@@ -619,6 +619,8 @@ export async function applyRectificationChoice(
accounting: AccountingClient,
command: ApplyChoiceCommand,
): Promise<AppliedChoiceReceipt> {
const { assertV9ResultWritable } = await import("./result-identity.ts");
await assertV9ResultWritable(accounting, command.userId, command.caseId);
const dossier = await loadV9CaseDossier(accounting, command.userId, command.caseId);
if (dossier.case.sessionId !== command.sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
@@ -1003,6 +1005,12 @@ export async function persistNextInterviewAfterChoice(input: {
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
const identityDossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) {
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
}
const birthDate = input.birthDate ?? null;
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
let decisionState = input.decisionState;
@@ -1296,6 +1304,8 @@ export async function requestTieBreakPersonality(input: {
hostNarration: string;
focus: ConversationFocus | null;
}> {
const { assertV9ResultWritable } = await import("./result-identity.ts");
await assertV9ResultWritable(input.accounting, input.userId, input.caseId);
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
if (dossier.case.acceptedTime) {
return {
@@ -1478,6 +1488,11 @@ export async function applyCollectFocusDenial(
? RECTIFICATION_USER_COPY.collectSkippedAck
: RECTIFICATION_USER_COPY.collectDeclinedAck;
const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId);
const { resultIdentityView } = await import("./result-identity.ts");
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
throw new RectificationToolServiceError("result_identity_read_only");
}
const focus = dossier.conversationSummary.activeFocus;
if (!focus || focus.id !== input.focusId) {
throw new RectificationToolServiceError("agentic_rectification_focus_not_active");
@@ -1627,6 +1642,11 @@ export async function persistNextInterviewIfIdle(input: {
askedTurnId: input.askedTurnId ?? null,
});
let dossier = rescored.dossier;
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) {
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
}
const finishIdle = <T extends { hostNarration: string | null; terminalNote?: boolean }>(result: T): T => {
let hostNarration = result.hostNarration;
if (dossier.case.acceptedTime && hostNarration) {
@@ -2423,6 +2443,11 @@ export async function ensureNonTerminalTurnExit(input: {
caseId: string;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
const before = await inspectNonTerminalTurnExit(input);
const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts");
const { readV9EngineScoringIdentity } = await import("./engine-client.ts");
if (resultIdentityView(before.dossier, await readV9EngineScoringIdentity()).read_only) {
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
}
if (before.satisfied) {
return { persisted: false, choiceReady: false, hostNarration: null };
}
@@ -47,6 +47,8 @@ export async function mutateCaseForBlockChoice(input: {
if (!isBlockChoiceSchema(input.schema)) {
return { dossier: input.dossier, snapshotCurrent: true };
}
const { assertV9ResultWritable } = await import("./result-identity.ts");
await assertV9ResultWritable(input.accounting, input.userId, input.caseId, null);
const window = windowFromBlockChoice({
schema: input.schema,
optionId: input.optionId,
@@ -105,6 +107,8 @@ export async function mutateCaseForWidenWindow(input: {
if (!isWidenWindowSchema(input.schema)) {
return { dossier: input.dossier, snapshotCurrent: true };
}
const { assertV9ResultWritable } = await import("./result-identity.ts");
await assertV9ResultWritable(input.accounting, input.userId, input.caseId);
const chosen = windowFromWidenChoice({
schema: input.schema,
optionId: input.optionId,
@@ -58,6 +58,8 @@ export type BlockScanBlock = Readonly<{
export type BlockScanPayload = Readonly<{
evidenceLedgerFingerprint?: string | null;
algorithmVersion?: string | null;
policyVersion?: string | null;
candidateRangeFingerprint?: string | null;
minuteStep?: number | null;
declinedAtFingerprint?: string | null;
rounds?: number | null;
@@ -132,6 +134,8 @@ export function parseBlockScanPayload(value: unknown): BlockScanPayload | null {
? row.evidence_ledger_fingerprint
: null,
algorithmVersion: typeof row.algorithm_version === "string" ? row.algorithm_version : null,
policyVersion: typeof row.decision_policy_version === "string" ? row.decision_policy_version : null,
candidateRangeFingerprint: typeof row.candidate_range_fingerprint === "string" ? row.candidate_range_fingerprint : null,
minuteStep: typeof row.minute_step === "number" ? row.minute_step : null,
declinedAtFingerprint: typeof row.declined_at_fingerprint === "string"
? row.declined_at_fingerprint
@@ -145,6 +149,8 @@ export function serializeBlockScanPayload(payload: BlockScanPayload): Record<str
return {
evidence_ledger_fingerprint: payload.evidenceLedgerFingerprint ?? null,
algorithm_version: payload.algorithmVersion ?? null,
decision_policy_version: payload.policyVersion ?? null,
candidate_range_fingerprint: payload.candidateRangeFingerprint ?? null,
minute_step: payload.minuteStep ?? null,
declined_at_fingerprint: payload.declinedAtFingerprint ?? null,
rounds: payload.rounds ?? 0,
@@ -1,3 +1,5 @@
import { resultIdentityView, OLD_ALGORITHM_NOTICE } from "./result-identity.ts";
import { readV9EngineScoringIdentity, type LiveEngineScoringIdentity } from "./engine-client.ts";
import {
evidenceLedgerFingerprint,
loadV9CaseSkillIdentityStatus,
@@ -20,12 +22,21 @@ import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectifica
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
import { normalizeBirthTimeSource } from "@/lib/rectification-agentic/birth-time-provenance";
export async function dossierResponseWithIdentity(
...args: Parameters<typeof dossierResponse>
) {
return dossierResponse(args[0], args[1], args[2], {
...args[3], liveIdentity: await readV9EngineScoringIdentity(),
});
}
export function dossierResponse(
dossier: V9CaseDossier,
receipts: Array<Awaited<ReturnType<typeof loadV9TurnReceipt>>>,
skillIdentity: Awaited<ReturnType<typeof loadV9CaseSkillIdentityStatus>>,
options: { fullDetail?: boolean; listed?: ConversationFocusList } = {},
options: { fullDetail?: boolean; listed?: ConversationFocusList; liveIdentity?: LiveEngineScoringIdentity } = {},
) {
const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null });
const decision = decideFromDossier(dossier, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
@@ -38,11 +49,13 @@ export function dossierResponse(
: fields;
const turns = attachOfferResultToTurns(turnsWithQuestions, {
resultId: dossier.latestResult?.resultId ?? null,
canAdopt: overlaid.can_adopt === true,
canAdopt: !identity.read_only && overlaid.can_adopt === true,
acceptedTime: dossier.case.acceptedTime,
});
return {
case: {
result_identity: identity,
result_notice: identity.read_only ? OLD_ALGORITHM_NOTICE : null,
case_id: dossier.case.caseId,
session_id: dossier.case.sessionId,
status: dossier.case.status,
@@ -67,22 +80,24 @@ export function dossierResponse(
status: turn.status,
created_at: turn.createdAt,
receipt: turnReceipt(turn.id, receipts),
question: turn.question,
question: identity.read_only ? null : turn.question,
offer_result_id: turn.offer_result_id,
})),
evidence: dossier.evidence,
latest_result: dossier.case.stage === "block_scan" || decision.sessionOutcome === "compare_blocks"
? null
: dossier.latestResult
? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true)
? { ...publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true),
...(identity.read_only ? { can_adopt: false, selectionAllowed: false, confirmationAllowed: false, read_only: true } : {}) }
: null,
interview: {
...fields,
...(identity.read_only ? { can_adopt: false, selection_allowed: false, confirmation_allowed: false, validated: false, read_only: true } : {}),
birth_time_source: birthTimeSource,
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: choiceCardFromCaseDossier(dossier),
current_question: identity.read_only ? null : projectCurrentQuestion(dossier.conversationSummary.activeFocus),
choice_card: identity.read_only ? null : choiceCardFromCaseDossier(dossier),
step_state: stepStateFromCaseDossier(dossier),
next_user_action: nextUserActionFromDossier(dossier),
question_source: questionSourceFromFocusList(listed),
@@ -422,6 +422,19 @@ export function scoringIdentityFromEnginePayload(
};
}
export function scoringIdentityIsTrusted(identity: LiveEngineScoringIdentity): boolean {
return Boolean(identity.algorithmVersion && identity.policyVersion);
}
export function scoringIdentityMatches(
stored: { algorithmVersion?: string | null; policyVersion?: string | null } | null | undefined,
live: LiveEngineScoringIdentity,
): boolean {
return Boolean(stored && scoringIdentityIsTrusted(live)
&& stored.algorithmVersion === live.algorithmVersion
&& stored.policyVersion === live.policyVersion);
}
export function cachedEngineScoreIsReusable(
stored: {
evidenceLedgerFingerprint?: string | null;
@@ -435,20 +448,21 @@ export function cachedEngineScoreIsReusable(
if (!stored) return false;
if (stored.evidenceLedgerFingerprint !== fingerprints.evidenceLedgerFingerprint) return false;
if (stored.candidateRangeFingerprint !== fingerprints.candidateRangeFingerprint) return false;
if (live.policyVersion && stored.policyVersion && stored.policyVersion !== live.policyVersion) {
return false;
}
if (live.algorithmVersion && stored.algorithmVersion && stored.algorithmVersion !== live.algorithmVersion) {
return false;
}
return true;
return scoringIdentityMatches(stored, live);
}
export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringIdentity> {
const fromEnv = liveEngineScoringIdentityFromEnv();
if (fromEnv.policyVersion || fromEnv.algorithmVersion) return fromEnv;
if (scoringIdentityIsTrusted(fromEnv)) return fromEnv;
try {
return scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
const native = scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
// Partial overrides cannot invent the missing half of a computation identity.
// A conflicting partial override is not a coherent identity pair.
if ((fromEnv.algorithmVersion && fromEnv.algorithmVersion !== native.algorithmVersion)
|| (fromEnv.policyVersion && fromEnv.policyVersion !== native.policyVersion)) {
return { algorithmVersion: null, policyVersion: null };
}
return native;
} catch {
return { algorithmVersion: null, policyVersion: null };
}
@@ -1014,6 +1028,7 @@ export async function runV9RangeReading(input: {
export type V9BlockScanResult = Readonly<{
resultId: string | null;
algorithmVersion: string;
policyVersion: string | null;
minuteStep: number;
blocks: readonly BlockScanBlock[];
}>;
@@ -1042,6 +1057,7 @@ export async function runV9BlockScan(input: {
return {
resultId: typeof data.result_id === "string" ? data.result_id : null,
algorithmVersion: parsed.algorithmVersion ?? String(data.algorithm_version ?? ""),
policyVersion: scoringIdentityFromEnginePayload(record(data.decision_receipt)).policyVersion,
minuteStep: parsed.minuteStep ?? 10,
blocks: parsed.blocks,
};
@@ -0,0 +1,42 @@
import {
readV9EngineScoringIdentity,
scoringIdentityMatches,
scoringIdentityIsTrusted,
type LiveEngineScoringIdentity,
} from "./engine-client.ts";
import {
loadV9CaseDossier,
RectificationToolServiceError,
type AccountingClient,
type V9CaseDossier,
} from "./tool-service.ts";
export const OLD_ALGORITHM_NOTICE = "按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。";
/** Read-time projection only. Never persist or relabel a historical source. */
export function resultIdentityView(dossier: V9CaseDossier, live: LiveEngineScoringIdentity) {
const source = dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult;
const hasAnyResult = Boolean(dossier.case.blockScan || dossier.latestResult);
return {
read_only: hasAnyResult && !scoringIdentityMatches(source, live),
can_recompare: hasAnyResult && scoringIdentityIsTrusted(live) && !scoringIdentityMatches(source, live),
algorithm_version: source?.algorithmVersion ?? null,
policy_version: source?.policyVersion ?? null,
};
}
/** Server-owned check at every candidate mutation boundary, not a client flag. */
export async function assertV9ResultWritable(
accounting: AccountingClient, userId: string, caseId: string, resultId?: string | null,
): Promise<void> {
const dossier = await loadV9CaseDossier(accounting, userId, caseId);
if (!dossier.latestResult && !dossier.case.blockScan && resultId === undefined) return;
const live = await readV9EngineScoringIdentity();
if (resultIdentityView(dossier, live).read_only
|| (resultId === null && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult))
|| (typeof resultId === "string" && (dossier.case.stage !== "minute"
|| dossier.latestResult?.resultId !== resultId
|| !scoringIdentityMatches(dossier.latestResult, live)))) {
throw new RectificationToolServiceError("result_identity_read_only");
}
}
@@ -31,6 +31,8 @@ import {
executedMethodsFromLedger,
mergeVedastroValidateIntoReceipt,
readV9EngineScoringIdentity,
scoringIdentityIsTrusted,
scoringIdentityMatches,
runV9BlockScan,
runV9CandidateScore,
runV9VedastroValidate,
@@ -61,6 +63,7 @@ export type ScorePersistTimings = Readonly<{
}>;
export type ScorePersistResult = Readonly<{
readOnly?: boolean;
persisted: V9PersistedCandidate | Readonly<{
resultId: string;
cached: boolean;
@@ -71,7 +74,7 @@ export type ScorePersistResult = Readonly<{
representativeTime: null;
algorithmVersion: string;
eventContractVersion: string;
policyVersion: string;
policyVersion: string | null;
decisionReceipt: Readonly<Record<string, unknown>>;
executionLedger: readonly [];
}>;
@@ -87,6 +90,7 @@ export async function persistBlockScanPayload(input: {
userId: string;
caseId: string;
evidenceFingerprint: string;
rangeFingerprint?: string;
scan: Awaited<ReturnType<typeof runV9BlockScan>>;
previousRounds?: number;
}): Promise<void> {
@@ -96,6 +100,8 @@ export async function persistBlockScanPayload(input: {
await writeV9BlockScan(input.accounting, input.userId, input.caseId, {
evidence_ledger_fingerprint: input.evidenceFingerprint,
algorithm_version: input.scan.algorithmVersion,
decision_policy_version: input.scan.policyVersion,
candidate_range_fingerprint: input.rangeFingerprint ?? null,
minute_step: input.scan.minuteStep,
rounds: isSub ? (input.previousRounds ?? 0) + 1 : 0,
blocks: input.scan.blocks,
@@ -167,8 +173,15 @@ export async function scoreAndPersistCurrentEvidence(input: {
if (!candidateRange) throw new RectificationToolServiceError("case_range_missing");
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence);
const rangeFingerprint = candidateRangeFingerprint(candidateRange, compute.baselineProfileFingerprint);
const liveIdentity = await readV9EngineScoringIdentity();
const readOnly = !scoringIdentityIsTrusted(liveIdentity);
if (readOnly && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult)) {
throw new RectificationToolServiceError("engine_identity_unavailable");
}
if (
dossier.case.stage === "block_scan"
!readOnly
&& dossier.case.stage === "block_scan"
&& shouldForceMinuteAfterSubBlocks(dossier.case.blockScan)
) {
await setV9CaseStage(input.accounting, input.userId, input.caseId, "minute");
@@ -176,11 +189,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
}
if (dossier.case.stage === "block_scan") {
const cachedPayload = dossier.case.blockScan;
const cached = Boolean(
cachedPayload
&& cachedPayload.evidenceLedgerFingerprint === evidenceFingerprint,
);
if (!cached) {
const cached = cachedEngineScoreIsReusable(cachedPayload, {
evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint,
}, liveIdentity);
if (!cached && !readOnly) {
const events = toEngineEvents(scorable);
const extras = blockScanRequestExtras(candidateRange);
const scoreStarted = Date.now();
@@ -197,11 +209,13 @@ export async function scoreAndPersistCurrentEvidence(input: {
userId: input.userId,
caseId: input.caseId,
evidenceFingerprint,
rangeFingerprint,
scan,
previousRounds: cachedPayload?.rounds ?? 0,
});
const reloaded = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
return {
readOnly: !scoringIdentityMatches(scan, liveIdentity),
persisted: {
resultId: scan.resultId ?? "",
cached: false,
@@ -212,7 +226,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
representativeTime: null,
algorithmVersion: scan.algorithmVersion,
eventContractVersion: "",
policyVersion: "",
policyVersion: scan.policyVersion,
decisionReceipt: {},
executionLedger: [],
},
@@ -228,9 +242,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
};
}
return {
readOnly,
persisted: {
resultId: "",
cached: true,
cached,
candidates: [],
overallConfidence: "low",
selectionAllowed: false,
@@ -238,7 +253,7 @@ export async function scoreAndPersistCurrentEvidence(input: {
representativeTime: null,
algorithmVersion: cachedPayload?.algorithmVersion ?? "",
eventContractVersion: "",
policyVersion: "",
policyVersion: cachedPayload?.policyVersion ?? null,
decisionReceipt: {},
executionLedger: [],
},
@@ -253,25 +268,19 @@ export async function scoreAndPersistCurrentEvidence(input: {
},
};
}
const rangeFingerprint = candidateRangeFingerprint(
candidateRange,
compute.baselineProfileFingerprint,
);
const events = toEngineEvents(scorable);
const previousReceipt = keepAnswers ? dossier.latestResult?.decisionReceipt : null;
const askedProbeKeys = keepAnswers
? askedSemanticKeysForEngine(previousReceipt, dossier.evidence)
: [];
const latest = dossier.latestResult;
const liveIdentity = await readV9EngineScoringIdentity();
if (
keepAnswers
&& latest
&& cachedEngineScoreIsReusable(
latest
&& (readOnly || (keepAnswers && cachedEngineScoreIsReusable(
latest,
{ evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint },
liveIdentity,
)
)))
) {
const ledger = latest.executionLedger ?? [];
const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt);
@@ -287,7 +296,8 @@ export async function scoreAndPersistCurrentEvidence(input: {
let vedastroMs = 0;
let persistMs = 0;
if (
latest.selectionAllowed
!readOnly
&& latest.selectionAllowed
&& primary
&& runnerUp
&& primary !== runnerUp
@@ -317,13 +327,14 @@ export async function scoreAndPersistCurrentEvidence(input: {
persistMs = Date.now() - persistStarted;
}
return {
readOnly,
persisted: {
resultId: latest.resultId,
cached: true,
cached: !readOnly,
candidates,
overallConfidence: latest.overallConfidence,
selectionAllowed: candidateProjection.consistent && latest.selectionAllowed,
confirmationAllowed: latest.confirmationAllowed,
selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed,
confirmationAllowed: !readOnly && latest.confirmationAllowed,
representativeTime,
algorithmVersion: latest.algorithmVersion,
eventContractVersion: latest.eventContractVersion,
@@ -340,10 +351,10 @@ export async function scoreAndPersistCurrentEvidence(input: {
candidates,
overallConfidence: latest.overallConfidence,
marginPercent: null,
acceptanceAllowed: latest.selectionAllowed,
selectionAllowed: candidateProjection.consistent && latest.selectionAllowed,
proposeAllowed: latest.selectionAllowed,
confirmationAllowed: latest.confirmationAllowed,
acceptanceAllowed: !readOnly && latest.selectionAllowed,
selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed,
proposeAllowed: !readOnly && latest.selectionAllowed,
confirmationAllowed: !readOnly && latest.confirmationAllowed,
representativeCandidateId: null,
representativeTime,
decisionReceipt,
@@ -383,7 +394,8 @@ export async function scoreAndPersistCurrentEvidence(input: {
});
const engineCompareMs = Date.now() - scoreStarted;
const vedastroStarted = Date.now();
const receipt = await persistableReceipt(score, {
const responseReadOnly = !scoringIdentityMatches(score, liveIdentity);
const receipt = responseReadOnly ? score.decisionReceipt : await persistableReceipt(score, {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange,
events,
@@ -451,8 +463,9 @@ export async function scoreAndPersistCurrentEvidence(input: {
executionLedger: score.executionLedger,
});
return {
persisted,
score,
readOnly: responseReadOnly,
persisted: responseReadOnly ? { ...persisted, selectionAllowed: false, confirmationAllowed: false } : persisted,
score: responseReadOnly ? { ...score, selectionAllowed: false, confirmationAllowed: false, acceptanceAllowed: false, proposeAllowed: false } : score,
dossier,
windowScan: score.windowScan,
decisionReceipt,
@@ -10,6 +10,7 @@
* event arrays, scores or permission decisions.
*/
import { createHash } from "node:crypto";
import { assertV9ResultWritable } from "./result-identity.ts";
import type { AdoptedCredibleRangePayload } from "../../report-candidate-range.ts";
import {
EVIDENCE_KINDS,
@@ -1546,6 +1547,7 @@ export async function advanceV9CaseFromBlockScan(
caseId: string,
window: { start_time: string; end_time: string },
): Promise<{ start_time: string; end_time: string }> {
await assertV9ResultWritable(accounting, userId, caseId, null);
const row = await rpc<Record<string, unknown>>(
accounting,
"advance_agentic_rectification_case_from_block_scan",
@@ -1730,6 +1732,7 @@ export async function persistV9InferenceState(
decisionReceipt: Readonly<Record<string, unknown>>;
decisionStateFingerprint: string;
}>> {
await assertV9ResultWritable(accounting, userId, caseId, null);
const row = await rpc<Record<string, unknown>>(
accounting,
"append_agentic_rectification_inference_transition",
@@ -1794,6 +1797,9 @@ export async function persistV9ChoiceAction(
narration: string | null;
receipt: Readonly<Record<string, unknown>>;
}>> {
// Non-scoring choices were checked before their window/evidence mutation;
// rechecking an intentionally cleared block payload would reject its audit.
if (input.inference) await assertV9ResultWritable(accounting, userId, caseId, null);
const inference = input.inference
? {
expected_revision: input.inference.expectedRevision,
@@ -1907,6 +1913,7 @@ export async function acceptV9Candidate(
if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
await assertV9ResultWritable(accounting, userId, caseId, resultId);
const row = await rpc<Record<string, unknown>>(
accounting,
"accept_agentic_rectification_candidate_for_case_v2",
@@ -1957,6 +1964,7 @@ export async function confirmV9BirthTime(
if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) {
throw new RectificationToolServiceError("invalid_candidate_ref");
}
await assertV9ResultWritable(accounting, userId, caseId, input.resultId);
const row = await rpc<Record<string, unknown>>(
accounting,
"confirm_agentic_rectification_candidate_for_case_v2",
@@ -2018,6 +2026,8 @@ export function safeToolErrorCode(error: unknown): string {
"candidate_not_found",
"candidate_expired",
"candidate_selection_blocked",
"result_identity_read_only",
"engine_identity_unavailable",
"candidate_time_not_allowed",
"candidate_already_selected",
"candidate_superseded",
@@ -47,6 +47,16 @@ export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问";
export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。";
export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。";
export async function recompareHistoricalResult(input: {
canRecompare: boolean;
historyReadonly: boolean;
busy: boolean;
send: (action: "message", text: string) => Promise<void>;
}): Promise<void> {
if (!input.canRecompare || input.historyReadonly || input.busy) return;
await input.send("message", "请按当前算法重新比较候选。");
}
export function isAbortError(caught: unknown): boolean {
return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError";
}
@@ -79,6 +89,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{
type?: unknown;
}>;
case?: Readonly<{
result_notice?: string | null;
result_identity?: Readonly<{ can_recompare?: boolean }>;
status?: unknown;
accepted_time?: unknown;
confirmed_time?: unknown;
+65 -2
View File
@@ -64,6 +64,7 @@ import { inclusiveClockWidthMinutes, indistinguishableWidthMinutes } from "@/lib
import { rankActive } from "@/lib/rectification-agentic/core/convergence-evaluator";
import { unionStillValidRange } from "@/lib/rectification-agentic/core/credible-range";
import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan";
import { assertV9ResultWritable, resultIdentityView, OLD_ALGORITHM_NOTICE } from "@/lib/rectification-agentic/v9/result-identity";
import { scoreAndPersistCurrentEvidence as persistCurrentEvidenceScore } from "@/lib/rectification-agentic/v9/score-persist";
import {
buildConfirmationGate,
@@ -158,6 +159,8 @@ import {
runV9Diagnostics,
runV9VedastroValidate,
readV9EngineScoringIdentity,
scoringIdentityIsTrusted,
scoringIdentityMatches,
cachedEngineScoreIsReusable,
toEngineEvents,
executedMethodsFromLedger,
@@ -998,6 +1001,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
// Ranking stays fail-open when compute is unavailable.
}
const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate });
const currentDossier = await loadV9CaseDossier(accounting, userId, caseId);
if (resultIdentityView(currentDossier, await readV9EngineScoringIdentity()).read_only) {
return { collectingPlan: { ...collectingPlan, next_followup: null },
persistedFocus: { status: "skipped" as const, focus: null, questionId: null, prompt: null },
contrastPacket, decision };
}
const followup = collectingPlan.next_followup;
const persistedFocus = await persistServerOwnedFocus({
accounting,
@@ -1033,8 +1042,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
if (parsed.scorable.length === 0 || !parsed.case.candidateRange) {
return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null };
}
const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity());
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) {
if (!identity.read_only && parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) {
const persisted = parsed.latestResult
? await persistPlanFocus(parsed, parsed.latestResult)
: null;
@@ -1050,6 +1060,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
};
}
const scored = await scoreAndPersistCurrentEvidence(targetCaseId);
if (scored.readOnly) {
return { status: "skipped" as const, executedMethods: [] as const, errorCode: "result_identity_read_only", errorKind: null, cached: false, openQuestion: null };
}
const latest = scored.parsed.case.stage === "block_scan"
? null
: {
@@ -1108,6 +1121,19 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
let dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
let parsed = parseDossierForTools(dossier);
const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity());
if (identity.read_only) {
const storedProjection = projectTurnDecision(dossier);
const projection = { ...storedProjection, read_only: true,
notice: OLD_ALGORITHM_NOTICE, can_adopt: false, current_question: null, choice_card: null,
current_probe: null, inference: null,
candidate_summary: { ...(storedProjection.candidate_summary as Record<string, unknown>), selection_allowed: false, validated: false },
can_recompare: identity.can_recompare,
next_action: { type: identity.can_recompare ? "compare_candidates" : "read_only", selection_allowed: false, validated: false } };
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
hasReadCase = true;
return projection;
}
if (input.resume === true && parsed.case.status === "paused") {
await transitionV9CaseStatus(
accounting,
@@ -1925,6 +1951,17 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint });
try {
const scored = await scoreAndPersistCurrentEvidence(input.caseId);
if (scored.readOnly) {
const projection = { read_only: true, notice: OLD_ALGORITHM_NOTICE,
algorithm_version: scored.persisted.algorithmVersion, cached: false,
candidates: scored.persisted.candidates, can_adopt: false,
selection_allowed: false, confirmation_allowed: false, open_question: null };
await receipt("rectification-compare-candidates", "candidates.comparing", "completed", {
inputFingerprint, resultFingerprint: hashResult(projection),
engineVersion: scored.persisted.algorithmVersion ?? null, executedMethods: [],
});
return projection;
}
const latest = scored.parsed.case.stage === "block_scan"
? null
: {
@@ -2000,6 +2037,27 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
await receipt("rectification-read-diagnostics", "diagnostics.completed", "started", { inputFingerprint });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
const liveIdentity = await readV9EngineScoringIdentity();
if (!scoringIdentityIsTrusted(liveIdentity)) {
const identity = resultIdentityView(dossier, liveIdentity);
if (!identity.read_only) throw new RectificationToolServiceError("engine_identity_unavailable");
const projection = {
...identity,
notice: OLD_ALGORITHM_NOTICE,
diagnostics: null,
can_confirm_exact_minute: false,
executed_methods: [],
};
await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(projection),
engineVersion: identity.algorithm_version,
executedMethods: [],
});
return projection;
}
// With a trusted identity this is an independent calculation, not a
// validation of the stored candidate. Keep its actual response source.
const parsed = parseDossierForTools(dossier);
if (parsed.scorable.length === 0) {
throw new RectificationToolServiceError("no_scorable_evidence");
@@ -2015,12 +2073,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
parsed.evidence,
),
});
const readOnly = !scoringIdentityMatches(diagnostics, liveIdentity);
const projection = {
engine_result_id: diagnostics.engineResultId,
algorithm_version: diagnostics.algorithmVersion,
policy_version: diagnostics.policyVersion,
read_only: readOnly,
notice: readOnly ? OLD_ALGORITHM_NOTICE : null,
diagnostics: diagnostics.diagnostics,
missing_layers: diagnostics.missingLayers,
can_confirm_exact_minute: diagnostics.canConfirmExactMinute,
can_confirm_exact_minute: !readOnly && diagnostics.canConfirmExactMinute,
};
await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", {
inputFingerprint,
@@ -2052,6 +2114,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
throw new RectificationToolServiceError("no_candidate_result");
}
const latest = parsed.latestResult;
await assertV9ResultWritable(accounting, userId, input.caseId, latest.resultId);
const { decision } = sessionAwareFollowupForParsed(parsed, latest);
const currentSnapshot = snapshotSourceFromDossier(parsed, null);
const storedSnapshot = storedSnapshotSource(latest);
@@ -0,0 +1,174 @@
-- BUG-984: result provenance comes from the last completed result in the
-- selected attempt. Preserve owner/turn/attempt checks and the public shape.
-- Compatible with old writers; no table changes or historical row updates.
begin;
create or replace function public.get_agentic_rectification_turn_receipt(
p_user_id uuid,
p_case_id uuid,
p_turn_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_turn public.agentic_rectification_turns%rowtype;
v_case public.agentic_rectification_cases%rowtype;
v_attempt_id uuid;
v_phases jsonb;
v_tool_activities jsonb;
v_tools jsonb;
v_methods jsonb;
v_engine_version text;
begin
if p_user_id is null or p_case_id is null or p_turn_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select * into v_turn
from public.agentic_rectification_turns
where id = p_turn_id and case_id = p_case_id;
if not found then
raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001';
end if;
v_attempt_id := v_turn.successful_attempt_id;
if v_attempt_id is null then
select id into v_attempt_id
from public.agentic_rectification_run_attempts
where turn_id = p_turn_id
order by attempt_number desc
limit 1;
end if;
select coalesce(jsonb_agg(
jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name)
order by rp.sequence, rp.created_at, rp.id
), '[]'::jsonb) into v_phases
from public.agentic_rectification_run_phases rp
where rp.turn_id = p_turn_id
and (
(v_attempt_id is not null and rp.attempt_id = v_attempt_id)
or (v_attempt_id is null and rp.attempt_id is null)
);
with latest_terminal as materialized (
select distinct on (tr.tool_name)
tr.id,
tr.tool_name,
tr.status,
tr.executed_methods,
tr.started_at,
tr.safe_error_code,
tr.result_fingerprint
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id
and tr.status in ('completed', 'failed')
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.tool_name, tr.started_at desc, tr.id desc
),
latest_started as materialized (
select distinct on (tr.tool_name)
tr.tool_name,
tr.started_at
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id
and tr.status = 'started'
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.tool_name, tr.started_at desc, tr.id desc
)
select
coalesce(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object(
'tool', terminal.tool_name,
'status', terminal.status,
'methods', case
when terminal.status = 'completed' then terminal.executed_methods
else '[]'::jsonb
end,
'started_at', coalesce(started.started_at, terminal.started_at),
'elapsed_ms', greatest(
0,
(extract(epoch from (
terminal.started_at - coalesce(started.started_at, terminal.started_at)
)) * 1000)::int
),
'error', terminal.safe_error_code,
'result_fingerprint', terminal.result_fingerprint
)) order by coalesce(started.started_at, terminal.started_at), terminal.id
), '[]'::jsonb),
coalesce(jsonb_agg(terminal.tool_name order by coalesce(started.started_at, terminal.started_at), terminal.id)
filter (where terminal.status = 'completed'), '[]'::jsonb)
into v_tool_activities, v_tools
from latest_terminal terminal
left join latest_started started on started.tool_name = terminal.tool_name;
with latest_terminal as materialized (
select distinct on (tr.tool_name)
tr.tool_name,
tr.status,
tr.executed_methods
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id
and tr.status in ('completed', 'failed')
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.tool_name, tr.started_at desc, tr.id desc
)
select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods
from (
select distinct jsonb_array_elements_text(terminal.executed_methods) as method
from latest_terminal terminal
where terminal.status = 'completed'
) methods;
select tr.engine_version into v_engine_version
from public.agentic_rectification_tool_receipts tr
where tr.turn_id = p_turn_id and tr.engine_version is not null
and tr.status = 'completed'
and (
(v_attempt_id is not null and tr.attempt_id = v_attempt_id)
or (v_attempt_id is null and tr.attempt_id is null)
)
order by tr.started_at desc, tr.id desc
limit 1;
return jsonb_build_object(
'turn_id', v_turn.id,
'attempt_id', v_attempt_id,
'status', v_turn.status,
'skill_name', v_case.skill_name,
'skill_version', v_case.skill_version,
'engine_version', v_engine_version,
'phases', v_phases,
'tool_activities', v_tool_activities,
'tools', v_tools,
'methods', v_methods,
'started_at', v_turn.created_at,
'completed_at', v_turn.completed_at
);
end;
$$;
revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid)
to service_role;
commit;
@@ -120,6 +120,52 @@ test("block_scan RPCs are service_role-only and advance a declared period", { sk
assert.equal(opened.error, null, rpcError(opened.error));
const caseId = String((opened.data as Record<string, unknown>).case_id);
// BUG-984: identities belong to completed results, never deployment/start rows.
// All rows are synthetic; the timestamp/id order, not the version text, wins.
const turnId = fixture.psql(`
insert into public.agentic_rectification_turns (case_id, status, model_name, assistant_message)
values ('${caseId}', 'completed', 'fixture', 'fixture') returning id;
`).split("\n")[0];
const receipt = async (owner = userId, turn = turnId) => service.rpc("get_agentic_rectification_turn_receipt", {
p_user_id: owner, p_case_id: caseId, p_turn_id: turn,
});
const addReceipt = (id: string, status: string, version: string | null, second: number, attempt: string | null = null, tool = "rectification-compare-candidates") => fixture.psql(`
insert into public.agentic_rectification_tool_receipts
(id, case_id, turn_id, tool_name, public_phase, status, engine_version, started_at, attempt_id)
values ('${id}', '${caseId}', '${turnId}', '${tool}', 'candidates.updated', '${status}',
${version ? `'${version}'` : "null"}, '2026-09-20 00:00:${String(second).padStart(2, "0")}+00',
${attempt ? `'${attempt}'` : "null"});
`);
const version = (n: number) => `rectification-v5-matrix-scoring-${n}`;
addReceipt("10000000-0000-4000-8000-000000000001", "started", version(8), 1);
addReceipt("10000000-0000-4000-8000-000000000002", "completed", version(7), 2);
assert.equal((await receipt()).data?.engine_version, version(7));
addReceipt("10000000-0000-4000-8000-000000000003", "completed", version(9), 3);
addReceipt("10000000-0000-4000-8000-000000000004", "completed", version(10), 4, null, "rectification-read-diagnostics");
addReceipt("10000000-0000-4000-8000-000000000005", "failed", version(99), 5);
assert.equal((await receipt()).data?.engine_version, version(10));
// Same timestamp: stable id tie-break, independent of insertion/lexical version order.
addReceipt("10000000-0000-4000-8000-000000000006", "completed", version(11), 4);
assert.equal((await receipt()).data?.engine_version, version(11));
const attempt1 = fixture.psql(`
insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at)
values ('${caseId}', '${turnId}', 1, 'completed', now()) returning id;
`).split("\n")[0];
addReceipt("10000000-0000-4000-8000-000000000007", "completed", version(10), 6, attempt1);
fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = '${attempt1}' where id = '${turnId}'`);
const attempt2 = fixture.psql(`
insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at)
values ('${caseId}', '${turnId}', 2, 'failed', now()) returning id;
`).split("\n")[0];
addReceipt("10000000-0000-4000-8000-000000000008", "failed", version(99), 7, attempt2);
assert.equal((await receipt()).data?.engine_version, version(10), "failed retry cannot replace successful attempt");
fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = null where id = '${turnId}'`);
assert.equal((await receipt()).data?.engine_version, null, "latest failed-only attempt has no result provenance");
assert.match(rpcError((await receipt("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /case_not_found/);
assert.match(rpcError((await receipt(userId, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /turn_not_found/);
assert.equal(fixture.psql(`select engine_version from public.agentic_rectification_tool_receipts where id = '10000000-0000-4000-8000-000000000002'`), version(7), "historical rows are not relabelled");
assert.equal(fixture.psql(`select has_function_privilege('authenticated', 'public.get_agentic_rectification_turn_receipt(uuid,uuid,uuid)', 'EXECUTE')`), "f");
const staged = await service.rpc("set_agentic_rectification_case_stage", {
p_user_id: userId,
p_case_id: caseId,
File diff suppressed because it is too large Load Diff
@@ -735,14 +735,16 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.doesNotMatch(chat, /send\("message", choiceCardUserMessage/);
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
assert.doesNotMatch(chat, /choiceCardUserMessage/);
assert.match(caseProjection, /choice_card: choiceCardFromCaseDossier/);
// 原值: 直接卡片投影;新值: 只读时 null;原因: BUG-984 不能选择旧结果。
assert.match(caseProjection, /choice_card: identity\.read_only \? null : choiceCardFromCaseDossier/);
// 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。
// 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。
// 原因: BUG-725。
assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/);
assert.match(messageEntry, /disabled=\{!liveQuestion\}/);
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/);
assert.match(caseProjection, /current_question: projectCurrentQuestion/);
// 原值: 无条件问题;新值: 只读不提供可写问题;原因: BUG-984。
assert.match(caseProjection, /current_question: identity\.read_only \? null : projectCurrentQuestion/);
assert.match(caseProjection, /overlayPublicDecision/);
assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/);
// 原值: 断言读 cases/[caseId]/route.ts 的 interview 投影
@@ -85,7 +85,9 @@ const EVIDENCE = [
function blockPayload(extra: Record<string, unknown> = {}) {
return {
evidence_ledger_fingerprint: "f".repeat(64),
algorithm_version: "test",
// Explicit fixture identity matches the test deployment; no unknown identity bypass.
algorithm_version: "rectification-v5",
decision_policy_version: "rectification-candidate-policy-v2",
minute_step: 10,
blocks: [...BLOCKS],
...extra,
@@ -366,6 +366,8 @@ test("persistNextInterviewAfterChoice after family denial asks the invite, not o
};
const accounting = fakeAccounting({
...receiptHandlers,
// 新增服务端身份重读 fixture;原采集/提示断言不变。
get_agentic_rectification_case_dossier: () => dossierFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
@@ -206,7 +206,8 @@ const ENGINE_SCORE = {
success: true,
endpoint: "rectification_v5_score",
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
algorithm_version: "rectification-event-contract-v2",
// 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同仍独立保留。
algorithm_version: "rectification-v5",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
@@ -9,7 +9,13 @@ import {
v9EngineVersion,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { openRectificationCase } from "../src/lib/rectification-agentic/v9/case-service.ts";
import { loadV9TurnReceipt } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { loadV9TurnReceipt, parseV9CaseDossier, evidenceLedgerFingerprint, candidateRangeFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { scoreAndPersistCurrentEvidence } from "../src/lib/rectification-agentic/v9/score-persist.ts";
import { acceptV9Candidate, confirmV9BirthTime, advanceV9CaseFromBlockScan } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { assertV9ResultWritable, resultIdentityView } from "../src/lib/rectification-agentic/v9/result-identity.ts";
import { ensureNonTerminalTurnExit, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { dossierResponseWithIdentity } from "../src/lib/rectification-agentic/v9/case-dossier-response.ts";
import { recompareHistoricalResult } from "../src/lib/rectification-surface-state.ts";
import { resolveSkillPackageVersion } from "../src/lib/skill-package-registry.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
@@ -80,7 +86,7 @@ test("native versions invalidate matching-input old scores while same-version sc
assert.equal(previous.algorithmVersion, PREVIOUS);
});
test("policy-only or stale algorithm overrides bypass native versions under the existing cache policy", async (t) => {
test("partial override is completed by native identity; complete override needs no request", async (t) => {
isolateIdentityEnv(t);
let fetches = 0;
t.mock.method(globalThis, "fetch", async () => {
@@ -91,22 +97,245 @@ test("policy-only or stale algorithm overrides bypass native versions under the
const stored = { ...fingerprints, algorithmVersion: PREVIOUS, policyVersion: golden.versions.decision_policy_version };
process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
const policyOnly = await readV9EngineScoringIdentity();
assert.equal(policyOnly.algorithmVersion, null);
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), true);
// 原值 null/true;新值 CURRENT/false;原因:部分覆盖不能绕过实际算法身份。
assert.equal(policyOnly.algorithmVersion, CURRENT);
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), false);
process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS;
const staleOverride = await readV9EngineScoringIdentity();
assert.equal(staleOverride.algorithmVersion, PREVIOUS);
assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, staleOverride), true);
assert.equal(fetches, 0);
assert.equal(fetches, 1); // 原值 0;部分覆盖必须补齐,完整覆盖仍不请求。
});
test("unreachable versions retain the existing unknown-identity cache fallback, not proof of rescore", async (t) => {
test("unreachable versions never validate a cached result as current", async (t) => {
isolateIdentityEnv(t);
t.mock.method(globalThis, "fetch", async () => { throw new Error("fixture versions unavailable"); });
const live = await readV9EngineScoringIdentity();
assert.deepEqual(live, { algorithmVersion: null, policyVersion: null });
const fingerprints = { evidenceLedgerFingerprint: "e".repeat(64), candidateRangeFingerprint: "c".repeat(64) };
assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), true);
// 原值 true;新值 false;原因:BUG-984 策略 b,只读不是当前缓存命中。
assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), false);
});
for (const storedVersion of [PREVIOUS, CURRENT]) {
test(`native late-night block cache checks actual identity: ${storedVersion}`, async (t) => {
isolateIdentityEnv(t);
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
const range = { start_time: block.request.start_time, end_time: block.request.end_time };
const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null });
const fingerprint = evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence);
raw.case.block_scan = {
...block.response, algorithm_version: storedVersion,
decision_policy_version: block.response.decision_receipt.decision_policy_version,
evidence_ledger_fingerprint: fingerprint,
candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)),
};
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record<string, unknown>; return {}; },
});
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (url: unknown) => {
requests.push(String(url));
return Response.json(String(url).endsWith("/versions") ? golden.versions : block.response);
});
const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS);
assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS);
const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID });
assert.equal(result.persisted.algorithmVersion, CURRENT);
assert.equal(result.persisted.cached, storedVersion === CURRENT);
assert.equal(requests.filter((url) => url.endsWith("/block_scan")).length, storedVersion === CURRENT ? 0 : 1);
assert.ok(requests.some((url) => url.endsWith("/versions")));
});
}
for (const storedVersion of [PREVIOUS, CURRENT]) {
test(`native minute cache checks actual identity: ${storedVersion}`, async (t) => {
isolateIdentityEnv(t);
const range = { start_time: golden.request.start_time, end_time: golden.request.end_time };
const raw = dossierFixture({ candidateRange: range, latestResult: null });
raw.latest_result = {
...golden.score, ...golden.score.decision_receipt,
result_id: golden.score.result_id, candidates: golden.score.candidate_decisions,
algorithm_version: storedVersion, decision_receipt: golden.score.decision_receipt,
evidence_ledger_fingerprint: evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence),
candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)),
};
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
persist_agentic_rectification_candidate_v2: (_fn, args) => ({
...golden.score.decision_receipt, result_id: args.p_engine_result_id,
candidates: args.p_candidates, algorithm_version: args.p_algorithm_version,
decision_policy_version: args.p_decision_policy_version, decision_receipt: args.p_decision_receipt,
}),
});
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (url: unknown) => {
requests.push(String(url));
assert.ok(String(url).endsWith("/versions") || String(url).endsWith("/score"));
return Response.json(String(url).endsWith("/versions") ? golden.versions : golden.score);
});
const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS);
assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS);
const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, keepAnswers: true });
assert.equal(result.persisted.algorithmVersion, CURRENT);
assert.equal(result.persisted.cached, storedVersion === CURRENT);
assert.equal(requests.filter((url) => url.endsWith("/score")).length, storedVersion === CURRENT ? 0 : 1);
assert.ok(requests.some((url) => url.endsWith("/versions")));
});
}
test("stale history exposes explicit recompare and real tools replace old provenance", async (t) => {
isolateIdentityEnv(t);
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
const range = { start_time: block.request.start_time, end_time: block.request.end_time };
const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null, blockScan: { ...block.response, algorithm_version: PREVIOUS } });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record<string, unknown>; return {}; },
set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_domain: args.p_target_domain, expected_answer_schema: args.p_expected_answer_schema, status: "active" }, idempotent: false }),
});
let scans = 0;
t.mock.method(globalThis, "fetch", async (url: unknown) => {
if (String(url).endsWith("/versions")) return Response.json(golden.versions);
assert.ok(String(url).endsWith("/block_scan"));
scans++;
return Response.json(block.response);
});
const tools = createRectificationV9Tools({ accounting: accounting.client as never, userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID });
const read = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
assert.equal(read.can_recompare, true);
assert.equal((read.next_action as Record<string, unknown>).type, "compare_candidates");
assert.equal(scans, 0, "opening history is not forced recomputation");
await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID });
assert.equal(scans, 1);
const refreshed = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never);
assert.equal(refreshed.case.result_identity.read_only, false);
assert.equal(refreshed.case.result_identity.algorithm_version, CURRENT);
assert.equal(refreshed.case.result_identity.policy_version, golden.versions.decision_policy_version);
const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(component, /resultNotice && canRecompare && !historyReadonly/);
assert.match(component, /recompareHistoricalResult\(\{ canRecompare, historyReadonly, busy, send \}\)/);
assert.match(component, /if \(\(action === "message" && !trimmed\) \|\| conversationReadonly\) return/);
const sent: string[] = [];
const send = async (action: "message", text: string) => { sent.push(`${action}:${text}`); };
await recompareHistoricalResult({ canRecompare: true, historyReadonly: false, busy: false, send });
assert.deepEqual(sent, ["message:请按当前算法重新比较候选。"]);
for (const state of [{ canRecompare: false, historyReadonly: false, busy: false }, { canRecompare: true, historyReadonly: true, busy: false }, { canRecompare: true, historyReadonly: false, busy: true }]) {
await recompareHistoricalResult({ ...state, send });
}
assert.equal(sent.length, 1, "unknown identity, terminal history and busy clicks cannot dispatch");
});
test("partial conflicts and incomplete native pairs never authorize reuse", async (t) => {
isolateIdentityEnv(t);
process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS;
t.mock.method(globalThis, "fetch", async () => Response.json(golden.versions));
assert.deepEqual(await readV9EngineScoringIdentity(), { algorithmVersion: null, policyVersion: null });
delete process.env.RECTIFICATION_ALGORITHM_VERSION;
t.mock.method(globalThis, "fetch", async () => Response.json({ algorithm_version: CURRENT }));
const incomplete = await readV9EngineScoringIdentity();
assert.equal(incomplete.policyVersion, null);
assert.equal(cachedEngineScoreIsReusable({ algorithmVersion: CURRENT, evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, { evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, incomplete), false);
});
for (const stage of ["minute", "block_scan"] as const) {
for (const failure of ["http", "timeout", "partial"] as const) {
test(`${stage} ${failure}: historical result stays read-only without recompute; server mutations reject`, async (t) => {
isolateIdentityEnv(t);
if (failure === "partial") process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8"));
const range = { start_time: golden.request.start_time, end_time: golden.request.end_time };
const raw = dossierFixture({ stage, candidateRange: range, latestResult: {
...golden.score, ...golden.score.decision_receipt,
result_id: golden.score.result_id, candidates: golden.score.candidate_decisions,
algorithm_version: PREVIOUS, decision_receipt: golden.score.decision_receipt,
}, blockScan: { ...block.response, algorithm_version: PREVIOUS } });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }),
});
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (url: unknown) => {
requests.push(String(url));
assert.ok(String(url).endsWith("/versions"), "no scoring/oracle call on identity failure");
if (failure === "timeout") throw new DOMException("fixture timeout", "TimeoutError");
return Response.json({ error: "fixture_unavailable" }, { status: 503 });
});
const input = { accounting: accounting.client, userId: USER_ID, caseId: CASE_ID };
const result = await scoreAndPersistCurrentEvidence(input);
assert.equal(result.readOnly, true);
assert.equal(result.persisted.cached, false);
assert.equal(result.persisted.algorithmVersion, PREVIOUS);
assert.equal(result.persisted.selectionAllowed, false);
assert.equal(result.persisted.confirmationAllowed, false);
const response = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], {
status: "verified", requiresSkillAdoption: false,
} as never);
assert.match(response.case.result_notice ?? "", /按旧算法产出/);
assert.equal(response.case.result_identity.algorithm_version, PREVIOUS);
assert.equal(response.case.result_identity.can_recompare, false);
assert.equal(response.choice_card, null);
const candidateId = golden.score.candidate_decisions[0].candidate_id;
await assert.rejects(acceptV9Candidate(accounting.client, USER_ID, CASE_ID, golden.score.result_id, candidateId, TURN_ID), /result_identity_read_only/);
await assert.rejects(confirmV9BirthTime(accounting.client, USER_ID, CASE_ID, {
resultId: golden.score.result_id, candidateId, requestId: TURN_ID, consentQuote: "fixture", sourceTurnId: TURN_ID,
}), /result_identity_read_only/);
await assert.rejects(advanceV9CaseFromBlockScan(accounting.client, USER_ID, CASE_ID, range), /result_identity_read_only/);
const tools = createRectificationV9Tools({ ...input, turnId: TURN_ID, accounting: accounting.client as never });
const projection = await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<{ read_only: boolean }> }).execute({ caseId: CASE_ID });
assert.equal(projection.read_only, true);
const completed = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_status === "completed");
assert.equal(completed?.args.p_engine_version, PREVIOUS);
const readProjection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
assert.equal(readProjection.read_only, true);
assert.equal((readProjection.candidate_summary as Record<string, unknown>).selection_allowed, false);
const diagnostics = await (tools["rectification-read-diagnostics"] as unknown as { execute(input: unknown): Promise<Record<string, unknown>> }).execute({ caseId: CASE_ID });
assert.equal(diagnostics.read_only, true);
assert.match(String(diagnostics.notice), /按旧算法产出/);
assert.equal(diagnostics.algorithm_version, PREVIOUS);
assert.equal(diagnostics.can_confirm_exact_minute, false);
assert.equal(diagnostics.diagnostics, null);
assert.deepEqual(diagnostics.executed_methods, []);
const diagnosticsReceipt = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_tool_name === "rectification-read-diagnostics" && call.args.p_status === "completed");
assert.equal(diagnosticsReceipt?.args.p_engine_version, PREVIOUS);
await assert.rejects((tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID }), /result_identity_read_only/);
assert.equal((await ensureNonTerminalTurnExit(input)).persisted, false);
assert.equal((await persistNextInterviewIfIdle(input)).persisted, false);
assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_") || call.fn === "insert_agentic_rectification_tool_receipt"), "no candidate, focus, inference or validation writes");
assert.ok(requests.length > 0);
});
}
}
test("missing stage source, absent results and nonlatest result IDs cannot authorize candidate writes", async (t) => {
isolateIdentityEnv(t);
process.env.RECTIFICATION_ALGORITHM_VERSION = CURRENT;
process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version;
const latest = { ...golden.score, result_id: golden.score.result_id, candidates: golden.score.candidate_decisions };
const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")).response;
for (const raw of [
dossierFixture({ stage: "block_scan", latestResult: latest, blockScan: null }),
dossierFixture({ stage: "minute", latestResult: null, blockScan: block }),
dossierFixture({ stage: "minute", latestResult: null, blockScan: null }),
dossierFixture({ stage: "minute", latestResult: latest }),
]) {
const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => raw });
await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, TURN_ID), /result_identity_read_only/);
assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_")));
}
const empty = dossierFixture({ latestResult: null, blockScan: null });
const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => empty });
await assertV9ResultWritable(accounting.client, USER_ID, CASE_ID); // ordinary new-case collection remains allowed
await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, null), /result_identity_read_only/);
assert.equal(resultIdentityView(parseV9CaseDossier(empty)!, { algorithmVersion: null, policyVersion: null }).read_only, false);
});
async function runGoldenToolSequence(t: test.TestContext, options: {
@@ -164,7 +393,18 @@ async function runGoldenToolSequence(t: test.TestContext, options: {
for (const name of ["rectification-compare-candidates", "rectification-read-diagnostics"] as const) {
const execute = () => (tools[name] as unknown as { execute(input: unknown): Promise<unknown> }).execute({ caseId: CASE_ID });
if (options.diagnosticsFailure && name === "rectification-read-diagnostics") await assert.rejects(execute);
else await execute();
else {
const projection = await execute() as { read_only?: boolean; algorithm_version?: string; can_confirm_exact_minute?: boolean };
if (name === "rectification-compare-candidates" && options.compareVersion && options.compareVersion !== CURRENT) {
assert.equal(projection.read_only, true, "rolling response mismatch stays read-only after saving true provenance");
assert.equal(projection.algorithm_version, options.compareVersion);
}
if (name === "rectification-read-diagnostics" && options.diagnosticsVersion && options.diagnosticsVersion !== CURRENT) {
assert.equal(projection.read_only, true);
assert.equal(projection.algorithm_version, options.diagnosticsVersion);
assert.equal(projection.can_confirm_exact_minute, false, "rolling diagnostics cannot claim current validation");
}
}
}
assert.ok(calls.includes("/api/rectification/v5/score"));
const persisted = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2");
@@ -187,7 +427,7 @@ test("failed diagnostics do not claim an engine result identity", async (t) => {
]);
});
test("BUG-984 blocker: real tool sequence permits mixed completed identities in one turn", async (t) => {
test("mixed completed identities require chronological aggregation (covered by database regression)", async (t) => {
// Identity-only mutation models rolling backend versions; all response shape and values
// remain the real native golden. This is not a claim to have run future algorithms.
const version9 = "rectification-v5-matrix-scoring-9";
@@ -196,8 +436,8 @@ test("BUG-984 blocker: real tool sequence permits mixed completed identities in
const completed = receipts.filter((call) => call.args.p_status === "completed");
assert.equal(new Set(completed.map((call) => call.args.p_turn_id)).size, 1);
assert.deepEqual(completed.map((call) => call.args.p_engine_version), [version9, version10]);
// Preserve the blocker as positive evidence, not a false passing acceptance test.
// F2 acceptance must replace this diagnostic once the approved aggregate fix lands.
// This proves why string max is invalid; database-rectification-block-scan.test.ts
// executes the authorized aggregate migration and verifies chronological selection.
assert.equal([version9, version10].sort().at(-1), version9);
assert.notEqual([version9, version10].sort().at(-1), completed.at(-1)?.args.p_engine_version);
});
@@ -41,7 +41,8 @@ const ENGINE_SCORE = {
success: true,
endpoint: "rectification_v5_score",
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
algorithm_version: "rectification-event-contract-v2",
// 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同不变。
algorithm_version: "rectification-v5",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
@@ -74,7 +74,8 @@ test("question-on-turn migration adds asked_turn_id and answer_option", () => {
test("GET turns attach question by asked_turn_id and never by timestamps", () => {
assert.match(caseProjection, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/);
assert.match(caseProjection, /question: turn\.question/);
// 原值 turn.question;新值只读 null;原因 BUG-984,仍以 asked_turn_id 绑定历史。
assert.match(caseProjection, /question: identity\.read_only \? null : turn\.question/);
assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/);
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
// 原值: if (!focus.askedTurnId) continue
@@ -516,7 +516,8 @@ test("list focuses RPC error marks GET questions unavailable and warns", async (
// 新值: 投影在 case-dossier-response.ts,GET 仍 return NextResponse.json(dossierResponse)
// 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。
assert.match(caseDossierResponse, /question_source: questionSourceFromFocusList\(listed\)/);
assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/);
// 原值同步 dossierResponse;新值 await 身份投影;原因 BUG-984 历史刷新同样核验。
assert.match(casesRoute, /return NextResponse\.json\(await dossierResponseWithIdentity/);
});
test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => {
@@ -217,7 +217,8 @@ test("missing current_question is explicit only for resumable cases", () => {
assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/);
assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/);
assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/);
assert.match(chat, /readonly && \(/);
// 原值 readonly;新值 historyReadonly;原因:结果身份只读不等于 Case 已结束。
assert.match(chat, /historyReadonly && \(/);
assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/);
});
@@ -638,7 +638,8 @@ test("accept-candidate requires a server-persisted result; no tool means no minu
resultId: RESULT_ID,
candidateId: CANDIDATE_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"),
// 原值 candidate_not_found;新值 result_identity_read_only;原因:无来源在写 RPC 前即拒绝,仍不得产生候选。
(error: unknown) => error instanceof Error && error.message.includes("result_identity_read_only"),
);
});
@@ -39,6 +39,7 @@ import {
test("accepted is never upgraded to confirmed by the accept path", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
accept_agentic_rectification_candidate_for_case_v2: () => ({
success: true,
saved_time: "05:02",
@@ -56,6 +57,7 @@ test("accepted is never upgraded to confirmed by the accept path", async () => {
test("accept fails closed if the accept-only RPC reports confirmed", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
accept_agentic_rectification_candidate_for_case_v2: () => ({
success: true,
saved_time: "05:02",
@@ -76,6 +78,7 @@ test("accept fails closed if the accept-only RPC reports confirmed", async () =>
test("confirmed requires the engine gate plus explicit grounded consent", async () => {
// confirmation_allowed=false on the stored result blocks confirmation.
const blocked = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
confirm_agentic_rectification_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_confirmation_blocked");
},
@@ -93,6 +96,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
// Confirming a time that is not the representative minute is rejected.
const mismatch = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
confirm_agentic_rectification_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_confirm_time_mismatch");
},
@@ -110,6 +114,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async
// Consent quote must be grounded in the source turn's message.
const ungrounded = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
confirm_agentic_rectification_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_consent_not_grounded");
},
@@ -178,6 +183,7 @@ test("confirm-birth-time binds consent to the server-owned current turn", async
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
accept_agentic_rectification_candidate_for_case_v2: () => ({
success: true,
saved_time: "05:02",
@@ -200,6 +206,7 @@ test("candidate ownership is case-scoped: the RPC always receives the case id",
test("accept is idempotent: replaying the same selection succeeds without a second write", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
accept_agentic_rectification_candidate_for_case_v2: () => ({
success: true,
saved_time: "05:02",
@@ -742,7 +749,15 @@ test("compare-candidates reuses a matching fingerprint without calling the engin
}
});
test("compare-candidates rescores when stored policy lags the live engine", async () => {
test("compare-candidates rescores when stored policy lags the live engine", async (t) => {
const algorithm = process.env.RECTIFICATION_ALGORITHM_VERSION;
const policy = process.env.RECTIFICATION_DECISION_POLICY_VERSION;
delete process.env.RECTIFICATION_ALGORITHM_VERSION;
delete process.env.RECTIFICATION_DECISION_POLICY_VERSION;
t.after(() => {
process.env.RECTIFICATION_ALGORITHM_VERSION = algorithm;
process.env.RECTIFICATION_DECISION_POLICY_VERSION = policy;
});
const rawDossier = dossierFixture();
const parsed = parseV9CaseDossier(rawDossier);
const compute = parseV9ComputeProjection(computeFixture());
@@ -1,6 +1,11 @@
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
// Unit fixtures below describe this explicit engine/policy pair. Production now
// requires both; identity failure tests clear these and exercise real discovery.
process.env.RECTIFICATION_ALGORITHM_VERSION = "rectification-v5";
process.env.RECTIFICATION_DECISION_POLICY_VERSION = "rectification-candidate-policy-v2";
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";