fix(w05): 终审两条 Critical + 地基缺陷的另一半 + 文档与欠账收口

**C1 新建芯片类型会改写既有字典行(共享主数据被静默污染)**
链条逐行复核成立:件上种子行漏挂 data-col → barred 与 isSeq 双保护同时失效
(isSeq 那条是因为 Task 7b 把 type_id 从 integer 改成了 text)→ 落进「全表一致」,
而 H35 的自投影恰好保证这张表只有一行 → 人新建的类型被认成既有 type_id=1 →
确认时随范围放行把既有字典行改名并落进正式表,屏上只说「改了 1 行」。
三条各自成立的裁定叠出来的静默污染。
修法:种子行改成 disabled 的真实 <input data-col=type_id>(desk.ts 零改动),
格子里摆「由系统生成」——不能一声不吭地拿掉,那样人会以为这张档少了一格。
测试不止断言结果:另加两条用**真实 resolveRowKey** 重放病灶/修法两种 cells 形状,
直接证明因果机制。平台根治(全表一致应排除 auto 列)记 debt。

**C2 两个启动参数都选填 → 留空则整单永久无解**
gate_reason 说「在下面把两格补上」,而块① 没有加一行、也不在 gateLedgers ——
一句指向空气的指路。改为必填;第一档改口为唯一真无解的那条路径(两格填了同一个号)。

**I1 地基缺陷只修了一半:recipe.let 同样按对象键序求值**
与 fields 是一模一样的假设,同样住 jsonb 列、同样被 PG 重排;今天不炸只因 W05 那五条
互不引用,而 ADR-014 是给后续所有活动用的。新增 orderLets/letReferences
(把 {in:X} 视为依赖 —— 语义与 fields 不同,故单独实现),auto-runner 与静态审同步接入。

**I2 审核卡按「重做」照样收步推进** —— 坐实为真实平台缺陷(「卡答过了就收步」快道不看裁决),
并证明活动侧加选项救不了、chosenGuidance 那条也救不了(那是喂模型的提示词,
而这条快道存在的目的就是跳过模型)。按裁定只留复现测试,平台未改。

**I4** 同一堵墙撞第二次即发兜底执行工单交人 + 立监听(头一次软化留痕),
不再空转到 exhausted 而人无处可改。

debt +4(C1 根治 / modules→app / thead-after-tbody / data-by 落回外层),
并补写此前被两处引用却从不存在的「refersTo 传递闭包」条目。
文档:ADR-014:201「按声明顺序求值」改对(照它写配方的人会重犯本轮的地基缺陷)、
陈旧注释四处、spec 四处分叉逐条判断处置、端到端测试记录标为历史、
CLAUDE.md 纠正「新建类型端到端通了」那句(它没通过真人路)并更新版本与文档表。

包版本 2.1.9(已装,SQL 直查库内 version/required/gate_reason/视图 HTML 四项核实)。
pnpm gates 退出码 0(2476 通过 / 34 跳过);E04 92/92、I01 55/55 无回归。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-30 16:09:18 +08:00
co-authored by Claude Opus 5
parent 9f5b1c99d3
commit 45cec82984
19 changed files with 1078 additions and 69 deletions
+7 -2
View File
@@ -1669,7 +1669,12 @@
**e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见
`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 /
都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口
`assessment-released` 全对;类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
`assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻**
(见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**,
没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是
「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目),
这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。
类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
(`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择,
`prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例
卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说
@@ -1704,7 +1709,7 @@
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
| | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) |
| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` |
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 |
| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) |
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
@@ -127,9 +127,14 @@ describe('清单本身合规', () => {
* 2026-08-29 重写:启动表单从「需求 + 原PN + 候选数组 + datasheet」四件收到「原PN + 候选」两件
* (单数,一次评估恰一对一候选)—— `requestDescription` / `candidateInternalPns`(数组)/
* `datasheets` 三键随 P-01 需求主记录一起撤下(本活动不再碰它)。逐条断言见「契约 > 启动两参」。
*
* **2026-08-30 终审 Critical 2 改判**:两格从「都选填」改成**都必填**——旧断言
* `toEqual([])` 钉的正是那条留空即永久卡死在 S1 的死路(gate_reason 指向一条不存在的
* 「加一行」路径),详见「契约 > 启动两参」那条测试与 `manifest.ts`/`case-model.ts`
* 里的大注释。「客户与需求两类字段已随重写撤下」这半句结论不变。
*/
it('起单一格必填都不设(2026-08-28 用户定);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual([]);
it('起单两格都必填(2026-08-30 修 Critical 2 改判);客户与需求两类字段已随重写从启动表单撤下', () => {
expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']);
const launchProps = Object.keys(manifest.contract.input.data['properties'] as Record<string, unknown>);
expect(launchProps).not.toContain('customerCode');
expect(launchProps).not.toContain('customerName');
@@ -262,11 +267,20 @@ describe('台账声明', () => {
});
describe('契约', () => {
it('启动两参,都选填 —— 缺了由第一步派工单补', () => {
/*
* **2026-08-30 终审 Critical 2**:两颗料号是这件事的身份,不是可以事后补的细节 ——
* 都选填时留空,`sub_eval.part` 的两行会因为同一个空主键 `internal_pn=''` 撞成一行,
* `gate_reason` 判「还没填全」而块① 没有「加一行」的路,doneWhen 永不成立,整单
* 永久停在 S1(链条见 `manifest.ts`/`case-model.ts` 的大注释)。改必填不是把人堵死——
* 平台既有的「受理归一」机制(`task-service.ts` 的 `missingRequired`/`askCompletion`)
* 接住它:起单缺这两格,任务落成 `awaiting-human`,一张补全卡问负责人要,答完才开跑,
* 不是门口拒单。主数据(品牌/类型/参数)仍然缺了才由第一步派数据工单补。
*/
it('启动两参,都必填 —— 两颗料号是身份不是细节,留空会让整单永久卡死在 S1', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
expect(Object.keys(props)).toEqual(['originalInternalPn', 'candidateInternalPn']);
expect(manifest.contract.input.data['required']).toEqual([]);
/* 运营端启动表单:两格都是「可选可新写」的下拉 */
expect(manifest.contract.input.data['required']).toEqual(['originalInternalPn', 'candidateInternalPn']);
/* 运营端启动表单:两格仍是「可选可新写」的下拉 —— 必填只挡空值,不挡「库里没有就自己写一个新的」 */
for (const key of ['originalInternalPn', 'candidateInternalPn']) {
expect(props[key]?.['x-control']).toBe('combo');
expect(props[key]?.['x-options']).toMatchObject({ ledger: 'parts.profile', value: 'internal_pn' });
@@ -574,15 +588,29 @@ describe('件① 评估输入补全', () => {
});
/*
* `auto:true` 的主键(`type_id`)**不该出现在种子行里**——自增列不问人(与联系人编码
* `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个手写输入格。
* `auto:true` 的主键(`type_id`)**人不填它**——自增列不问人(与联系人编码
* `contact_code` 同一条纪律)。种子行只有 `type_name`/`description` 两个可填输入格,
* `type_id` 自己那一格**必须**是一个真的、禁用的 `data-col` 输入格(值恒空),
* **不能干脆不给** `data-col`。
*
* **2026-08-30 终审 Critical 1 的回归钉**(反着来的教训:这条测试原来断言
* `not.toContain('data-col="type_id"')`,而正是那个「不给 data-col」的形状让
* `desk.ts` 的 `resolveRowKey` 认不出「这一列人碰过没有」,退回「全表一致」把
* 新建的类型误判成种子字典里唯一那一行、静默改名覆盖它——详见 `views/report.ts`
* `CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
* 里对真实 `resolveRowKey` 的因果验证)。这条测试当年"证明"的是病灶本身,现在改成
* 钉住修法:`type_id` 必须挂 `data-col`,且必须是禁用的 —— 人看得见「由系统生成」
* 这句占位,但怎么点都改不了它的值。
*/
it('**新建类型的种子行不问 type_id**(自增列不问人):已有行的 type_id 只读、没有孪生输入框', () => {
expect(html).toContain('系统生成');
// 「系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行,断言里面没有 data-col="type_id"
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? '';
it('**新建类型的种子行:type_id 挂 data-col、但禁用恒空**(2026-08-30 修 Critical 1 —— 漏挂会让 resolveRowKey 的"全表一致"把新建的类型误判成既有那一行,静默覆盖它)', () => {
expect(html).toContain('由系统生成');
// 「由系统生成」只出现在 chip_type 那条种子行里 —— 精确抓出那一行
const chipTypeStaticRow = /<tr class="jc-row-static">(?:(?!<\/tr>)[\s\S])*?由系统生成(?:(?!<\/tr>)[\s\S])*?<\/tr>/.exec(html)?.[0] ?? '';
expect(chipTypeStaticRow).not.toBe('');
expect(chipTypeStaticRow).not.toContain('data-col="type_id"');
/* 必须是一个真的 <input data-col>(collect() 只认 input/select/textarea[data-col]),
不能是纯文本 <span> —— 否则这一格永远不会出现在回传的 cells 里,barred 的保护无从谈起 */
expect(chipTypeStaticRow).toMatch(/<input[^>]*data-col="type_id"[^>]*disabled/);
/* disabled 意味着人怎么点都改不了它的值 —— 保存时这一格恒是空串,才谈得上「barred」 */
expect(chipTypeStaticRow).toContain('data-col="type_name"');
expect(chipTypeStaticRow).toContain('data-col="description"');
});
@@ -194,6 +194,23 @@ describe('S1 配方', () => {
expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
});
/*
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
*/
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
const kase = acts[5];
const fields = kase?.kind === 'project' ? kase.fields : undefined;
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
expect(gateReason?.then).not.toContain('还没填全');
expect(gateReason?.then).not.toContain('把两格补上');
expect(gateReason?.then).toContain('终止');
});
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
const params = acts[4];
expect(params?.kind === 'project' && params.prune).toBe(true);
@@ -335,7 +335,12 @@ export const ASSESSMENT_STEPS = [
eval_ref: { in: 'evalRef' },
internal_pn: { col: 'pn' },
role: { col: 'role' },
/* 先取依据,再按依据判 —— 字段按声明顺序求值,算出来的进同一行的 S-FEEL 作用域 */
/*
* 先取依据,再按依据判 —— `exists_status` 的 `when` 引用 `found_pn_ref`,
* `orderFields` 按这条依赖排出安全求值序(不是「声明顺序恰好写对了」,
* 2026-08-30 治 jsonb 键序重排那次改的:见 `auto-order.ts` 头注),
* 算出来的进同一行的 S-FEEL 作用域。
*/
found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' },
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' },
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' },
@@ -397,13 +402,18 @@ export const ASSESSMENT_STEPS = [
* 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单
* 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**:
* 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍
* 原样命中、照样幂等跳过,不会覆盖人刚填的内容。`prune:true` 清掉的只是「先前选了
* 某个类型、后来改选了另一个」时那些不再相关的陈旧行。
* 原样命中、照样幂等跳过,不会覆盖人刚填的内容。
*
* **同样不带 `prune`**(与 ② 同一处修正,同一个理由:字段清单也在 ⑥ 的
* `gateLedgers` 上,人交回数据工单那一刻就会跟着落地;已落地的行 `prune` 撤不掉,
* 带上 `prune:true` 会在「先前定过某个类型的字段清单、后来改选了另一个类型」这条
* 路上原样复现 H35 e2e 案例 2 那个死锁)。历史选型的陈旧字段行留在草稿层不算错,
* 与 ② 的字典行是同一条道理。
*/
{
kind: 'project' as const,
into: 'parts.type_param',
/* 同上一笔同一个理由,同一次修正 —— 见 ② 那笔头上的大注释,不重复贴 */
/* 同上一笔同一个理由,同一次修正(含"不带 prune") —— 见 ② 那笔头上的大注释,不重复贴 */
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
@@ -480,6 +490,20 @@ export const ASSESSMENT_STEPS = [
/*
* ⑥ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑦)。
*
* **第一档改口(2026-08-30 修 Critical 2)**:两个启动参数现在是**必填**
* (`contract.input.required`),`missingRequired` 挡在起单那一刻 —— 空字符串永远
* 到不了这一步。`sub_eval_part_rows < 2` 因此只剩**一条**能触发它的真实路径:
* `originalInternalPn === candidateInternalPn`(人把两颗料填成了同一个号)——
* ① 那一笔 `project` 用它们铸 `sub_eval.part` 的主键 `internal_pn`,两行撞成同一个键,
* 第二笔 `draftWrite` 覆盖第一笔,表里只剩一行。旧文案「原料号与候选料号还没填全 ——
* 在下面把两格补上」在这条残余路径上是**两处都错**:①两格根本不是空的(必填墙已经
* 拦住空值),说「没填全」是失实;②块① 的行源是 `mine:sub_eval.part`、人不加行
* (没有「+加一行」按钮),即便真缺一行也补不出来 —— 那句「补上」指向一条不存在的路
* (墙要配路,这句连墙都不是墙)。改口直说实情与唯一出路:两颗料号是这件事的身份,
* 填成同一个号没法比出差异,只能终止重开——这是**真正没有其他出路**的场景(与
* 「留空」不同,「重复」没有任何一步能在本单内部把两个身份重新分开),不算失职地
* 指错方向。
*
* **排在 ④⑤ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正):
* `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`,
* 而这两个数是 ④⑤ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ④⑤ **之前**
@@ -502,7 +526,7 @@ export const ASSESSMENT_STEPS = [
chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
gate_reason: {
when: 'sub_eval_part_rows < 2',
then: '原料号与候选料号还没填全 —— 在下面把两格补上。',
then: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。',
else: {
when: 'sub_eval_part_exists_status_absent > 0',
then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
@@ -650,8 +674,10 @@ export const ASSESSMENT_STEPS = [
},
/*
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
* (2026-08-30,真跑 e2e 第一次撞见的循环依赖,`docs/ACT-WORK-05-端到端测试记录.md`
* 追加记录):`substitution.difference_item.substitution_relation_id` 声明了
* (2026-08-30,真跑 e2e 第一次撞见的循环依赖;**不是**记进
* `docs/ACT-WORK-05-端到端测试记录.md`——那份文档整篇是四步重写[2026-08-29]之前
* 的旧九步设计,已在文档开头标为历史、没有再追加过):
* `substitution.difference_item.substitution_relation_id` 声明了
* `refersTo: 'substitution.relation.substitution_relation_id'`(要求引用的那一行
* 已经真实存在),而 `substitution.relation` 的 `recommendation_level`/`risk_level`/
* `difference_summary`/`recommendation_reason` 这几列**要读差异明细的
@@ -9,12 +9,12 @@
* 「本来就该是确定的」那几步(编号抄别单、判在库判错、数据工单派给在库的料…),
* 真正需要判断的只有一件事:**这一条参数,候选跟原厂比算不算事**。
*
* 这份清单是 Task 2 的产物,只动 `declarations.ledger` 与 `contract` 两段,以及 `assets`
* 里已经不自洽的那几处(spec §8 的退役清单前移到这一步,见「计划裁定 G3」)。**案例模型
* (`case-model.ts`)还是旧的九步、旧的 `guide` 文本** —— 下一个任务(Task 3)才整体重写成
* spec §4 的四步(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`)+ 四道哨兵 + 零 `guide`
* (行为改由 `PlanStep.auto` 声明式配方接管)。旧剧本引用本次删掉的台账地址而导致的包内自测红,
* 已按台账逐条 `it.skip` 并注明「Task 3 会补回」,不删测试(§12.5 铁律)。
* **2026-08-30 更新**:这份注释原写于重写 Task 2(只动 `declarations.ledger` 与
* `contract` 两段那一刻),彼时 `case-model.ts` 还是旧九步——**Task 3 已把它整体重写成
* spec §4 的四步**(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`)+ 四道哨兵 +
* 零 `guide`(行为改由 `PlanStep.auto` 声明式配方接管),清单与案例模型此刻同版一致,
* 不再有「一边新一边旧」的过渡态。`assets` 里当时已不自洽的那几处(spec §8 的退役清单,
* 见「计划裁定 G3」)同批处理完毕。
*
* ## 九张台账声明(spec §3)
* 两张新字典(`parts.chip_type` / `parts.type_param`,Task 1 已物化 + 播种)承接「约束类型按类型
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.1.8',
packageVersion: '2.1.9',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -66,21 +66,41 @@ export const manifest: ActivityManifest = {
version: '1.0.0',
input: {
/*
* 两格,都选填(spec §4.1):库里有就选,没有就自己写一个新的 —— 启动后由第一步的
* 配方核对存在性、缺了就派数据工单补齐。运营端启动表单渲染这两格为
* 「选择 + 新增」下拉(`x-control: combo`),候选从 `parts.profile.internal_pn`
* 现读(`x-options`);`x-order` 定表单次序(清单存在 jsonb 里,书写顺序到不了运营端)。
* requestDescription / candidateInternalPns(数组)/ datasheets 三键已随重写撤下 ——
* 这一版没有「需求主记录」「N 候选并行」「预填 datasheet」这几件事。
* **两格,都必填**(2026-08-30 修 Critical 2,推翻 spec §4.1 原来的「都选填」)——
* 一次评估恰是「一对一候选」,这两颗料号是这件事的**身份**,不是可以事后补的细节:
* 库里有就选,没有就自己写一个新的(合法的新料号字符串,不是留空)。
* 「启动后由第一步的配方核对存在性、缺了就派数据工单补齐」这句话说的是料号的**主数据**
* (品牌/类型/参数),从来不是料号**本身**——旧措辞把两件事含糊成了一件事。
*
* 都选填时留空的下场(评审逐行核过的链条):S1 配方 `project into sub_eval.part` 拿
* `originalInternalPn`/`candidateInternalPn` 铸两行的主键 `internal_pn`,两个空字符串
* 是**同一个主键**——第二笔 `draftWrite` 覆盖第一笔,`sub_eval.part` 收成**一行**不是
* 两行;`gate_reason` 的 `sub_eval_part_rows < 2` 当场为真,提示「在下面把两格补上」——
* 而块① 的行源是 `mine:sub_eval.part`(人不加行,没有「+加一行」按钮),那两格根本
* 不存在。`doneWhen` 永不成立,提交也按不下去,**唯一出路是终止重开**,且五条样例与
* e2e 五案例都没填过空值,两条验证线都照不到这条死路。
*
* 改必填**不是关上「留空」这扇门就完事**——平台早有配套机制接住它(2026-07-30 立的
* 「受理归一」:缺必填不拒单,任务落成 `awaiting-human`,一张补全卡问负责人要那几个
* 字段,答完合并进 `data` 才转 `running`;`apps/api/src/modules/task/internal/usecase/
* task-service.ts` 的 `missingRequired`/`askCompletion`,`__tests__/intake.test.ts`
* 已经把这条路测得很实)。必填不是墙,是把「两颗料号是什么」这件唯一要人先说清楚的
* 事挪到起单那一刻问清楚,不留给配方去猜一个猜不出来的空字符串。
*
* 运营端启动表单渲染这两格为「选择 + 新增」下拉(`x-control: combo`),候选从
* `parts.profile.internal_pn` 现读(`x-options`);`x-order` 定表单次序(清单存在
* jsonb 里,书写顺序到不了运营端)。requestDescription / candidateInternalPns(数组)/
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
* datasheet」这几件事。
*/
data: {
type: 'object',
required: [],
required: ['originalInternalPn', 'candidateInternalPn'],
properties: {
originalInternalPn: {
type: 'string',
title: '原内部料号',
description: '差异评估基准料号;库里有就选,没有就自己写一个新的,启动后由第一步派数据工单补齐。',
description: '差异评估基准料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
examples: ['LM2596S-5.0'],
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
@@ -89,7 +109,7 @@ export const manifest: ActivityManifest = {
candidateInternalPn: {
type: 'string',
title: '候选内部料号',
description: '被评估的候选料号;库里有就选,没有就自己写一个新的,启动后由第一步派数据工单补齐。',
description: '被评估的候选料号(必填);库里有就选,没有就自己写一个新的 —— 它的主数据(品牌/类型/参数)缺了才由第一步派数据工单补齐。',
examples: ['XL2596S-5.0'],
'x-control': 'combo',
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn' },
@@ -180,8 +200,11 @@ export const manifest: ActivityManifest = {
predicate: 'validation_record_rows = 1 and validation_record_missing = 0',
},
/*
* **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现,记入
* `docs/ACT-WORK-05-端到端测试记录.md`)——「裁量项带谓词时谓词说了算」这条纪律
* **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现 —— **不是**记进
* `docs/ACT-WORK-05-端到端测试记录.md`:那份文档整篇是 2026-08-27 及更早的旧
* 九步设计,四步重写[2026-08-29]之后没有再往里追加过任何一条,已在文档开头标为
* 历史;这条发现连同其余 2026-08-30 的都只活在这份源码注释与 `docs/debt.md` 里)。
* 「裁量项带谓词时谓词说了算」这条纪律
* (2026-08-02 立,`engines.ts` 的 `criterionCheck`:`settledByData`)本仓已有先例,
* 从前 W05 这两条 L4 是裸的(无 `predicate`),而 T_release 全程 `auto` 驱动、
* `submit` 是五个声明式动词之一(`packages/contracts/src/auto-recipe.ts`),
@@ -505,7 +528,7 @@ export const manifest: ActivityManifest = {
/*
* DMN 判定表已退役:D01(逐参数四态)改由 S2 唯一一次 analyze 判定;D02(推荐等级)的规则
* 搬进配方的 when 链(spec §3.5)—— 规则只许有一处真源,再留一份 DMN 就是同一件事两处
* 各存一份。Task 3 会把 `steps` 换成四步、`guide` 全部清空(auto 配方接管行为)。
* 各存一份。`steps` 已是四步、`guide` 全部清空(auto 配方接管行为,Task 3 完成)。
*/
businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, steps: ASSESSMENT_STEPS, phases: ASSESSMENT_PHASES },
/* 三条技能已随重写退役(全程无 agent,没有临场编排可言);skills.ts 已在 Task 10 删除 */
@@ -330,9 +330,32 @@ const PART_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
/**
* 块②a 新建芯片类型 —— `parts.chip_type`(Task 7b,2026-08-29 补齐)。类型下拉(块①)里
* 没有合适的,人在这里现造一个:类型名称(必填)+ 说明(建议)。`type_id` 是自增主键
* (`auto:true`,声明改 `text` 的理由见 manifest 那条大注释)—— **不进表单**,自增列
* 不问人(与联系人编码 `contact_code` 同一条纪律,2026-08-02 立);只读态摆 `ro()`,
* 连孪生输入框都不给。
* (`auto:true`,声明改 `text` 的理由见 manifest 那条大注释)—— **人不填它**,自增列
* 不问人(与联系人编码 `contact_code` 同一条纪律,2026-08-02 立);已有行只读态摆 `ro()`。
*
* **种子行里 `type_id` 必须挂一个真实的(禁用、恒空)`data-col`,不能干脆不给 ——
* 这是 2026-08-30 终审 Critical 1 的病灶与修法**(链条评审逐行核实过,详见
* `docs/debt.md`「auto:true 又是整数型的主键」条目的姊妹坑):第一版图省事,把这一格
* 写成纯文本 `<span>系统生成</span>`,不带 `data-col` —— 保存时 `collect()` 只认
* `input[data-col]/select[data-col]/textarea[data-col]`,`type_id` 于是**压根不出现在
* 回传的 `cells` 里**。而 `desk.ts` 的 `resolveRowKey` 用 `k in cells`(不是「值是否非空」)
* 判断「这一列人碰过没有」——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」,
* 专门为「auto 列件上摆着却空着」设计的 `barred` 保护(`(input.autos??[]).filter((k)=>k in
* cells && ...)`)因此从未触发,退回给「全表一致」那条兜底路径。而 Task 7b 把
* `type_id` 从 `integer` 改成 `text` 之后,「序号型主键不走全表一致」(2026-08-23 那条
* 保护,认的是 `isSeq`)也顺带失效 —— 两条保护同时缺位,「全表一致」直接接管:
* `parts.chip_type` 此刻**恰好一行**(Ruling H35 的自投影②保证了这一点),
* `seen.size===1` 当场把人新建的类型判成「就是那唯一一行」,一次保存就把种子字典行
* (如 `type_id=1` 的「DC-DC 降压稳压器」)**静默改名**——`mintable`/`autoKeys()`(真正
* 为它设计的续号器)永远轮不到。
* **修法**:给这一格换成一个真的 `<input data-col="type_id" disabled>`(值恒空,
* 只显示「由系统生成」占位)——`'type_id' in cells` 从此为真、值为空,`barred` 正确
* 命中,`resolveRowKey` 交回 `undefined`,`mintable` 才轮得到、续出一个不撞任何既有行
* 的新号。**平台侧 `resolveRowKey` 本身一行没改**——这是活动侧绕开,`isSeq`/「全表
* 一致」两条平台保护的根治仍记在 `docs/debt.md`(下一个建「单一主键又是 auto 列」的
* 活动仍会踩,除非那两条也一并治了)。回归钉在 `__tests__/activity.test.ts`
* (报告层的 markup)与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
* (真实 `resolveRowKey` 的因果机制,两种 `cells` 形状各自的结果)。
*
* **零行照样加得出第一行**(这是这一块存在的全部理由):`mine:parts.chip_type` 每单
* 开局是零行,`ViewContainer.addRow` 靠克隆 `tr:last-of-type` 添新行,零行时找不到可
@@ -381,7 +404,7 @@ const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_t
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><span class="jc-vcell ro">系统生成</span></td>
<td><input class="jc-cell mono" data-col="type_id" placeholder="由系统生成" disabled></td>
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
</tr></tbody>
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
});
});
/**
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
* `barred` 因此永远不会把它算进去;
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
* 那条 2026-08-23 的保护对它不成立。
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
* /说明**静默覆盖**到既有那一行头上。
*
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
* auto 列」的活动仍会踩上第一条用例这个坑。
*/
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
const KEYS = ['type_id'];
const TYPES = { type_id: 'text' };
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
const ONE_EXISTING = [{ key: { type_id: '1' } }];
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
expect(out).toEqual({ type_id: '1' });
});
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
expect(out).toBeUndefined();
});
});
describe('提交闸:必填没填就交不了', () => {
const gate = (rows: Array<Record<string, unknown>>) =>
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
@@ -781,4 +781,44 @@ describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', ()
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
/*
* **`recipe.let` 同一条审读**(2026-08-30 全分支终审 Important 1)——`let` 与 `fields`
* 的依赖读法不一样(`let` 的官方跨条目读法是 `{in:X}`,不是 `when` 里的裸标识符),
* 所以静态审那边是单独一份 `letsCycle`/`letReferences`,这里单独一组测试钉住它,
* 不是「已经被 fields 那组测过了」。
*/
it('recipe.let 两条互相依赖({in:X} 互指)→ error,点名两个 let', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { a: { in: 'b' }, b: { in: 'a' } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
}),
);
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
expect(hit).toBeDefined();
expect(hit?.message).toContain('a');
expect(hit?.message).toContain('b');
expect(hit?.message).toContain('let');
});
it('recipe.let 链式依赖(不是环,只是 tag 用 {in:"stamp"} 读 stamp)→ 不报循环依赖', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { stamp: 'W05-', tag: { cat: [{ in: 'stamp' }, 'X'] } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: { in: 'tag' } } }],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
it('recipe.let 引用起单入参 / case: 这类全局名字不算依赖,不误报循环依赖', () => {
const findings = autoRecipeFindings(
withRecipe({
let: { a: { in: 'someInput' }, b: { in: 'case:openedAt' } },
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
}),
);
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
});
});
@@ -431,6 +431,50 @@ function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly strin
return result.ok ? undefined : result.cycle;
}
/**
* 同上,但专为 `recipe.let` 用(2026-08-30 全分支终审 Important 1)——多认一种依赖:
* **裸 `{in:X}` 本身就是依赖**。`let` 的官方跨条目读法就是 `{in:X}`(见
* `packages/contracts/src/auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能
* {in:'名'} 引用」,运行时 `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——
* 与 `fields` 相反(`fieldReferences` 故意不认 `{in:X}`:那边的 `{in:X}` 只读
* `lets`/`scope`,永远不会命中同一个 `fields` 对象里的另一个字段,认了反而误报)。
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `fieldReferences` 完全一致。
*
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `letReferences` 是同一个算法,
* 写了两遍**——理由同 `fieldReferences` 头注(L4 装包静态审不能 import L5 配方执行器)。
* 改动这个函数时,记得看看那边要不要一起改。
*/
function letReferences(expr: AutoExpr): readonly string[] {
if (typeof expr !== 'object' || expr === null) return [];
if ('in' in expr) return [expr.in];
if ('when' in expr) {
const verdict = staticCheck(expr.when);
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
}
if ('lookup' in expr) {
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
}
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
if ('fmt' in expr) return letReferences(expr.fmt);
return [];
}
/** `recipe.let` 内部求值依赖能不能排出安全序(与运行时 `orderLets` 同一件事)。装的时候就该看出来,不留到运行时才炸。 */
function letsCycle(lets: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
const entries = Object.entries(lets);
const names = new Set(entries.map(([name]) => name));
const deps = new Map<string, readonly string[]>(
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
);
const result = topoOrder(
entries.map(([name]) => name),
(name) => deps.get(name) ?? [],
);
return result.ok ? undefined : result.cycle;
}
/**
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
*
@@ -546,6 +590,23 @@ export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFindi
/* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */
const letNames = new Set(Object.keys(recipe.let ?? {}));
for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`);
/*
* **`let` 之间的求值依赖不能成环**(2026-08-30 全分支终审 Important 1,与下面 `fields`
* 那条 `fieldsCycle` 同一课):`let` 存进 jsonb 之后键序不再保证,运行时已经按依赖
* (`{in:X}`)排出安全序求值(见 `orderLets` 头注),但 A 依赖 B、B 又依赖 A 排不出
* 任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
*/
{
const cycle = letsCycle(recipe.let ?? {});
if (cycle !== undefined) {
out.push({
code: 'schema-invalid',
severity: 'error',
message: `let 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个 let 是否互相用 {in:...} 或 when 引用了对方)`,
at: `${at}.let`,
});
}
}
for (const [index, action] of recipe.do.entries()) {
const where = `${at}.do[${index}]`;
@@ -8,10 +8,12 @@
*/
import { describe, expect, it } from 'vitest';
import type { AutoExpr } from '@contracts';
import { AutoFieldCycleError, orderFields } from '../internal/domain/auto-order.ts';
import { AutoFieldCycleError, orderFields, orderLets } from '../internal/domain/auto-order.ts';
/** 只看排出来的字段名序列,不看表达式本身 */
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
/** 同上,给 `orderLets` 用 */
const letNames = (lets: Readonly<Record<string, AutoExpr>>) => orderLets(lets).map(([name]) => name);
describe('orderFields', () => {
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
@@ -119,3 +121,101 @@ describe('orderFields', () => {
expect(out).toEqual([['only', expr]]);
});
});
/**
* `orderLets` —— `recipe.let` 版的求值序(2026-08-30 全分支终审 Important 1)。
*
* `let` 与 `fields` 的依赖读法**不一样**:`fields` 的字段互相看见只能靠 `when` 字符串
* 里的裸标识符(`{in:X}` 在 `fields` 里读 `lets`/`scope`,读不到同一个 `fields` 对象里
* 的另一个字段);`let` 的官方跨条目读法**就是** `{in:X}`(`packages/contracts/src/
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」)。所以这里
* 单独一组测试,不是照抄 `orderFields` 那组把函数名换一下 —— 头两条专门钉住这个差异。
*/
describe('orderLets', () => {
it('**`{in:X}` 本身就是依赖**(let 与 fields 的关键差异):B 用 {in:"a"} 读 A,A 必须排到 B 前面', () => {
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
const lets: Record<string, AutoExpr> = {
b: { cat: [{ in: 'a' }, '-suffix'] },
a: 'x',
};
const order = letNames(lets);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
});
it('三级链,全靠 {in:X} 串起来:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
const chain: Record<string, AutoExpr> = {
evalRef: { in: 'case:openedAt' },
/* B:{in:X} 提到 A */
stamp: { cat: ['W05-', { in: 'evalRef' }] },
/* C:{in:X} 提到 B */
tag: { cat: [{ in: 'stamp' }, '-X'] },
};
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去
const jsonbLike: Record<string, AutoExpr> = {};
for (const key of ['tag', 'stamp', 'evalRef']) jsonbLike[key] = chain[key] as AutoExpr;
const order = letNames(jsonbLike);
expect(order.indexOf('evalRef')).toBeLessThan(order.indexOf('stamp'));
expect(order.indexOf('stamp')).toBeLessThan(order.indexOf('tag'));
});
it('when 字符串里的裸标识符同样算依赖(与 fields 那半条规矩一致,let 的 when 也进含 ...lets 的合并作用域)', () => {
const lets: Record<string, AutoExpr> = {
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
a: 'x',
};
const order = letNames(lets);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
});
it('依赖藏在 lookup.by / else / cat / fmt 里也认得出(与 fields 那组同款覆盖)', () => {
const lets: Record<string, AutoExpr> = {
target: { lookup: 'parts.chip_type', by: { type_id: { in: 'source' } }, take: 'type_name', else: '' },
source: { fmt: { in: 'raw' }, as: 'digits' },
raw: '5',
};
const order = letNames(lets);
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
expect(order.indexOf('raw')).toBeLessThan(order.indexOf('source'));
});
it('没有互相依赖的 let:谁先谁后都行,数量对得上就好', () => {
const lets: Record<string, AutoExpr> = { b: { in: 'x' }, a: 'literal', c: { in: 'y' } };
expect(letNames(lets).sort()).toEqual(['a', 'b', 'c']);
});
it('只认同一个 let 对象内的名字 —— {in:X} 引用起单入参 / case: / 基座读数这类全局名字不算依赖,不参与排序', () => {
const lets: Record<string, AutoExpr> = {
a: { in: 'originalInternalPn' },
b: { in: 'case:openedAt' },
};
// 两条互不相干,不该抛错,数量对得上即可
expect(letNames(lets).sort()).toEqual(['a', 'b']);
});
it('真正的环({in:X} 互指):抛 AutoFieldCycleError,话里点名是 let(noun 参数),不默默给错答案', () => {
const lets: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
expect(() => orderLets(lets)).toThrow(AutoFieldCycleError);
try {
orderLets(lets);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(AutoFieldCycleError);
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
expect((err as Error).message).toContain('循环依赖');
expect((err as Error).message).toContain('let');
}
});
it('自己依赖自己({in:X} 提到自己的名字)不算环 —— 常见写法是把同名入参"提升"进 let 命名空间', () => {
const lets: Record<string, AutoExpr> = { chip_type_id: { in: 'chip_type_id' } };
expect(() => orderLets(lets)).not.toThrow();
expect(letNames(lets)).toEqual(['chip_type_id']);
});
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
const expr: AutoExpr = { in: 'evalRef' };
const out = orderLets({ only: expr });
expect(out).toEqual([['only', expr]]);
});
});
@@ -978,3 +978,107 @@ describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一
}
});
});
/**
* **回归:jsonb 打乱 `recipe.let` 键序,同样必须求出与原声明序一致的结果**
* (2026-08-30 全分支终审 Important 1)。
*
* H47(上面那一大段)只治了 `fields` 那一半 —— `recipe.let` 同样住在
* `sys.activity_package.manifest` 这个 jsonb 列,同样会被 PG 按「先短后长、同长字典序」
* 重排。而 `let` 的官方跨条目读法**就是** `{in:X}`(见 `packages/contracts/src/
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」),后一条 let
* 靠它读前一条已经算出的值 —— 键序被打乱,`{in:X}` 会读到 `undefined`,与 `fields` 那次
* 事故是**同一种病灶、同一种沉默失败**(不报错、结果看着完整、只是算错)。
*
* 下面 `LETS` 故意让**被依赖的那一条名字更长**(`originalStamp`,13 字)、**依赖它的那一条
* 名字更短**(`tag`,3 字)——jsonb「先短后长」重排会把 `tag` 排到 `originalStamp` **前面**,
* 恰好是「后一条 let 引用前一条,键序被 jsonb 重排会颠倒」这个具体形状。
*/
describe('回归 · jsonb 打乱 recipe.let 键序,求值仍要与原声明序一致(2026-08-30)', () => {
/** 与上面 `reorderLikeJsonb` 同一个规则,这个 describe 块单独起一份(两处各自独立,互不依赖对方的实现细节) */
function reorderLikeJsonb<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
const out: Record<string, T> = {};
for (const key of keys) out[key] = fields[key] as T;
return out;
}
/** 被依赖的那一条(13 字)排在依赖它的那一条(3 字)前面 —— 声明序是对的,jsonb 会把它排错 */
const LETS: Record<string, AutoExpr> = {
originalStamp: { in: 'caseSeed' },
tag: { cat: [{ in: 'originalStamp' }, '-X'] },
};
it('reorderLikeJsonb 确实会把依赖的那一条排到被依赖的前面(不然这条用例什么都没测)', () => {
expect(Object.keys(reorderLikeJsonb(LETS))).toEqual(['tag', 'originalStamp']);
});
function harness(seed: Record<string, unknown>) {
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
const deps = {
data: {
view: () => Promise.resolve({ ...seed }),
writable: () =>
Promise.resolve([{ domain: 'demo', object: 'record', label: '演示记录', keys: ['case_ref'], fields: [] }]),
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
drafts.set(id, { key: i.key, patch: i.patch });
return Promise.resolve({ changed: true });
},
draftDrop: () => Promise.resolve({ dropped: false }),
},
cards: {
issue: () => Promise.reject(new Error('本用例不该发卡')),
decide: () => Promise.reject(new Error('本用例不该发卡')),
entry: () => Promise.reject(new Error('本用例不该发卡')),
ask: () => Promise.reject(new Error('本用例不该发卡')),
unanswered: () => Promise.resolve([]),
},
memory: { append: () => Promise.resolve() },
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
sfeel: {
condition: (expr: string, scope: Record<string, unknown>) => evaluateCondition(expr, scope),
expression: () => undefined,
unaryTest: () => false,
},
};
const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
return { runner: createAutoRunner(deps as never), drafts, ctx };
}
const recipeOf = (lets: Record<string, AutoExpr>): AutoRecipe => ({
let: lets,
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'CR-1', computed_tag: { in: 'tag' } } }],
});
it('声明序与打乱成 jsonb 重排序,求出的 let 值必须完全相同、且是对的(不是空字符串兜底)', async () => {
const seed = { caseSeed: 'W05-20260830' };
const declared = harness(seed);
const declaredOut = await declared.runner.run(recipeOf(LETS), declared.ctx);
const shuffled = harness(seed);
const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(LETS)), shuffled.ctx);
expect(declaredOut.failedAt).toBeUndefined();
expect(shuffledOut.failedAt).toBeUndefined();
const declaredTag = [...declared.drafts.values()][0]?.patch['computed_tag'];
const shuffledTag = [...shuffled.drafts.values()][0]?.patch['computed_tag'];
/* 正确答案:originalStamp = 'W05-20260830'(读 scope),tag = 'W05-20260830' + '-X' —— 打乱前不改变这一点 */
expect(declaredTag).toBe('W05-20260830-X');
/* 求值序被治好之前,这里会是 '-X'(打乱后 `originalStamp` 还没算,`{in:'originalStamp'}` 读到 undefined→'') */
expect(shuffledTag).toBe('W05-20260830-X');
expect(shuffledTag).toBe(declaredTag);
});
it('真正的环(a 依赖 b、b 依赖 a)—— 不抛出去炸掉整个回合,交回 failedAt 且话里点名是 let', async () => {
const cyclic: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
const h = harness({});
const out = await h.runner.run(recipeOf(cyclic), h.ctx);
expect(out.failedAt).toBeDefined();
expect(out.failedAt?.message).toContain('循环依赖');
expect(out.failedAt?.message).toContain('let');
});
});
@@ -103,6 +103,13 @@ function harness(
unanswered?: ReadonlyArray<{ id: string; title: string }>;
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
issueRejects?: string;
/**
* **只让 `workOrder:'acceptance'` 那一句抛**(2026-08-30 终审 I4)——模拟落地预检
* 拒发验收卡(`raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
* 抛出的那种);与 `issueRejects`(不分 workOrder 一律抛)分开,好让「验收卡发不出去、
* 但兜底的执行工单发得出去」这个场景测得出来。
*/
acceptanceIssueRejects?: string;
} = {},
): Harness {
const issued: Harness['issued'] = [];
@@ -186,6 +193,9 @@ function harness(
cards: {
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
if (over.acceptanceIssueRejects !== undefined && input.workOrder === 'acceptance') {
throw new Error(over.acceptanceIssueRejects);
}
issued.push({ workOrder: input.workOrder, card: input.card });
return { id: `card-${issued.length}` };
},
@@ -2429,6 +2439,68 @@ describe('auto 步:配方在拍首跑', () => {
expect(lines[0]?.who).toBe('system');
});
/*
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
* `custom:` 走**对话卡族**,不是 `approve`)。`card-rules.ts` 的 `checkChatCardReply`
* 认这个答复合法(`allowCustom` 对审核卡默认真,见 `engines.ts:1507`
* `allowCustom: input.allowCustom ?? reviewFace`)、`worldTouched` 照样落 true ——
* 于是这条「卡答过了就收步」的判断(:2179,四个前提:`noDoneWhen && carded &&
* recipeIssuesCard(recipe) && current.guide.trim() === ''`)一个字都不看
* `reply.value`/`chosen.value` 是 `approve` 还是 `custom:...`,一样收步、一样往前推。
*
* **为什么不能靠活动侧加一个 `reject` 选项治**:上面那四个前提里没有一条查了选项或
* 选中的值,给卡加选项不会改变这个判断的走向——同一张卡不管选中哪一个,只要
* `worldTouched` 变真,这一步就被收。
*
* **为什么 2026-08-27 那次「chosenGuidance 分岔」治不了这一条**:那次治的是**半 auto**
* 路径(`guide` 非空、真的会去问模型的那条)—— `answeredCardFact`/`chosenGuidance`
* 拼进的是**喂给模型的提示词**(:2255 `staged` 数组),而这里根本不问模型
* (`current.guide.trim() === ''` 正是触发这条近道的四个前提之一)。**没有模型可问,
* 就没有提示词可以说服** —— 同一症状("审核那一步被收掉、一路推到放行",
* `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上
* 重演,而那次的修法结构性地够不到这条路径。
*
* **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清
* 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的
* `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡**
* (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、
* 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同,
* 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、
* 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给
* 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。
*/
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
unanswered: [],
sfeelCondition: () => false,
// 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到**
trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }],
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
/*
* **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算"
* (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果
* 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。
*/
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
/*
* 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7
* 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回),
@@ -2636,6 +2708,79 @@ describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
expect(refused?.title).toContain('一个字没改');
});
/*
* **Important 4(2026-08-30 全分支终审)**:上面四条测的都是「打回重做」那条路
* (`trail` 里有 `REDO_MARK`)。这三条测**从没被打回过、纯粹是验收判据(L1/L2 机检)
* 没过**的那条路——这一步没有 guide(全 auto),没有模型会读「提交放行被拒」这句话,
* 台账在更早一步就已经收口、没有活的编辑面能让人就地改:下一拍重跑只会拿到同一份
* 数据、撞回同一堵墙,纯粹烧步数预算直到 exhausted(task-service 的 markProgress
* 每拍都记,stallAfterMs 那道熔断因此不会提前触发)。用 `acceptanceIssueRejects`
* 模拟 `raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
* 被落地预检拒发(这正是 W05 一颗 UNKNOWN 差异行漏填去向时,`every-param-judged`[L2]
* 会真实抛出的那种错误)。
*/
it('Important 4 · 头一次撞验收判据的墙(不是打回重做):留痕、状态仍 running —— 给它一次机会(软化那半段)', async () => {
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
const h = harness({
parsed: AUTO_RELEASE,
runtime: AUTO_RELEASE_RUNTIME,
sfeelCondition: () => false,
acceptanceIssueRejects: reason,
steps: [RELEASE_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('running');
expect(h.issued).toHaveLength(0);
expect(h.listened).toHaveLength(0);
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
expect(refused?.title).toContain('每条核心参数都有明确状态');
});
it('Important 4 · 同一堵墙第二次(trail 里已有同一句拒回痕)→ 不再空转到 exhausted,发兜底执行工单交人、立监听', async () => {
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
const h = harness({
parsed: AUTO_RELEASE,
runtime: AUTO_RELEASE_RUNTIME,
sfeelCondition: () => false,
acceptanceIssueRejects: reason,
// 模拟"上一拍已经在这堵墙上留过一条一模一样的痕"
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
steps: [RELEASE_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
/* 发的是兜底的**执行工单**,不是 workOrder:'acceptance'(那正是刚撞过的那堵墙,再发一遍只会再撞一次) */
expect(h.issued).toHaveLength(1);
expect(h.issued[0]?.workOrder).toBe('execution');
expect(String(h.issued[0]?.card['title'])).toContain('T_release');
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
expect(basis.find((b) => b.k === '这一步')?.v).toBe('T_release');
expect(basis.find((b) => b.k === '拒回原话')?.v).toContain('每条核心参数都有明确状态');
// 立了人事件监听,人一答就叫得醒(不是伪装成"等外部回音"却没人会来)
expect(h.listened).toEqual([{ eventType: 'human' }]);
});
it('Important 4 · 兜底执行工单自己也发不出去(如调人预算触顶):不抛、状态仍 awaiting、留痕两层原因', async () => {
const reason = '调人预算已经触顶(3/3)';
const h = harness({
parsed: AUTO_RELEASE,
runtime: AUTO_RELEASE_RUNTIME,
sfeelCondition: () => false,
// issueRejects 对任何 workOrder 都抛 —— 验收卡(raiseAcceptance 内部)与
// 兜底执行工单(下面新增的那一支)两次尝试都会撞上同一句话
issueRejects: reason,
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
steps: [RELEASE_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
expect(h.issued).toHaveLength(0);
expect(h.listened).toHaveLength(0);
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
expect(note).toBeDefined();
expect(note?.title).toContain('调人预算已经触顶(3/3)');
});
});
/**
@@ -1,9 +1,9 @@
/**
* 配方字段求值序 —— 纯函数,零 IO(编码规范 §5.3)。
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
*
* `AutoAction.fields` / `analyze.input.fields` 存进 `sys.activity_package.manifest`
* (`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:PostgreSQL 的 `jsonb` 按
* 「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
*
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
@@ -15,14 +15,35 @@
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
*
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
* 不是简单复用 `whenReferences`/`orderFields`。
*/
import type { AutoExpr } from '@contracts';
import { staticCheck } from '@sfeel';
import { topoOrder } from '@utils';
/**
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
*/
export class AutoFieldCycleError extends Error {
constructor(readonly cycle: readonly string[]) {
super(`字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`);
constructor(
readonly cycle: readonly string[],
noun: '字段' | 'let' = '字段',
) {
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
this.name = 'AutoFieldCycleError';
}
}
@@ -80,3 +101,53 @@ export function orderFields(fields: Readonly<Record<string, AutoExpr>>): Readonl
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
return result.order.map((name) => [name, byName.get(name)!] as const);
}
/**
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
*
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
*/
function letReferences(expr: AutoExpr): readonly string[] {
if (typeof expr !== 'object' || expr === null) return [];
if ('in' in expr) return [expr.in];
if ('when' in expr) {
const verdict = staticCheck(expr.when);
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
}
if ('lookup' in expr) {
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
}
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
if ('fmt' in expr) return letReferences(expr.fmt);
return [];
}
/**
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
*/
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
const entries = Object.entries(lets);
const names = new Set(entries.map(([name]) => name));
const deps = new Map<string, readonly string[]>(
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
);
const byName = new Map(entries);
const result = topoOrder(
entries.map(([name]) => name),
(name) => deps.get(name) ?? [],
);
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
return result.order.map((name) => [name, byName.get(name)!] as const);
}
@@ -11,7 +11,7 @@
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
*/
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
import { AutoFieldCycleError, orderFields } from '../domain/auto-order.ts';
import { AutoFieldCycleError, orderFields, orderLets } from '../domain/auto-order.ts';
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
import { expandSource } from '../domain/auto-source.ts';
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
@@ -107,9 +107,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
const condition = deps.sfeel.condition;
const lets: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
lets[name] = evalExpr(expr, { scope, lets, condition });
}
/*
* **`let` 算好之前不许任何一笔动作跑**(2026-08-30 终审 Important 1)—— 计算挪到了
* 下面主循环第一次迭代的 try 里(见那一段注释),`letsReady` 只是标一下算过没有。
*/
let letsReady = false;
async function runAction(
action: AutoAction,
@@ -361,6 +363,25 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
dirty = false;
}
/*
* **`let` 与 `fields` 同一个 jsonb 重排隐患,同一套治法**(2026-08-30 终审
* Important 1,H47 当时只治了 `fields` 那一半 —— 见 `auto-order.ts` 头注)。
* `{in:X}` 是 `let` 唯一的官方写法,后一条 let 靠它读前一条已经算出的值,
* 键序被 jsonb 打乱同样会让这条依赖读到 `undefined`(未绑定的名字,不报错、
* 静默算错)。改按 `orderLets` 的依赖拓扑序求值,只算一次(`letsReady`)。
*
* **放进第一次循环的 try 里,不是单独一段**——唯一目的是**复用同一个 catch**:
* 真撞上环(该在装包静态审就被拦下,见 `static-audit.ts` 新增的 `letsCycle`
* 检查,这里是最后一道兜底)交人的路子,与某一笔配方动作本身办不下去完全一样,
* 不必另开一条"let 专属"的失败通道(`failedAt.index` 报的是 0,即"第 1 笔"——
* 不完全精确,但 `failed.message` 里的原话已经说清是 let 算不出来,够用)。
*/
if (!letsReady) {
for (const [name, expr] of orderLets(recipe.let ?? {})) {
lets[name] = evalExpr(expr, { scope, lets, condition });
}
letsReady = true;
}
const base: ExprCtx = { scope, lets, condition };
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
@@ -159,6 +159,13 @@ const REDO_MARK = '验收被打回重做';
const REFUSE_MARK = '打回之后台账一个字没改';
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
const AUTO_FAIL_MARK = '配方办不下去';
/**
* 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 ——
* 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支,
* 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面
* `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。
*/
const AUTO_SUBMIT_REFUSE_MARK = '提交放行被拒';
export function toolInputHash(input: unknown): string {
const text = JSON.stringify(input) ?? '';
@@ -2133,13 +2140,78 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
/*
* **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。
*
* 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步
* (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`,
* `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个
* `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因
* (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈:
* 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份
* 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算,
* 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记,
* `stallAfterMs` 那道熔断因此永远不会提前触发)。
*
* **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的
* 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次
* 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对,
* 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙:
* 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式——
* 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。
*/
const already = (await deps.memory.window(taskId, STALL_WINDOW)).some(
(t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
);
await deps.memory.append({
taskId,
kind: 'result',
title: `提交放行被拒:${reason}`,
title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
});
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
if (!already) {
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
}
const stuck = await deps.tasks.of(taskId);
try {
const card = await deps.cards.issue({
taskId,
ownerId: stuck.ownerId,
humanCallCap: stuck.humanCallCap,
workOrder: 'execution',
card: {
title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`,
ask:
'这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' +
'也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' +
'数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' +
'判据或配方本身有问题就找开发改完重跑。',
basis: [
{ k: '这一步', v: current.item },
{ k: '拒回原话', v: reason },
],
fields: ['decision'],
},
});
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
} catch (issueError) {
const issueReason = issueError instanceof Error ? issueError.message : String(issueError);
await deps.memory.append({
taskId,
kind: 'result',
title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`,
detail: {
auto: current.item,
hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`,
},
});
}
return {
runtime: stateRuntime,
enabled: stateEnabled,
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
status: 'awaiting',
};
}
}
if (auto.issuedCardId !== undefined) {
+19
View File
@@ -1,5 +1,24 @@
# ACT-WORK-05 差异评估 —— 端到端测试记录
> **历史记录,已停止更新(2026-08-30 标注)**:这份文档记的是**旧九步案例模型**
> (`S0_bind` / `S0d_p08_master` / `S0d_p10_document` / `S1_input_confirm` /
> `S2_normalize` / `S3_assess` / `S4_review` / `S5_prepare_landing` / `T_release`)
> 从零到 released 的走查账,时间跨度到 2026-08-27 为止。**2026-08-29 的整体重写**
> (Task 3,`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §4)
> 把九步砍成了四步(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`,行为改由
> `PlanStep.auto` 声明式配方接管,ADR-014)——**下面九个计划项的表、逐轮走查、A–Z
> 发现表,描述的都是这个已经不存在的旧结构**,不代表当前 `case-model.ts` 的真实形状。
> 保留只为存档:六轮浏览器走查与脚本 e2e 逼出的 26 条发现(A–Z)里,相当一部分
> (措辞纪律、`refersTo`/`requiredWhen` 这类结构治法、「机检只读基座数」的又一次违反)
> **后来被折进了四步重写的设计与 `docs/debt.md`**,不是白跑的,只是不该再被当成
> 「这个活动现在长什么样」的说明书来读。**四步重写之后的发现不再追加进这份文档**——
> 散落在源码注释(`case-model.ts`/`manifest.ts` 的大注释)、`docs/debt.md`、以及
> `.superpowers/sdd/2026-08-29-act-work-05-rewrite/` 下的过程记录里。
>
> 以下原文未删改。
---
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
+6 -1
View File
@@ -198,7 +198,12 @@ type AutoExpr =
| { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套)
```
**字段按声明顺序求值,已算出的字段进同一行的 S-FEEL 作用域** —— 「先 lookup 出依据,再按依据判 found/absent」
**字段按依赖拓扑序求值,不按声明/存储顺序**(2026-08-30 全分支终审 Important 1 改写此段 ——
`fields` / `let` 存进 `sys.activity_package.manifest` 这个 jsonb 列之后,对象键的原始书写顺序
不再保证,PG 会按「先短后长、同长字典序」重排;`orderFields`/`orderLets`
按字段名之间的真实依赖排出一个安全序,真正的环在装包静态审那一关就被拒装,不留到运行时
静默算错。见 `apps/api/src/modules/loop-engine/internal/domain/auto-order.ts`),
已算出的字段进同一行的 S-FEEL 作用域 —— 「先 lookup 出依据,再按依据判 found/absent」
这种写法靠它成立。
### 4.1 例:S0d_p10_document(整步确定,最小的那个)
+171 -3
View File
@@ -1277,6 +1277,101 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
## `modules → app` 是全仓第一条这个方向的 import(2026-08-30,Ruling H9,Task 4/7b 装 `@app/*` 别名时记的)
`apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 与
`w05-s3-review-recompute.test.ts` 两份测试用新增的路径别名 `@app/*`(`tsconfig.base.json` +
`vitest.config.ts` 各加一行,与既有 `@modules/*`/`@shared/*`/`@features/*`/`@web-shared/*`
同一种写法)直接 import `apps/api/src/app/wiring/engines.ts` 的具名导出(`rowReadings`/
`missingReadings`/`tallyReadings`/`distinctReadings`)——按本仓的模块层级(`docs/编码规范.md`:
`apps/api/src/app/` 是「唯一组装层」,`modules/*` 是被组装的那一层,依赖方向只许
`app → modules`),这是**全仓第一条 `modules → app`** 的 import,且目前只出现在测试文件里
(production 代码里一条都没有)。
**为什么会有它**:这两份测试要把真 `createAutoRunner` 跑起来验证「引擎逐 action 刷新
时机」这类只有真求值才测得出的回归;读数层四个函数(`missingReadings` 等)只在
`engines.ts` 里有实现,第一版手抄了一份副本,**同一笔提交里就漂了**(`missingReadings`
拼缺项名的真身多带一句 `label ?? `,手抄件漏掉)——「手抄会漂」这个仓已经踩过三次
(读数函数 / `let` 镜像 / 选择器复刻),这次选择 import 真身而不是再抄一遍。
**要治**(方向,未裁定,是全仓架构决定不是单个活动包能裁的):
- `.dependency-cruiser.cjs` 现在只有 `packages-are-leaves`(禁 `packages → apps`)管这类
方向,`modules → app` 完全不受约束——该不该补一条规则(禁 `modules → app`,但要给
测试文件开口子,否则这条测试会被打回手抄、重蹈「手抄会漂」的覆辙),需要专门讨论。
- 或者认为「测试文件例外」本就是可接受的常态(`packages/ui-kit/__tests__/
view-container-collect.test.ts` 已有 `apps → packages` 方向的先例),把这条也当先例
记下就够,不必新增规则。
**排查法**:`grep -rn "from '@app/" apps/api/src/modules` 排除 `__tests__` 目录,
确认这个方向至今只活在测试里。
## `<thead>` 排在 `<tbody>` 之后:违反 HTML5 表格内容模型、读屏器列头关联失效(2026-08-30,Ruling H31)
`activities/part-substitution-assessment/src/views/report.ts` 的 `CHIP_TYPE_TABLE` /
`TYPE_PARAM_TABLE`(Task 7b 修「新建行按钮克隆到表头」那次 Critical 回归)把 `<thead>`
写在两个 `<tbody>` 之后——**挪位本身是那次修复的正确解法**(`data-edit` 两处写法让
`ViewContainer.bodyOf()` 解析到外层 `<table>`,`addRow()` 在这个 host 里找
`tr:last-of-type`;`<thead>` 若排在数据行前面会被优先命中、克隆出表头文字而不是数据格),
但这个修法本身留了一笔新账:
- **违反 HTML5 的 table 内容模型**:标准写法是 `<thead>` 排最前(`<tfoot>` 可以排在
`<thead>` 之后、`<tbody>` 之前,但 `<thead>` 本身不许排在 `<tbody>` 之后)。
- **会让读屏器的自动列头关联失效**:无障碍算法按**文档序**从数据格向上扫、找同列的
`<th>` 来读出「这一格是哪一列」,`<thead>` 排在最后时这条自动关联找不到表头。
呈现不受影响(CSS `table-header-group` 让 `<thead>` **视觉上**永远排最前、不看它在
DOM 里排第几,`querySelector`/`:last-of-type` 认的却是文档序——呈现与选择器认的不是
同一回事),走查看不出来,只有读屏器 / 标记校验器能测出来。复审当时判断:本仓从未
提出过可访问性(a11y)要求,故非违反既有标准、是此前不存在的隐性缺口;真要治得动
`bodyOf`/`addRow` 或重构成单个 `<tbody>`,都超出了那次修复「零平台改动」的前提。
**要治**(方向,未验证,留给认真做无障碍的时候一起评估):
- 平台侧治本:`bodyOf`/`addRow` 改成不依赖 `<thead>`/`<tbody>` 的相对顺序(直接按
`data-rows` 标记的那个 `<tbody>` 取,不用 `tr:last-of-type` 在整个 `<table>` 范围里找),
`<thead>` 就能挪回最前面,两全。
- 或者把两个 `<tbody>`(动态行 + 静态种子行)合并成一个,`<thead>` 天然留在最前——但
种子行「不受 `data-rows` 管辖、行数再少也在」这条设计意图需要另一种表达方式。
**影响面**:目前只有这两张表这么写(其余活动的可编辑表 `<thead>` 都在最前,因为它们
没有「零行也要有种子行」这个需求、`data-edit` 也没有写两遍)。
**排查法**:`grep -n '<thead>' activities/*/src/views/*.ts`,看它是不是排在最后一个
`</tbody>` 之后——目前全仓只有这两张表命中。
## `data-by` 嵌套查表:父键在本行里缺席时会悄悄落回外层同名字段(2026-08-30,终审 I3,浏览器真人走查缺陷①逮到)
**现象**(ACT-WORK-05 task-12,浏览器走查):数据工单件上,候选料那一行的「芯片类型」
只读态**显示着一个值**,而红带同时说它「缺芯片类型」——编辑态与直接查库都核实过
那一格确实是空的,屏上两处自相矛盾。
**病根**:`apps/api/src/modules/interaction/internal/domain/view-render.ts` 的
`renderRows`(:191)递归处理嵌套 `data-rows` 时用 `{ ...scope, ...one }` 合并「外层
作用域」与「本行」——`one`(本行)**没有**的字段会悄悄落回 `scope`(外层)里同名的
那一个。这个「悄悄落回」平时无害(未填的草稿行缺一个键,落回外层同名字段多半也是
空的),但当外层作用域**恰好**有一个同名、非空的字段时,`data-by` 嵌套查表
(`byParent`,:201)会拿这个不该被拿到的值去查表,查出一个不属于这一行的结果。
ACT-WORK-05 恰好撞上这个「恰好」:`engines.ts` 的 `taskData.view()` 会把「各对象最新
一行的字段名」铺到顶层(`task.data`),而 `chip_type_id` 这个名字同时是 `sub_eval.part`
/ `sub_eval.case` / `parts.profile` 三张表的列名——`case-model.ts:134` 那条注释早就为
同一个撞名问题给 S-FEEL 求值加过护栏(「这个名字在三张表上都有,顶层同名以后写的赢,
改用对准一张表的 `lookup`,不歧义」),但那条护栏只保护了 `case-model.ts` 自己的配方
求值,没有覆盖到 `view-render.ts` 这一侧的 `data-by` 嵌套查表——同一类撞名在这里
原样重演。
**已钉进套件、未修**(`apps/api/src/modules/interaction/__tests__/view-render.test.ts:460`
的 `it.fails`,已用最小复现核对过不是测试自己写错):这是共享面(E04/I01 也用
`data-by`),改动需要先报给设计者裁定修法,当时只坐实、不改 `view-render.ts`。
**要治**(方向,未验证):`renderRows` 合并作用域时,`one` 里**没有**的键不该落回
`scope` 同名字段——要么显式置 `undefined`(阻断落回),要么 `byParent` 取父键值时
只认 `one`(本行)自己有没有这个键,不看合并后的结果。两条都要过现有测试(「父值
缺席一行不留」那条已经钉死了「本行显式给出空字符串」这个不同的场景,不能被误伤)。
**排查法**:嵌套 `data-by` 查出来的值与红带/编辑态对不上,先怀疑这条——尤其当查表用
的那个列名在**多张表上撞名**、且当前这一行**没有**这个字段(不是空字符串,是键都不
存在)时。
## 运营中心 · 活动模块接口(2026-08-27 落地时记下的)
- **实例清单的过滤 / 分页在内存里做**:`listInstances` 全量取该活动的 `sys.task`,非终态行逐单读记忆与
@@ -1500,6 +1595,79 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
号从 `1` 起,最容易撞上种子数据里最早的几行)。
## `resolveRowKey` 的「全表一致」没有排除 `auto` 列 —— 第二形(2026-08-30,全分支终审 Critical 1)
上一条记的是 `type:'integer'` 那一形(§③ 续号步骤抢跑);把 `type_id` 改成 `text` 绕开它
之后,**同一个函数的另一段**(`resolveRowKey` §② 「全表一致」,`apps/api/src/app/routes/
desk.ts:144-168`)在**一个更窄的组合**下现出第二形:一张表的主键**唯一**一列就是
`auto:true`(没有别的非 auto 主键列陪衬,如 W05 的 `parts.chip_type.type_id`)。
**病灶链条**(`part-substitution-assessment@2.1.8` 的 `views/report.ts` 曾经这样写):
视图把这一列的种子行(件上「新建一行」时用来兜底提交的那一条静态行)干脆不给 `data-col`
(想法是「自增列不问人,索性连格子都不摆」)——`ViewContainer.collect()` 只认
`input/select/textarea[data-col]`,这一列于是**压根不出现在回传的 `cells` 里**。
`resolveRowKey` 的 `barred` 判断认的是 `k in cells`(这一列在回传里露没露过面),**不是
「值是否非空」**——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」,专门为「auto 列
件上摆着却空着」设计的 `barred` 保护(:135)因此从未触发,`known` 退回
`findKnownRow`(对空 `key` 恒返回 `undefined`),`isSeq('type_id')` 因为声明是 `text`
也是假 —— 两条已有保护都不成立,直接落进「全表一致」:表里此刻**恰好一行**(活动侧
的自投影机制保证了这一点,是巧合但不是意外)时,`seen.size===1` 把新建的那一行判成
「就是那唯一一行」,一次保存就把既有那一行的其余字段**静默覆盖**。
**这次只在活动侧绕开**(给种子行的这一格换一个真的、`disabled` 的 `<input data-col=
"type_id">`,值恒是空字符串——`barred` 就会正确命中,交回 `undefined` 让
`mintable`/`autoKeys()` 接手续号),`resolveRowKey` 一行没改,见 `views/report.ts` 里
`CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
「Critical 1」那组用真实函数逐字重放的两种 `cells` 形状。
**要治**(与上一条「§③ 续号加 `if (input.autos?.includes(k)) continue;`」是**同一刀**,
建议一起做):`resolveRowKey` 的 §②(全表一致循环,:144-168)在**进入这个 `for` 循环给
某一列赋值之前**,先判 `input.autos?.includes(k)` 就直接 `continue`(与 §③ 那条建议
补的守卫完全对称)。这样一来,声明了 `auto` 的列会**无条件**被这两段循环跳过 ——
不再依赖视图是否记得给它挂一个「看不见但露面」的 `data-col`;§① 那段「件上摆了、人也
填了」的正常改行路径不受影响(它在这两段循环**之前**就已经把值定下来,轮不到 §②③)。
补了这一刀,`barred` 这个专门的旁路判断其实就可以退休(它存在的唯一理由就是在 §②③
没有这条守卫时,给「视图记得开个空位」这种个例开个后门)——但退休 `barred` 是更大的
改动,不必和这次的守卫补丁绑在一起做。
**排查法**:与上一条同一句判词,但**不要求** `type:'integer'`——只要该表的主键里
`auto:true` 的那一列**是唯一的主键列**(没有别的非 auto 主键列陪衬,靠不上
`findKnownRow` 的「至少一格对得上」),且视图的种子/新增行**没有**给它挂 `data-col`,
就该怀疑这条坑,不论声明的类型是 `integer` 还是 `text`。
## 范围放行(`releaseWrite` 的 `only` 形)没有对 `refersTo` 做传递闭包(2026-08-30,ACT-WORK-05 e2e 撞见,H35 落地时在活动侧绕开)
**现象**:「数据工单确认即入库」(`apps/api/src/app/data-order-landing.ts`,2026-08-27)
把**这张卡的提交闸盯着的那几张表**(`dataOrderLedgers` 算出的 `only` 名单,取自卡绑的
`blocks` 或它自报的 `gateLedgers`)并进正式表(`ledger.releaseWrite(taskId,
{ only, cause })`)——只并名单上的表,不看这些表**引用**着哪些别的表。
如果 A 表(如 `parts.profile`)在 `only` 名单里、被并进正式表,而 A 表某一列声明了
`refersTo: 'B.x'`(如 `parts.profile.chip_type_id` 指向 `parts.chip_type.type_id`),
B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留在草稿层,**正式表里就会
出现一条引用着「只在草稿层存在、正式表查无此号」的悬空引用**:下一个任务(或同一个
任务的下一步)在正式表侧查 B 表,查无此值,`refersTo` 校验拒它,或 `data-by` 嵌套
查表显示不出来。
**这一次没有真的炸出悬空引用**——`part-substitution-assessment` 在活动自己的声明层
把 A/B 两张表都放进了同一张卡的 `gateLedgers`(Ruling H35,`case-model.ts` 那一步
`card` 定义处的大注释),绕开了这个触发点。但那是**这一个活动的选择**,不是平台
保证:下一个活动如果只把「主表」列进某张卡的 `gateLedgers`、忘了把它引用的「字典表」
也列进去,同样的悬空引用会在**正式表**里真实出现,不是测试环境才有的问题。
**要治**(方向,未验证):`releaseWrite` 的 `only` 形在计算真正要并的表集合时,应该
对 `only` 名单里每张表声明的 `refersTo` 做一次**传递闭包**——A 引用 B、B 又引用 C,
落地范围就该自动扩成 {A, B, C},而不是原样信任调用方给的 `only` 名单已经把引用链
配全了。
**同族**(下一条,紧接在后):都是「数据工单确认即入库」这个 2026-08-27 新加的机制,
与**别的**既有机制(这一条是 `refersTo` 的传递闭包,下一条是 `prune`)之间没有对齐
接缝造成的。
**排查法**:数据工单确认之后,别的任务查某张「被引用」的表查无此值(而草稿层里其实
有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它
`refersTo` 指向的「字典表」。
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见)
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
@@ -1525,9 +1693,9 @@ declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `aut
撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**:
`prune` 想撤、平台不让撤。
与 2026-08-30 已记的另一条(**范围放行没有对 `refersTo` 做传递闭包**,见「ACT-WORK-05 走查
逼出来的三条平台欠账」附近的记录)同族——两条都是「数据工单确认即入库」这个 2026-08-27
新加的机制与**别的**既有机制(`prune`、`refersTo` 的传递闭包)之间没有对齐接缝造成的。
与上一条(**范围放行没有对 `refersTo` 做传递闭包**)同族——两条都是「数据工单确认即
入库」这个 2026-08-27 新加的机制与**别的**既有机制(这一条是 `prune`,上一条是
`refersTo` 的传递闭包)之间没有对齐接缝造成的。
**要治**(方向,未验证,留给真正动手时评估):
- 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于
@@ -261,6 +261,17 @@ doneWhen sub_eval_part_exists_status_found = 2
- 人点「提交」= 数据工单确认 → **`chip_type` / `type_param` / `profile` / `parameter` 当场进正式表**
(平台 2026-08-27 的范围放行)。它们是主数据,该立刻可用,不等整单放行。
> **2026-08-30 补记**:实现是**八笔**,不是上面写的六笔 ——「提交」那一句已经点出原因:
> `chip_type` / `type_param` 要跟着数据工单一起进正式表(范围放行只并 `gateLedgers`
> 名单上的表,见 `docs/debt.md`「范围放行没有对 `refersTo` 做传递闭包」),而它们此刻
> 只在人新建类型时才有草稿行——挑一个已有类型时天然零行,闸拿「零行」误判成没干完。
> Ruling H35(e2e 真跑撞见)在②③之间插了两笔**自投影**(原样把选中的类型字典行 / 字段
> 清单行抄回自己身上,值不变、幂等,唯一目的是让 `mine:` 不再天然为空),使这两张表
> 能安全回到 `gateLedgers` 上。次序变成:①两颗料 → ②类型字典自投 → ③字段清单自投 →
> ④主数据骨架 → ⑤参数骨架 → ⑥案卷 → ⑦卡 → ⑧交代(与上面①③④⑤⑥对应,原④⑤⑥ 顺移成
> ⑥⑦⑧)。这两笔**不带 `prune`**(与③节 §11.4 那条改口同一个理由:字典/字段清单是共享
> 的,历史选型的陈旧行留着不算错)。详见 `case-model.ts` 该两笔头上的大注释。
### 4.3 S2 差异分析 —— 纯系统 + AI 唯一一次出场
```
@@ -313,14 +324,22 @@ doneWhen substitution_difference_item_rows > 0
### 4.4 S3 结论审核 —— 一张验收工单,人就地改
配方只有一笔:`card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一选项「确认通过」**,
带人工验收清单四条)。
**2026-08-30 改口**:这一节写下时以为配方只有一笔(`card`),下一段自己就说漏了 ——
真正的配方是**五笔**:①差异明细**重算笔**(2026-08-30 task-12「结论纹丝不动」缺陷补的,
见下)→ ②③ 复制自 S2 的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`(同一个对象引用,
不是抄了两份)→ ④ `card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一
选项「确认通过」**,带人工验收清单四条)→ ⑤ `say`。
人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → S2 的第③笔把推荐等级、摘要、风险
**重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。
人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → **①重算笔**先把人改的
`difference_status` 转译成 `judgement_class`/`risk_level`(这一步的存在理由:走查发现⑤
逮到的真实缺陷——最初的设计只复制了②③,人改的四态没有任何一笔把它转成
`judgement_class`,②③读到的 tally 纹丝不动,人在件上改了却什么都没变;`docs/debt.md`
已记「S3 存在的全部意义就是让人纠正 AI;纠正流不下去,S3 就是摆设」)→ ②③把推荐等级、
摘要、风险**重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。
> 重算发生在 S2 那一步的配方里,而 S2 已收步 —— 所以 S3 的配方里**复制一份 ③④ 两笔**
> (纯投影、幂等),不去回退步骤。这是刻意的:一步一份配方,不做跨步回跳。
> ②③ 复制的是 S2 那一步配方**同一个对象引用**(纯投影、幂等),不去回退步骤 —— S2 已
> 收步,S3 不能跨步回跳,只能在自己的配方里再摆一份同样的投影逻辑。这是刻意的:
> 一步一份配方,判定分类/风险等级的 `when` 链只许有一处真源(共用引用而非各写一份)。
这一步**不给 `doneWhen`**(它触达世界),人点了「确认通过」即收步。
@@ -499,7 +518,7 @@ tick
| key | level | 谓词大意 |
|---|---|---|
| `input-ready` | L1 | 两颗都在库 · 类型一致 · 参数行数 = 字段数×2 · 参数值不缺 |
| `input-ready` | L1 | 两颗都在库 · 类型一致 · 主数据与参数值都不缺必填(**不写**「参数行数 = 字段数×2」——2026-08-30 与实现一起改口:那不是基座数得出来的数,§4.2 doneWhen 尾注已经讲过同一条道理,这里当时漏改) |
| `every-param-judged` | L2 | `substitution_difference_item_missing = 0` 且四态四档计数之和 = `_rows`(AI 漏一条 → 那行四态为空 → 两边都不成立) |
| `level-by-rule` | L2 | 推荐等级与 `judgement_class` 计数一致(硬不符>0 必须是「不推荐」…) |
| `unknown-has-disposition` | L2 | `substitution_difference_item_missing = 0` |
@@ -574,7 +593,15 @@ tick
而判断正是人在 S3 要核的东西。
3. **类型字典会被建脏**(同一类型建两遍、字段名不一致)。件上下拉优先选已有、新建是次选;
MVP **不做去重合并**,记 debt。
4. **`prune` 会撤本单草稿层的行** —— 只在 S1 参数骨架这一笔用,别处不许用。静态审可以只放行这一处。
4. **`prune` 会撤本单草稿层的行**(2026-08-30 改口:这一条与实现一起对照过 —— 实际用在**三处**
`project`,不是一处:S1 的 `parts.profile`(④ 主数据骨架)/ `parts.parameter`(⑤ 参数骨架)、
S2 的 `substitution.difference_item`(②,§4.3)。§4.2/§4.3 的伪码从写下那一刻起就是这三处
都带 `prune`,这里当时漏改成了「只一处」。真正的约束不是「只许一处」,是**只许用在
「这一笔的数据源就是这张表在本单该有的全部」这一类骨架/明细投影上**——H35 后来新增的
`parts.chip_type`/`parts.type_param` 两笔**自投影**(4.2 节之外,原样抄字典行那两笔)
就**刻意不带** `prune`:它们写的是共享字典/字段清单,不是这一单独占的骨架,历史选型的
陈旧行留着不算错,带 `prune` 反而会在人改选类型后把已经落地的陈旧行撤成死锁——静态审
要放行的是「哪一类数据源」,不是「哪一个固定笔数」)。
5. **保存一次 = 一拍**。人反复保存十几次会吃步数,预算给到 40。
6. **表达力有上限**。上限之外的不要硬塞 —— 退回模型,或者把那一段切成独立的 agent 步。