# 技术债登记簿 > 编码规范 §17:历史违规集中登记于此——旧路径 `warn`、新代码 `error`,清零后升级。 > 例外必须注明原因与预期清理时间;散落的 `eslint-disable` 不算例外,算违规。 | # | 债 | 原因 | 预期清理 | 状态 | |---|---|---|---|---| | M6-1 | 转人代办的答案**不经出参 Schema 校验** —— 归型失败原样交回,由活动自己面对 | 人已经把事做完了,校验不过就得把它判成「调用失败」;而人做完的事不会因为格式不合就没做过。宁可让活动拿到一个明摆着不合型的值,也不谎报「这一步没成」。是否补一道「不合型即驳回重答」,等有真活动踩到再定 | 有活动踩到 | 进行中 | | M6-2 | 进程重启后恢复的任务,动作级 trace span 整段丢失(`beginAction` 依赖内存里的节点父 span,重启即空)| 观测尽力而为不掀翻业务的设计取舍;恢复路径要补父 span 得把节点 span 持久化或懒重建 | 观测面强化时 | 进行中 | | M0-1 | `pnpm db:seed` 只打印种子计划,未真正写库 | 种子数据(五岗位 / 五员工)已在 `apps/api/src/app/seed.ts` 冻结为常量并定义 `OrgSeeder` 端口;写入实现依赖 `org` 模块的 Drizzle schema,属 M1 | M1 | 已清理(M1) | | M0-2 | `packages/{sfeel,activity-sdk,ui-kit}` 仅有版本常量 | M0 只立骨架,内容按里程碑序填(§15);`@contracts` 已于 M1 落首版 | M3–M5 | 进行中 | | M1-1 | `container.ts` 里两个占位端口:`InFlightWorkProbe`(空集)、`TriggerMatcher`(恒不命中) | 二者的真身在 task(L6,M4)与 trigger(L7,M5);`MessageFallback` 已于 M2 接真身 | M4 / M5 | 进行中 | | M1-2 | 条件求值 `passthroughEvaluator` 恒真 | `@sfeel` 属 M3;M1 的监听只用类型 + 关联键过滤,不用内容条件 | M3 | 进行中 | | M1-3 | ~~投递记录写入即止,尚无派发 worker~~ | 派发器已于 M2 落地(advisory lock 串行 + 消费幂等);消费者(任务 / 触发器)随 M4 / M5 注册 | M2 | 已清理 | | M2-1 | 派发器的消费者表为空 —— 投递只标记已送达 | 任务消费者属 task(M4),触发器消费者属 trigger(M5);投递链路本身已可测 | M4 / M5 | 进行中 | | M5-3 | 活动 .JS 产物是源码文本,未经 tsup 单文件 ESM 真编译 | 静态审扫描的正是产物文本(import 与全局引用),当前形态已可审;真编译在装载器落地时接入 | M6 前 | 进行中 | | M5-2 | loop 的 Agent 回路有两个实现:单拍版(缺省,经 `ModelGateway`,两缝完整)与**开环版**(`JCAI_AGENT_OPEN_LOOP=1`:Mastra `Agent` + 工具总线,拍内自主连环调工具) | 端口 `AgentPort` 不变,usecase 未动。**开环的代价**:模型逐轮调用不经模型网关的录制回放(tool-calling 往返 `generate` 的 text-in/text-out 承载不了)—— 四元绑定与凭证隔离照旧走 `resolve`,**工具调用一缝完整**;要恢复逐轮录制须给 Mastra 供一个自实现的 AI SDK LanguageModel。开环待第一个真 loop 活动压测后再考虑转缺省 | 第一个真 loop 活动 | 进行中 | | M5-1 | `process` 未用 Mastra workflow 编译器 —— **运行时切面已立**(2026-07-28:`FlowRuntime` 端口 + 自持实现整体迁入 `internal/runtime/`,usecase 成薄壳,八例 E2E 为等价性护栏),编译器实现仍欠 | 编码规范 §6.1;接入路线已定(端口化+双实现,回边 reopen 先 spike);冲突清单见 process/CONTRACT.md | 编译器随 spike 结论落地 | 进行中(切面已立) | | M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 | | M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 | | M5-5 | ~~含网关历史版本的运行时兼容三处~~ | 用户令库只留一条流一个版本(外科手术清空),在途与历史归零 —— schema 的 `kind: 'gateway'`、`joinReady`、汇合等待、`gateway-deprecated` 违规项已于次轮全部摘除 | 2026-07-28 | 已清理 | ## 节点分派 · 员工组轮值(2026-07-28 第五轮更新) 编排不带岗位(活动点与审批点同一口径)——「谁来干」是运营时分配: 工作流绑了员工组按组轮值(组员并集最闲者先),没绑组**全员轮值**; 分派结果记在实例 assignee 上,审批箱/裁决权/链条/治理列表都按它认人。 ADR-011 的「绑定先于策略」随岗位一起退场 —— 工作组快照的**成员集合**成了轮值池, 按岗位点名的绑定语义不复存在。still 欠:起单时选组、按人指派、认领池 —— 运营中心的 分派界面落地后,这里的默认轮值让位。 ## 编排层 Mastra 化 · 阶段状态(2026-07-28) 四阶段路线(用户拍板:端口化 + 双实现;回边先 spike;Agent 放开工具循环): | 阶段 | 内容 | 状态 | |---|---|---| | 0 | loop 三修(提交放行发验收卡 / 事件只记不跑 / 轨迹喂回叙事面) | ✅ 已落 | | 1 | 抽 `FlowRuntime` 端口:执行面成可替换切面,usecase 成薄壳 | ✅ 已落(八例 E2E 为等价性护栏) | | 2 | reopen 回边 spike:`.dowhile` 子图 vs 外部胶合新 run | ✅ 已定案:**路线 B**(`spike/mastra-reopen/FINDINGS.md`) | | 3 | Mastra 编译器 = `FlowRuntime` 第二实现 | ✅ 编译器 + 胶合层已落(路线 B);**尚欠 `MastraRunner` 的真实现与接线开关** —— 见下 | | 4 | Agent 开环:Mastra `Agent` + 工具总线 | ✅ 已落(`JCAI_AGENT_OPEN_LOOP=1` 开启,缺省仍单拍) | **解耦本身已达成**(不依赖阶段 3):编排层对活动只有两个触点(`TaskGateway.start` 起活动 / 终态回报),执行面可整体替换而活动侧无感 —— 证据是 `new-package-flow.it.test.ts`:从零建一条新流(建包 → 链接已装载的活动 → 双判据激活 → 起单 → 走完),全程没碰活动一行代码。 **阶段 3 的剩余欠账**:`MastraRunner`(startRun / resumeRun 的真实现,需要 `Mastra` 实例 + `PostgresStore` 挂 `mastra` schema)与接线开关未做 —— 编译器与胶合层都已落且有单测,但**没有在真库上跑过一整条流**; 转正之前自持实现仍是唯一在跑的路径。真跑时要验的两条(spike 已点名): 跨进程 `createRun({ runId })` 取句柄再 resume、Postgres 下的快照并发写。 已绕过的语义冲突(详见 process/CONTRACT.md):回边 reopen(路线 B 解决)、 `.parallel()` 任一失败即整体失败(失败走向不进 Mastra)、三态聚合投影 (读实例表不读 run 状态)、`node_instance` 投影表照写(发号权在主工程)。 ## 鉴权(ADR-012 落地后的已知欠账) - **撤角色时效 ≤ 5 分钟**:改了 Keycloak 里的角色,现有 access token 到期前仍按旧角色算(手册 13.4); 要立即生效在 Keycloak 上 `POST /users/{id}/logout`。停用经名册同步后即时 403。 - **票据 60 秒可重放窗**(无状态换来的):绑定 sub、只认 GET;要单次得加一张已用票据表。 - **票据没有用途,横向范围 = 持票人的全部读权限**:它走在 **URL** 上(浏览器历史 / 反代日志 / `Referer` 都可能留下),而中间件只认「这枚票据是谁的」,不认「取它是为了干什么」—— 一枚为下载附件取的票据,同一分钟内照样读得了 `GET /api/gov/data/:domain/:object`(整张客户主数据)。 **不是提权**(那些读权限他本来就有),风险在留痕面。收敛做法二选一: ①签发时把用途绑进票据(`v1....`,scope ∈ {stream, file},中间件按路由核 scope); ②中间件对「认票据」的路径设白名单(只有 `/api/stream` 与那两条下载路由认 `?ticket=`)。 - **名册同步是手动按钮**(运营中心 · 员工 · 同步),没有定时;没配 provisioning 就只有首登即建。 - **web 待按《Keycloak 前端接入手册》接入**:登录页 / Bearer / 401 跳转 / SSE 与下载改走 `GET /api/auth/ticket` / 入口亮灯改读 `GET /api/me` 的 `ends`;接入前登不进。那一刻一并清掉 `SURFACE_FNS` 与退役契约。 - **退役契约下个迭代删**(§12.4 三步之第三步):`loginRoute` `logoutRoute` `sessionRoute` `createStaffRoute` `disableStaff*Route` `listGrantsRoute` `setGrantsRoute` 与 `sessionSchema` / `grant*` / `governanceFunction*` / `disableCheckSchema`。 - **`groups` 未用**:日后要按部门过滤数据,需 Keycloak 配 group membership mapper。 - **aud mapper 未配即全线 401**:401 的 message 会点名「给发 token 的那个 client 加 audience mapper」,但没有启动期探测。 - **无库环境里,路由覆盖的 fail-closed 保险会静默消失**:`apps/api/src/app/__tests__/route-coverage.it.test.ts` 用 `describe.skipIf(database === undefined)` 跳过(同目录另外三份 it-test 同款,是仓库级惯例)—— 没有库的环境里这条测试根本不跑,进程仍 exit 0;正在搭 CI 的人必须先给它一个库, 否则「写了路由处理器却没在契约里声明 access」这类漏洞会在 CI 里悄悄放过。 ## 工作组(ADR-011 落地后的已知欠账) - ~~**没有治理台管理界面**~~ —— 2026-07-27 清账:运营中心 · 员工 · 工作组页签可建组 / 改组 (`GET|PUT /api/gov/work-groups`);实测改绑定后新起工作流的 `work_group` 快照即含新成员表。 - **一人一组**:`workGroupOf` 取该人名下稳定的第一条;起单时选哪个组这一步没做。 - **组成员离岗无提醒**:绑定的人离岗时静默退回四策略,不告诉任何人。 ## 工作成果 · 文件 - ~~**Markdown 只以原文纸面呈现**~~(2026-08-23 销账):渲染接上了,而且**一个依赖都没引** —— 基座那一段 `markdownBody`(`file-compose.ts`)把子集(标题 / 段落 / 有序无序列表 / GFM 表格 / 分隔线 / 粗体 / 行内代码)转成固定几种标签,全程转义,再走同一个沙箱容器渲。 与下载到的 .html 是同一段函数出的,两处看着是同一份文件。 `FileDoc.markdown.html` 缺席时仍退回 `
` 原文 —— **不假装渲染过**这条没变。
  仍不认的:内联 SVG、图片、引用块、代码块。认得越多,docx / xlsx 那一侧要对应的越多。
- **过程成果(deliverable)尚无来源**:活动还不发布 deliverable,所以成果文件目前
  只有活动推的那一屏。装配已接好(`desk.ts` 的 `results`),活动一产出就自动进列表。
  **注**:2026-08-23 起活动**另有一条产文件的路** —— 台账对象声明 `file` 段,件上挂
  `data-file-of`,人在工作区点资料名即开(I01 首用)。那条路不经 `deliverable`:
  资料的正身是台账那一行,文件是它的投影。两条路要不要并成一条,等第二个活动要用时再说。

## 沟通记录(占位)

工作区第二个页签「沟通记录」目前是空占位。它要汇集这一单**与外界的往来**:
邮件线程、通道消息、原件存档 —— 数据在 `channel` 与 `event` 里已有,尚未做投影。
界面上明说「还没接」,不伪装成「暂无数据」。

2026-07-28 对账:这条曾**只写在注释里没落到文案上** —— 注释写着「明说没接」,
正文却写「都会归到这里」,等于把没接说成了空,人会一直等。已改为「往来记录还没接通」,
并给空态加了第三色调 `muted`(通路没接,灰着不许诺;区别于 `brand` 的「等着就会有」)。
`outbound_mail` 至今只有 channel 建表与清库脚本碰过,任何界面都读不到 —— 所以文案也
不把人指去「消息」面。

## 工作区页签条装不下五个页签

五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,工作区仅 429px,差 94px。
当前先做成**整条可横滚**(不裁、不溢出卡片),最终形态待定,备选:
- 缩短页签文字(沟通 / 成果 / 详情 / 日志)—— 省 ~100px,但文案变短
- 翻页囊只留箭头、页码收进 title —— 省 ~50px,但看不见「第几件/共几件」
- 页签条换行两排 —— 全都放得下,但工作区正文少一行高

## 驾驶舱 · 死信卡(2026-07-27)

运营驾驶舱首屏收了「异常工作流 / 未闭合草稿 / 在途任务」三卡,**没有死信卡**。
死信目前只以治理者消息要素送出(`deadLetters.report` → `message`),没有一个按 kind 计数的
查询面 —— 要补这一卡,得给 system_message 加一条按 kind 聚合的治理路由。
在那之前,驾驶舱不摆一个查不出数的卡位。


## 工具箱 · MCP 连接(2026-07-28)

工具箱「接入」段有两件:**导入自定义工具已实装**(与第一方同门两审,端点调不通就不入目录);
**MCP 连接仅呈现**。系统还没有 MCP 客户端 —— 接进来之后 MCP 的工具与自定义工具同等待遇:
经工具调用一缝去调、过两审入目录、按白名单授给活动。卡上如实标「未连接」,不摆点不动的按钮。

## 活动槽位未实现(2026-07-28)

六个槽位(rfq-intake / alt-selection / sourcing-inquiry / cost-pricing / quote-negotiation /
intel-archive)只有契约面,入口产物是抛「未实现」的占位 —— 底座备齐后按 A1→A7 顺序逐个开发,
届时以新包版本经安装通道换入,槽位契约不动。

2026-07-28 更正:这条原写「起单后任务会停在 A1」,手工走查后发现说得不准 ——
**A1 本身走得完**(六步人工代办 + 收口验收放行,活动 `released`),卡的是**它后面的 G1**:
出口的必填去向旗产不出来,网关判不了。准确的诚实状态见下文「编排层 · 手工走查」。

## 编排层 · 走查已清项(2026-07-28)

一轮系统走查后已修:
- 卡片被压扁剪裁(`.g-page` 是滚动 flex 列,子块要 `flex-shrink: 0`)
- 数据对象只见键不见中文名(label 随物化落 PG 表/列注释,全链读回)
- LLM 工具错做成模型实例(改为五件大模型驱动的系统内通用工具)
- 元素盘点击落子落不下(原型里就坏:`create.move` 无 hover 目标 → `create.rejected`)
- 卡点还原凭空造边(`e-A5-A4`:审批点的驳回牌子被当成边;改为只认亲不新建)
- 已发布包改图找不到路(编排工作区就地给「起草新版本并去编辑」)
- 活动契约面治理台看不到(列表出参 + 活动详情 + 导出依赖文件三处打通)

已验为**正常**、不需要改的:编辑器往返无损(16 边 14 节点、映射与驳回声明全留)、
边映射校验逐边点名、发布门硬(缺条件即 412)、调起规则四类齐、导出依赖文件内容完整。

## 编排层 · 手工走查(2026-07-28)

把 `unified-quote` 从起单一路手工答到底,发现并已修 **5 项**:

| 症状 | 根因 | 修法 |
|---|---|---|
| 流程链读作 A1→A7→A2→A6→A3 | `toChain` 按 `layout.col` 排,而 MVP 图是回形走线 | 新增 `advanceOrder`,按边拓扑排、排除回边 |
| 七个活动派出七张一样的「请处理」 | 槽位骨架 `card: {}`,工单落回默认 | 卡面自述:标题 = 步骤标签,依据带步序 |
| 走到第 3 步就 `exhausted` 终态 | 六槽位共用 `humanCalls: 3`,A1 有 6 步 | `budgetFor(n)` 按各自步数给 |
| 六步答完卡在「等人却没东西给人答」 | 末步全是 execution,而 `released` 只出自末步验收放行 | 骨架末尾追加一步「收口验收」 |
| 只走了 A1 却报 `completed` | 状态聚合把「没有在跑的了」当完成 | 「完成」凭到过终点;收敛未到终点 = `abnormal` |

**当前诚实状态**:A1 六步可人工代办、收口验收可放行、活动 `released`;但流**过不了 G1** ——
出口 `intake` 的必填去向旗(`needAlternative` / `needSourcing`)产不出来。这不是缺陷:
出口载荷由**台账落地事实**推导(数组←落地行、标量←铺平事实),槽位没实现台账写入就没有事实,
让它凭空捏一个就是在系统内部 mock,规矩不许。现在这个状态会**如实上报**:
工作流 `abnormal` + 一条 `route-dead-end` 事件写明「G1 的两条出边要 needAlternative,
当时可用的键只有 body / customerCode / records / rfqRef / subject」。

**于是 A1 的开发完成判据就具体了**:`rfq-intake` 必须落 `project.intake_record` 等行、
并让 `needAlternative` / `needSourcing` 成为落地事实 —— 否则 G1 永远判不了。A2…A7 同理:
先看自己出口的必填字段能不能由落地事实推出来。

> 后记(2026-07-28 稍后):占位骨架落地后整条流已在真库上八例端到端全绿;
> 且**网关已废除**(G1…G4 不再是节点,上表按当日图记述)—— 判据不变,只是「G1 判不了」
> 如今读作「A1 的条件出边判不了」:要 `needAlternative` 的还是那两条边,只是边挂在 A1 身上了。

## api:check 的盲点(2026-07-28)

契约面只收 `index.d.ts` / `contract.d.ts` / `port.d.ts`,而 `index.d.ts` 是 re-export **名单**。
所以**给纯函数加形参、改返回类型,这道闸看不见** —— 实测:`aggregateWorkflowState` 加了
第三个入参,api:check 报「公开契约无变化 ✔」。名单变化(增删导出)照样能拦住。
要补得让契约面也收 domain 的 .d.ts,代价是全部纯函数签名进快照、日常改动噪声变大;
在那之前:**改纯函数签名时手动同步 CONTRACT.md**,别指望闸提醒。

## 触发链路 · 邮件分类 Agent(2026-07-30)

用户定的触发逻辑五步:接收邮件 → **分类 Agent 识别消息类别** → 命中 RFQ 事件 →
(可选)系统消息到指定人工作台 → 工作台确认起单。第 ④⑤ 步(确认链路)本轮已落地
(`sys.trigger_pending` + 消息 `eventRef` + 幂等确认);第 ② 步的「分类」目前由
**通道抽键正则 + 触发器 S-FEEL 规则**代位 —— 判得了「主题带不带询价/RFQ」,判不了语义类别。

升级路径:通道入站归一后挂一个 LLM 分类工具(工具箱五件之 classify,经模型网关一缝),
产出 `messageCategory` 进事件载荷,触发器规则改判 `messageCategory = "rfq"`。
前置:模型实例已绑定(大模型区)、分类样例集与回放审。不阻塞现有 RFQ 流 —— 正则今天够用。

## 范式统一的欠账:占位活动没有模型就跑不动(2026-07-29)

用户定:**活动不再分 flow 和 loop,内部实现都是 Loop**。本轮照此退役:
`flow-engine` 模块整个删除、`paradigm` 字段从清单与两张表退场、
`activities/` 里四个 flow 活动(A1/A4/A6/A7)与六个占位槽位改写成案例模型(CMMN + 说明书)。

**直接后果,写在明处**:`turn-runner.runTurn` 每一拍都要 `agent.decide` ——
环境里没有模型 key(`JCAI_DEEPSEEK_API_KEY` 或等价的实例绑定),案例活动**一步也走不动**。
于是三套端到端整套跳过,并在文件头写明恢复条件:

| 套件 | 为什么跳 |
|---|---|
| `unified-quote-flow.it.test.ts` | 靠答验收工单走完整条流;工单从前由 flow 末步发,现在由 Agent 每拍决定 |
| `new-package-flow.it.test.ts` | 同上(新建流程包后走一遍) |
| `testbed-trace.it.test.ts` | 同上(测试台起单 → 真任务 → 全链 Trace) |

恢复要两件,缺一不可:①环境有可用模型;②走法改写成 loop 的口径 —— 按 Enabled 集推进,
而不是按步序答工单。**在此之前不许把断言改松让它变绿**:绿得不诚实比红着更坏。

顺带作废的旧欠账:「占位期六个槽位一律 flow 范式,真开发时装回 loop」——
已经没有 flow 可退回,这条不存在了。

## 活动与工具调用:实现前探明的账(2026-07-30)

写《docs/活动开发规范.md》时三路探查把现状钉死,发现的差距与断链**全部汇总在
规范第 12 章「待实现清单」**(11 条主账 + 一串小不一致),此处不重复,只记两条
必须先修的:

- **写类动作意向登记后永不执行**:`submitForRelease`/`precheck` 无调用方,活路径
  绕过预检 → 意向永停 `drafted`,而 `land()` 只执行 `prechecked`。「所有写动作必须
  经人类验收」目前只做到了「登记」,没做到「验收后执行」。
- **工具未命中转人**:产品定「声明了但系统没有的工具,调用时转人类执行卡 + 作业
  指导书」;现状是运行时抛 `ToolNotFoundError`、安装时 `tool-contract-missing` 直接拒装。

实现活动与工具调用逻辑时,照规范第 12 章的顺序销账。


## ~~A2 执行单的可编辑表渲染不出输入框~~(2026-07-31 已销)

**结论:不是渲染 bug,是两件事叠在一起。**
① 我自己的排障失误 —— 用 `.closest('button,a,[role=button],li,…')` 找任务卡,匹配到了错误的祖先,
   于是屏上一直是**上一个任务**的工单(把 `editing`/`order.id` 打到 DOM 上一读就现形:
   `oid` 与 A2 的工单号对不上)。正确的是 `.closest('button.task-card')`。
   **教训:排障先把状态摆到 DOM 上读,别靠一层层猜。**
② 真缺陷 —— A2/A3 派的执行单**只给列名不给行**(cols 4/6,rows 0)。
   可编辑表没有行就没有格子:人打开看到一排表头加一片空白,一个字都填不了。
   已在两个活动的说明书与剧本里写死:**两张表都必须带行**,没有的值给空字符串
   (空格子界面自己标红「待填写」)。A2@1.4.0 / A3@1.1.0。

原记录 **现象**:MVP 工作流第二轮,A2 的执行工单载荷完全正确 —— `mode: execution`、两张 `t:"table"` 且 `editable: true`(已用 `/api/desk/tasks/:id` 核过接口出参),页签停在「待处理」、 `disabled` 为假(提交钮在)、两张表都渲染出来了,**但一个 `` 都没有** —— 即 `WorkOrderReport` 里的 `editing` 为假。同一条代码路径在 A1 的执行单上是好的。 **已排除**:①载荷缺字段(接口出参已核)②`blocks` 没传到(ViewRegion:382 传的就是 `cur.blocks`) ③`disabled` 为真(提交钮在)④`useState` 初值只算一次 —— 已改成渲染期按 `order.id` 重算,**没解决**。 **下一步**:在 `work-order-report.tsx` 里把 `editing` / `order.id` / `order.blocks.length` 打到 DOM 上直接读一次,先确认它到底是不是假、以及 `order` 是不是 `cur`。 这条挡着 MVP 工作流跨过 A2,是三轮端到端的**第一个硬阻塞**。
## ⑨ A2 派的是一张没有表的泛化卡(2026-07-31,未销) **现象**:MVP 第三轮,A2 起单后派出的执行工单标题是「需要你判断下一步」、**blocks 为 0** —— 而剧本 `ALTERNATIVE_STEPS.T_select` 明确要求卡面放两张可编辑表(初筛清单 / 替代对标表)。 上一轮(A2@1.1.0)它是发了两张表的(只是 rows 为 0,即 ⑧);这一轮连表都没有。 **「需要你判断下一步」是 `fillCard` 的兜底文案** —— 说明模型给的 `human` 行动卡面几乎是空的, 基座用当下局面把它补全了。也就是说 Agent **没照剧本组卡**。 **下一步**: · 先看这一拍的模型出参(trace 里的 ModelCall),确认它到底给了什么; · 若卡面确实空,问题在提示词 —— A2 的 guide 写得比 A1 长且全是「怎么组 blocks」的细节, 可能被单拍上下文里的其它段落淹掉;考虑把 blocks 的形状挪进 ACTION_ITEM_SCHEMA 的 description(schema 比说明书硬),或在 guide 开头就把 JSON 骨架给全。 · ⑧ 的修复(必须带行)因此**尚未验证** —— 它要等 A2 真的发出带表的卡才测得到。 ## A1 视图与判据重做(2026-07-31 第三轮)—— 已销与余账 **已销**(六轮端到端逐轮逼出来的,每条都有实测账): - 报告模版照 PRD `活动执行页/` 两份目标件重做:`INTAKE_OVERVIEW` = 来件原文 → 受理要素 → 指标带 → 有效 PN 清单(必填/选填胶囊 + 缺必填整行标底)→ 本轮不报; `INTAKE_RELEASE` = 要素 → 逐条记录(带状况标)→ 分级依据 → 分流去向 → 两类请求 → 结论 → **这一签下去会发生什么**。`@sdk` 新增 `letter` / `factCards` / `actionList` 与列样式(`req`/`opt`/`num`/`pill`),`data-blank` 让空值真的留空(判断在样式里,不在填数里)。 - **`@sdk` 五个零调用方的拼装件已删**:`kvCard` / `tableCard` / `noteCard` / `stagesCard` / `critList`(六个活动一个都没用过)。 - **说明书不再重写一遍逐步做法**:它与剧本 `guide` 曾各写一份六步,分叉过 —— 说明书里点名的 `S1_register` / `S2_ask_back` / `S3_normalize` 三个计划项案例模型里 根本不存在,模型照它报 itemId 一报一个被拒。现由测试钉死:说明书不许点名计划项。 - **机检判据不再两边自报**:新增基座读数 `域_对象_rows`(本单在那张表写下几行), `lines-present` / `route-alt` / `route-rfq` 各有一边读它;`quote-intake.classify` 的出参带 `counts`,条数照抄不靠模型数。实测拦下的是:条数报 9 而表里躺着 10 行、 三类条数报 7A/2B 而判定器给的是 5A/3B —— 派单数与条数同源同错,`2 = 2` 顺利过关。 - **「问过 ≠ 答过」**:S2 的收步判据从 `asked_total`(卡发出去就为真)改成 `answered_total`(答案已写回台账)。此前人填完提交,引擎下一拍开头就把这一步收了, 模型没轮到把答案写回来 —— 那一格白填。 - **归档件不再被冲掉**:收口后引擎还会跑一拍,那一拍推固定模版名 `case-overview`、 退到「声明的第一张」,把人刚签过的放行页冲成阶段二的执行件。现按剧本 `view` 推、 活步收完不推。 **余账**: | # | 事项 | 现状 | 目标 | |---|---|---|---| | A1-1 | 制表符 BOM 的拆列靠模型数格子 | 已在说明书里写死「每行格子数 = 表头列数」,第六轮九行全对;但这是提示词约束,换个 BOM 仍可能错位 | 基座把附件读成**结构化行**(表头 → 对象)一并交给活动,模型只做列的语义映射,不数制表符 | | A1-2 | `preauthorizedActions` 仍写在六个活动清单里 | 全仓零消费者(§15 ⑨),但契约里是必填字段,删它是破坏性契约变更 | 三步弃用:先转可选、再从清单摘、最后从契约删 | ## Harness 可靠性:三处「门本身教模型撒谎」(2026-08-01,已销) 端到端连跑两条样例路径逼出来的,每一条都不是模型跑偏,是**门的形状不对**: - **① 判据只接受「是」,模型就去改那个数。** `optional-asked` 原是 L2: `missing_minor_total = 0 or asked_total > 0` —— 两个数都由活动自己写。 实测:一颗料只缺目标价,模型如实记 1,提交被连拒四次,第五拍把它**改回 0**, 判据当场变绿放行,台账从此写着「缺选填 0 条」。 **机检判据只许读基座数得出来的数**;读活动自报的数,它不是门, 是一句「改哪个数能过」的提示。这一条已降为 L4 交人,并把缺口数摆上放行页指标带。 - **② 裁量判据也被要求「为真」,模型就去改裁断。** 换成 L4 之后,它如实裁了 「未满足」并写了理由,提交照样被拒 —— 判、提、拒、再判……十二轮 21 拍 187k token, 最后一拍把裁断翻成「满足」才过去。**裁量的本义就是可以裁出「不满足」**, 而不满足恰恰最该交给人。提交门已改:机检(L1/L2)必须为真,裁量(L3/L4)**裁过即可**, 裁断连同理由随卡交给签字的人。 - **③ 熔断数不出「同一堵墙」。** 一拍一串行动,同一个理由拒回来时标题里带着串位 (「第 2 个」「第 5 个」),整条标题一比就成了 N 堵不同的墙;而叙事窗只有 20 条, 一次撞墙连带五六条留痕,至多装得下三四次。两处叠加的结果:一单被同一条判据拒回 **33 次**、40 万 token 烧到 `exhausted` 才停,期间界面上只是「进行中」一动不动。 已修:比对时削掉串位前缀;熔断另开 60 条的窗(只在引擎里数,不进提示词)。 顺带:计划项号认唯一前缀简写(`S4` → `S4_history`),歧义为零才认 —— 实测三轮里两次因为模型只写前半截白烧一拍。 **验收**:两条样例路径各三轮,全部 `released`;Itelma 10 拍 / 88-90k / 行动被拒 0, 样例1 9-11 拍 / 71-88k / 行动被拒 0-1;数据逐轮对得上(9 行 5A/3B/1C · 1 alt + 3 rfq)。 ## 试跑日志吞主键(2026-08-01,已销) 存档那一屏只渲染 `patch`,`key` 整个不显示 —— 三行受理记录屏上只剩 「eau / lead_time」,看的人据此判定「PN 没有提取到」,而 PN 三条一条不少全在 key 里。 实时观测面(`activity-run.tsx`)一直是对的,只有存档面走岔。两处已合并到 `features/gov-testbed/internal/drafts.tsx` 一份实现。**没显示不等于没有, 但屏上看起来就是没有** —— 这也算「界面不撒谎」。 ## 画面比数据早一拍 / 满屏时刻差八小时(2026-08-01,已销) 用户实测报「PN 没取到,还是 —;第二阶段过不去」。查下来两件都不是抽取的事: - **帧推在行动之前**。`turn-runner` 每拍推画面那一下排在行动循环**之前**, 于是工作区那份件永远比数据早一拍:S2 那一拍把九条料写进草稿,而那一帧是写之前推的, 清单一行都没有;偏偏这一拍以「发卡问客户」收尾,整单挂在那儿等人 —— 人打开工作台看到一张空表,合理地判断「PN 没抽出来」。 已改成**推在行动之后**;提交那一下由 `raiseAcceptance` 自己摆放行页(`framed` 打招呼,回合末不再盖)。 测里钉了顺序断言(推帧时已写下几行,推早了就是 0)。 「第二阶段过不去」是同一件事的另一面:卡在等人答那张执行卡,答完即收步(已复跑验证,11 拍 released)。 - **满屏时刻是 UTC**。后端存 UTC ISO,前端 17 处 `at.slice(5,16).replace('T',' ')` 直接摆上去 —— 一封 08-01 04:18 到的信,工单写「派发 07-31 20:18」、气泡写「20:18」。 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实,人拿它核 SLA、跟客户对时间,一核一个错。 已抽 `@web-shared/at.ts`(atMinute / atClock / atFull)统一换掉;报告里的入站时刻走 `case:openedAt`(基座填,同样本地化)。 **顺手撞到的坑**:批量替换把 `FlowEditor` 里一处 `text.slice(0, 16)`(截标签长度,不是时刻) 也改成了时刻格式化 —— 已回退。正则扫时刻要认得出「哪些 slice 不是时刻」。 ## 拆表交给基座 · 同一件事不许问第二遍(2026-08-01,已销 A1-1) - **附件里的表由基座拆好**(`agent-runner` 的 `sheetRows` / `attachmentPart`)。 数制表符是能算的事,交给模型只会数错格子:实测同一行栽过三轮 —— BOM 上白纸黑字写着「Антенна · 浙江嘉康 · (空) EAU」,拆出来成了 「集成电路 · (空) · 30000」:品类错、制造商丢、年用量凭空多一个。 现在 `attachments[].rows` 一行一个对象、键就是表头;列数对不齐就不认这张表 (半张读歪的表比没有更坏)。 **两处坑连着踩**:①先把 rows 塞进那段按 1800 字截断的作用域里 —— 三轮下来照错不误, 因为它压根没进提示词;②原文与拆好的行并排放,把这一段撑了一倍。 现在附件**单独成段、不参与截断**,拆出来就把原文换下来。 验:同一条 BOM 连跑三轮,九行数据**一模一样且全对**(此前每轮都不一样)。 - **同一件事不许问第二遍**。熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法, 只是问的是同一件事。实测一单里同一个缺口连问三遍,四次调人的预算耗尽落 `exhausted`。 现在发卡前比一次:**人已经答过的同一张卡**直接拒,并把出路写进理由 —— 「他把格子留空交回来,就是对方给不了,那也是一个答案:如实记账、往下走」。 认的是标题(标点与全半角归一后比),**不做模糊匹配**:拦错比多问一遍更坏。 - **待处理那张可编辑表绑台账**(`blocks[].ledger` + `fields`): 发卡那一刻基座从草稿层把**整份清单**铺进去,人看到的是完整九行、逐格可改 —— 与 PRD 执行页一致。此前活动自己把行塞进回复里,两处死结:回复有长度上限, 铺长了半途截断整拍作废;那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会 (抄的时候把 BOM 原样的 30 填了进去,而台账里已经是 30000)。 ## 阶段2/阶段3 两张件对 PRD 逐块核到齐(2026-08-01) 按 `~/coding/JCAI_PRD/docs/活动执行页/` 两份目标件逐块比对(比对脚本按块名断言,不凭印象): **阶段2 待处理 22/22 · 阶段3 工作成果 17/17**。这一轮补齐的三块: - **归属签**「通道入站 → 冯兵 · BD」:两样都是基座知道的事实(触发来源、任务负责人), 由 `case:origin` / `case:owner` 填 —— 让活动自报等于让被指派的人自己说自己是谁。 两头有一头填不出就整枚不摆(`:has(b:empty)`)。 - **落款位置签**:写「这份件在流里是什么位置」(A1 受理归一 → 交下游), **不写状态**。在办与归档是同一张模版,写「已放行」进了档案才成立,在办时就是谎。 - **成交迷你柱**:`market.price_history` 有 customer_code / quoted_at / unit_price 三列, 但**这张表一行数据都没有**(种子不写、CRM 那件工具是桩)。所以不画六根编出来的柱子, 留原型自己的**空轨**(虚线)并在旁写清为什么空 —— 空得有出处,才不是组件坏了。 真要画,先得有人往那张表里写成交(见余账)。 仍**有意不做**的两处,理由写在 `views/report.ts` 的文件头: ① G2 命中行高亮 —— 命中在编排层求值,这份件画个对勾就是替它宣布结果; ② 报告里的编辑态与按钮 —— 改数据走执行工单(那张表已绑台账、整份九行逐格可改)。 **余账**:`customer` 维度的成交台账没有写入方 —— A4 定价要取历史锚点、A1 要画成交柱, 两处都指着它。定了谁写(A6 议价成交回写?A7 归档?)再画那条柱子。 ## 去向由分类表算出、验收页可就地改(2026-08-01,用户定的口径) 用户口径两句:**阶段3 要按验收目标件做,而且可编辑;最后走哪条边是分类表算出来的, 不是人编辑或选出来的**。照此改了三处: - **出口谓词进契约**:`output.selective[].ports[].when`(S-FEEL,按序求值首个命中即出)。 A1 的三口写成 `type_c_total > 0` / `type_c_total = 0 and type_b_total > 0` / `type_c_total = 0 and type_b_total = 0` —— 与报告上那张 G2 规则表逐条对应。 发放行卡时基座自己判,卡上只如实告诉人**走哪一口、凭哪一条**(`routedPort`), 不摆选择题;算不出来的组(每口都得写 `when`)才退回「人选一口」。 `revisableAtRelease` 里那两枚去向旗随之撤掉 —— **去向不是人该改的东西**。 - **验收页可就地改**:必交付物声明 `editableAtRelease: [列名…]`,基座在放行卡上铺一张 绑台账的表(整份行都在、逐格可改);人提交那一刻**基座写回草稿层**,AI 不经手。 执行卡上那张表同理(`blocks[].ledger` + `fields`)。 - **人手改的值与 AI 写的值过同一道归型门**。第一版直接调 `draftWrite` 绕过了 `coerceToColumn`,于是一句「客户已确认」落进 `double precision` 列, 到 `releaseWrite` 才被 PG 掀桌 —— **人签完字之后**,整单落 `blocked`(实测)。 现在归得了就归、空值不写、归不了**跳过那一格**并留痕(别的格照落): 人填错一格不该把他改对的另外八格一起丢掉。 **又踩了一次「链上最后一处丢值」**:`blocks` 契约声明了、submit 传了、release 转了, 到 `container.ts` 那个显式对象里被抹平 —— 编译不报、运行时静默丢,卡面 blocks 恒空。 `fields` 当年就是这么丢的,注释还在旁边。**装配层的显式对象是这条链的最后一关**, 加字段时那一处必须一起改。 ## 交代不复读:一字不差拦不住换个说法(2026-08-01) 用户指着对话区那一段问「不觉得啰嗦吗」——连着五条「阶段结论」说的是同一件事: 「9颗料都打上标了:7颗A…」「9颗料都打标完了:5颗A…」「这单9颗料都打标完了…」 字面各不相同,读到的却是同一句被复读了五遍。原来的去重只认**一字不差**。 现改成看重合度:两句归一(去标点空白)后,短的那句八成以上的字出现在长的那句里就算同一句。 阈值不敢再低 —— 近义但不同的交代重合度远低于此,**拦错一句比多说一句更坏**。 ## 件是投影,不是快照 —— 改完数据要重渲(2026-08-01,一半修完一半列账) 用户:「我编辑了表格,点了保存,实际表单变了,但是保存后 A、B、C 的图标没变; 我全修改成 A,按理说底下的流向应该是直通 A4 直接定价,也没有受影响。」 三层原因,前两层已修、第三层**未修**: **① 保存压根没落库。** `onEdits` 只把值塞进前端缓冲,等提交那一下才带走。 新增 `POST /api/desk/tasks/:taskId/table`(复用 `applyBoundTableEdits`),保存即写回草稿层, 工单不办结、放行门的位置没动。 **② 落了库也不会重渲。** `putView` 只存渲染好的 HTML —— 那一帧就是快照。 补迁移 0028 存下**模版名**,加 `views.repush(taskId)`:按当下数据、用同一张模版再渲一遍。 验证:人把九条全改成 A,**类徽跟着全变 A** ✓。 **③ 汇总列没人重算 —— 这一条没修。** 分布条与结论横幅那条去向读的是 `verdict` 的 `type_a_total / type_b_total / type_c_total`, 那是**活动写的汇总数**;人改的是 `record.type_tag` 逐行。逐行变了,汇总列不会自己跟着变 —— 于是重渲之后仍是「类徽全 A、分布条 5/3/1、去向 to-alternative」,**自相矛盾**。 要治得给台账一个「派生列」维度(声明 `type_a_total = count(record where type_tag='A')`, 基座按声明现算),与 `required` → `域_对象_missing` 是同一个路子: **能由数据算出来的,别让活动自报**。那是新能力,不是小修,故列在此。 在它落地之前,人在放行页改类标只改得动逐行那一层,汇总与去向仍以 AI 写的数为准。 顺带修了一处真缺口:`editableAtRelease` **原本不含 `type_tag`** —— 写回按这份名单摆格子,不在名单里的列压根不进载荷,所以人改了九格、一格也没落库。 三分类是 P-03 算的,但算得对不对该由人说了算:改一颗料的类就是推翻一次判定,那是他的职权。 ## 判据的第三态,与「看」跟「改」分家(2026-08-01) 用户两问:「①『选填缺口经过人』这个未达标为啥还在?②报告里两个可编辑表格默认锁定, 右上角安置一个按键,编辑按钮点击了才进入编辑态,取消或保存。」 ### ① 那条红叉不是「活没干完」,是 AI 办不到、交你定 —— 而卡上说不出这个区别 `acceptanceCriterionCheckSchema` 只有 `{label, level, pass}` —— **裁断与理由传不到卡面**。 于是「裁了但裁成不满足」与「压根没裁」长得一模一样:都是一个红叉「未达标」。 人看到一条永远不绿的项,既不知道 AI 有没有办过、也不知道它为什么办不到,只能凭感觉签。 而提交门早就允许「裁成不满足照样能提交」(裁量的本义就是可以裁出不满足), 更早的注释里也写着「裁成不满足的,连着理由一起进验收卡:那正是签字的人要看的东西」—— **那句话描述的行为从来没有实现过**,因为契约里没有那两个字段。 补齐:`settled`(裁过没有)+ `rationale`(凭什么)一路带到卡上; 裁量项在卡面变成**三态** —— 达标(绿)/ **AI 判为不满足 · 交你定**(琥珀,附理由)/ 未达标(红)。 两处判据表(对话区的卡、工单报告)统一走同一个组件,免得三态只在一处生效。 ### ② 看与改是两件事:表格默认锁着,按「编辑」才开 从前进待处理那一屏,整份件就是编辑态:A1 那份 54 个输入框一起亮着 —— 人还没决定要改什么,屏上已经是一张待填的表。**件首先是给人看的。** 每张可改的表各带一道闸(`[data-edit-for="域.对象"]`):锁着 → 「编辑」; 开了 → 「取消 / 保存」。取消还原成按下「编辑」那一刻的值,保存才把这张表交给外层缓冲 (真正写回草稿层仍在提交那一下)。一屏两张表各管各的。 **iframe 里依然一行脚本都没有** —— 按钮由父文档代理(`allow-same-origin`)。 **踩了一个跨 realm 的经典陷阱**:handler 里写 `event.target instanceof Element` —— `Element` 是**父文档**的构造器,而 target 来自 iframe 的 realm,原型链不同, `instanceof` 恒为 false。症状:点「编辑」毫无反应,而 class、CSS、选择器逐个查都对, 监听也确实挂上了、事件也确实收到了 —— 每次都在 handler 第一行 return。 **认能力不认血统**:有 `closest` 就当它是元素。 顺带把监听的挂载改成幂等的:`srcDoc` 换文档的时机抓不准,与其判断「挂过没有」, 不如每次有机会(effect / onLoad / measure)就挂一遍 —— 同一个 (type, listener) 重复 add 无副作用。 从前那个 `bound` 去重 ref 恰恰让「第一次挂在了空白文档上」变成了**永远没有第二次机会**。 ## Harness 的五条死胡同:门拦住了,却没给改正的手段(2026-08-01) **账**(每一轮都是真起单、真跑、真放行,`e2e-a1.mjs` 连跑): | 批次 | 版本 | released | 熔断 | 备注 | |---|---|---|---|---| | 基线 | v15 | 4/5 | 1 | 那一轮 13 次拒回、23 拍、234k token | | c | v16(+drop) | 4/6 | 2 | | | d | v17(+名单/Completed) | 6/8 | 1 | 另 1 轮是模型侧变慢(4 拍 605 秒),非缺陷 | | e | v18(+同源/指路) | 7/8 | 1 | | | f | v18(+JSON 名单) | 7/10 | 3 | **三次里两次是同一条**:`必填齐` | | **g** | **v19(+required)** | **10/10** | **0** | 全程 0 拒回,10-11 拍 / 99-112k token | 连跑 A1 摸底:**5 轮里 4 轮干净、1 轮熔断**;再跑 6 轮又熔断 2 轮。 挨个看失败形态,三条全是同一个病 —— **判据正确地拦住了一件办错的事,而模型没有一个动作能把它办对**。 门本身没错,错在门后没有路。 ### ① 写错一行没有退路 → 加 `drop` `XTSD04GLGEAQG-I` 是客户明说本轮跳过的那颗料,被写进了有效清单。 数报得全对(9 有效 / 1 跳过),行却有 10 条,判据 `record_rows = line_total` 当场拦住。 而模型**改不动**:它唯一能改的是 `line_total`(改成 10,`skipped_total` 又不对)。 13 次拒回、23 拍、234k token 才熔断 —— 而它从写错那一刻起就已经无解。 行动型里只有 `write` 没有删:草稿层本来就是这一单的过程事实,过程里写错一行是常事。 加 `drop`(`ledger.draftDrop` → `DraftStore.drop`):**只撤本单草稿层那一笔**, 正式版一个字都动不了,也不抛事件 —— 不是第四种写,放行门的位置没变。 撤不到如实回 `false`(主键写法对不上是常见原因):静默成功会让模型以为撤掉了, 下一拍判据照旧不过,它又去撤一次。 ### ② 拒回只给数字,不给「差在哪几行」→ 加主键名单 行数类判据拒回时只说「10 ≠ 9」。模型看得见对不上、看不见多的是哪一行, 于是只能猜:改数、重写、再提。三种失败形态全是这一类 —— 记录多一行、分类表 0 行、替代请求 1 张而 C 类 0 条。 基座数得出来的事实,基座给:`rowKeyList` 把每张表本单写下的行的**主键**摆进读数 (封顶 12 行,超出的如实说还有几行)。多的那个 PN 就在名单里对着 —— 一句话能办的事。 **不替模型判断谁多谁少**:名单是事实,判断留给它。 ### ③ 拒回的话把模型指上死路 → 按状态分开说 「`S3_confirm` 标了人工激活,要先 start 再干活 —— 当下是 **Completed**」。 模型照做去 start,而 `Completed → Active` 是非法迁移,又被拒;回头再撞第一堵墙。 两堵墙对着撞,连拒五拍熔断。 **拒回的话是模型下一拍的行动指南;指错方向比不给指引更糟。** 现在收掉/停用的项说「这一步已经办完收掉了,挂到当下这一步去(可选:…)」并列出可为的项; `start` 一个已收的项也改成无事发生(与「已在进行中」同款)——收掉的项不重新打开,但不必为按一下报错。 ### 顺带:两处收步判据比验收判据松 - `S5` 只看自报的 `untyped_total = 0` —— 数归零就收,而验收比的是 `type_table_rows = record_rows`(基座数的行)。数对了表没写全,**收了步却过不了验收**, 模型得回头补写一张已经收掉的表(c3 那一单正死于此)。 - `S6` 只看「写过 `alt_request_total` 这个数」—— 记完账就收,而单还一张没开。 两处都改成与验收判据同源。**收步比验收松,松出来的那一截就是返工。** S6 那条要额外挂一句 `untyped_total = 0` 当前置:两个 `_rows` 由基座铺 0, 而 `type_c_total` 要模型写过才有 —— 开局两边都是「没写过」,S-FEEL 里相等即真, 这一步会开局即收(包内自测那条「doneWhen 开局就为真」当场拦下)。 ### ④ 名单给了,主键却抄不对 → 摆成能原样粘的 JSON 装上 `drop` 之后再跑,模型**用了**它 —— 撤 `{"rfq_ref":"RFQ-MD14.1","pn":"XTSD…"}`, 连撤三次都撤不到。而名单里那一行是 `RFQ MD14.1/XTSD…`:**单号里是空格,不是横杠**。 名单用 `/` 拼串,模型得自己猜第一段是哪一列;列名它猜对了,值却按更常见的写法转写了一次。 **主键是认行的钥匙,一个字符都不能差 —— 那就别让它转写。** 名单改摆 JSON(`{"rfq_ref":"RFQ MD14.1","pn":"XTSD…"}`),拒回里明说「原样粘进 keys 就行」。 ### ⑤ 最后一条自报的机检判据 → 台账声明加 `required`,基座自己数 十轮里三轮死在同一条:**「必填齐(PN / 年用量)」[L1] `missing_key_total = 1`**,连拒五拍熔断。 这条是 L1 机检,读的却是**活动自己写的数**。人在 S3 卡上补了年用量、基座已经把值写回草稿层, 而那个汇总列还要模型自己重算 —— 它漏算一次,这条判据就永远绿不了,而数据本身完全合规。 (其中一轮它还想拿 `drop` 撤掉一行有效料来「解决」这条判据 —— 手段有了,可用错了地方, 因为门指的方向本来就是错的。) 这正是自己立过的那条纪律的漏网之鱼: > 机检判据只许读基座数得出来的数;读活动自报的数,它就不是门,是一句「改哪个数能过」的提示。 台账字段声明缺一维:**没有「必填」**,所以基座数不出来。补 `fields[].required`(可选, 不建 NOT NULL —— 草稿层的本分就是过程中允许缺),基座据此出两个读数: `域_对象_missing`(缺必填的行数)与 `_missing_keys`(缺在哪几行、缺哪一列)。 判据改读前者,S4 的入场前提也一并改。**模型改不动这个数,也不必改 —— 它只要把那一格补上。** ### 还删了一个孤儿 `intake-gaps` 模版:7 处 `view:` 引用没有一个指向它,声明着、渲染着、随包走, 而没有一步会推它 —— 缺口那件事早已由 `intake-overview` 的红胶囊与题头计数说清楚了。 ## 对话区太密集:把「一拍一句」改成「一轮一句」(2026-08-01) 用户贴了一段对话区,五条气泡里四条长这样: > 「我这就把审定结论落账,收掉这一步,往下走查历史价。」 > 「我去查这三颗料的历史价,拿到判据就写进分类表。」 > 「…我这就把标写进分类表和受理记录,收掉这一步。」 > 「…我这就把两张请求单和去向旗写好,收掉这一步。」 原话:「太密集了」。**那些不是给人的话,是工序播报** —— 写哪张表、收哪一步, 人既不用知道也帮不上忙;那一段本该由「AI 工作中」的办事条覆盖。 用户定的形状:**设定目标 →(分析现状 → 规划 → 执行)× N 轮 → 输出一个聊天气泡**, 气泡可以是仅结论,也可以是**结论 + 功能卡(录入 / 执行 / 验收)两部分**。 三处改到位: 1. **哪一拍才出声**(`turn-runner` 的 `handsOff`):一轮的收口 = 控制权要交出去 —— 发卡问人(ask)、派工单(human)、摆岔路(decide)、立监听等外部(listen)、 提交放行(submit)。别的拍照旧留痕进工作日志(`internal`),只是不进对话区。 按「打算做什么」判而不是「做成了什么」:交代必须赶在动手之前说出口,人要先看见 「我要去办什么」再看见卡。代价是模型说要发卡却被拒的那一拍会白说一句 —— 那种拍本来也该让人看见。 2. **模型知道这条规矩**(`agent-runner` 的提示词与 say 字段说明): 否则它照旧每拍写一句播报,只是被我们默默丢掉 —— 白烧 token,还写不好那一句总结。 现在明写:say 只在交出控制权那一拍才摆到人面前,写这一轮的结论与为什么现在需要他。 3. **结论与卡并成一个气泡**(`chat-projection`):它们本是同一轮的两半。 从前各占一条,每轮就是「一条话 + 一条只有标题加个按钮的卡」,而卡的标题多半是 那句话的缩写。顺带把收尾那三条引擎回声(裁决:pass / 落地收敛,发射输出口 / 终态) 收成一条 —— 裁决是人自己刚按下的,发了哪几个口在工作日志里。 **并卡的判断一度写错**:挡住并卡的条件写成 `tag !== 'conclusion'`,而交代本身常常 就是结论口吻(「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当 —— 于是第一轮并对了、收口那一轮没并,同一屏两种样子。改成按**来路**认(只有 terminal 那一条不许认领卡),不按语气签认。 账:同一条 11 拍的 A1,对话区从 **9 条降到 5 条**(系统开场 1 + AI 结论带卡 2 + 办结回执 1 + 收尾 1);被压下的 8 条交代一条不少地留在工作日志里。 ## 放行卡上那两条不该在的判据(2026-08-01) 用户看着放行卡的判据表:「最后的验收 L4 选题口不应该是收口验收的指标; L5 当前卡片就是放行卡,也不该显示在这里 —— **这里是判据**。」 两条都对,而且是两种不同的错: **① L4「去向判断合乎业务常理」—— 判据得能指向一个可做的动作。** 去向早已不是判断了:三个出口各写了 `when`,放行那一刻基座按序求值、首个命中即出。 再挂一条 L4 让模型裁「这个去向合不合理」,是让它给一个**自己没做的决定**盖章 —— 裁「满足」是废话,裁「不满足」也改不动任何东西(能改的是三类条数,而那已经由 route-alt / route-rfq 两条机检看着了)。这种判据只是一道要人按一下的手续。已删。 **② L5「BD 放行」—— 判据是签字前要核的东西,不是签字这件事自己。** 那张卡本身就是这道关。把它列进判据表,等于在放行卡上摆一条恒为「未达标」的项: 人一路核到底,最后一行永远是红的,而消掉它的动作就是按下同一张卡上的「确认通过」; 「n/m 达标」那个数也因此永远差一条,看着像还有活没干完。 **声明留着,呈现去掉** —— L5 说的是「这份交付物必须由人放行」,由 `T_release` 的 人工激活 + 终局唯放行落实,提交门与预检本来就把它排除在外;少的只是卡面这一行。 滤在基座组卡处(`engines.ts`),六个活动一起生效,不是 A1 单独打的补丁。 验收:端到端 11 拍 released、被拒 0;卡面九条(L1×3 / L2×4 / L3×1 / L4×1), 浏览器里数过 —— 没有 L5,也没有那条去向裁量。 ## 一份报告里嵌着另一份报告(2026-08-01) 用户:「现在你用你自己的报告格式**嵌套了一层**我要的报告格式」。 工单报告组件(`WorkOrderReport`)自带一整套外壳 —— 题头、编号/所属/编制/派发带、 依据指标带、章节头(`Part`)、判据表、落款动作 —— 然后把活动推的那一屏 `` 塞进自己身体里的一个「数据与分析」章节。两套版面语言叠在一起, 外面那套还先声夺人。搬 PRD 目标件那几轮全在改里面那一层,而人一眼看见的是外面那层。 **分家口径(用户定)**: | 区 | 放什么 | |---|---| | 工作区 · 待处理 | **只放那份件**,可就地改 | | 工作区 · 工作成果 | **只放那份件**,不加编辑态 | | 对话区的卡 | 作业指导书 · 依据 · 判据 · 填报表单 · **提交与裁决** | 就地改的做法:可改的格子挂 `data-col="台账列名"`、行容器挂 `data-edit="域.对象"`, 渲染时每格多渲一个 `` 孪生件,**只读那份与输入那份由样式表二选一** (根节点带不带 `editing`)。容器是 `sandbox="allow-same-origin"` 的无脚本 iframe: 外层读得到也改得动里面的 DOM,开编辑就是加个 class,收值就是把 input 读出来 —— **iframe 里始终一行脚本都没有**。为什么两份都渲:同一份件既在待处理给人改、 又在工作成果里定稿归档,**两处是同一份**;渲两套等于存两份,存两份就会分叉。 ### 两个只在「真人点」那条路上现形的洞 跑测脚本一直好好的,浏览器里一走就露: 1. **监听挂在 `about:blank` 上**。`srcDoc` 是异步换文档的,渲染那一刻 `contentDocument` 还是空白页 —— 监听挂上去,srcdoc 一到就随旧文档一起没了。 症状:人在件上改了两格,提交时载荷里一个字都没有(和从前「表格改动只活在组件局部 state」是同一种病的第二种得法)。修:回调放 ref、在 `onLoad` 里再挂一次、 记住挂过哪一份文档。 2. **算出来的去向没交回去**。`routedPort`(基座按判据算出的那一口)在卡上只作展示, `selectedPorts` 恒空;人按「确认通过」→ 放行落地判「选择组未选出口」→ 整单 `blocked`。 **台账已经合并进正式表了,单子却死在最后一步。** 试跑脚本自己带着 `port`,所以这条一直被盖着。修:有 `routedPort` 就把它当作选中的那一口 —— 人按下的「确认」,确认的正是基座算出来的那一口。 验收:浏览器里走完两道人环 —— 件上改两格(DAG 年用量 0→30000、UMW 制造商补上)→ 提交 → 落草稿层 → 放行 → **released**,九行数据落进正式表;工作成果那一屏零输入框、零外壳。 ## 阶段2 整个没发生:把「人的门」写成了「缺东西时的分支」(2026-08-01) 用户:「为啥步骤发生了变化?阶段2没有了?**我都没确认需求 它就让我验收了**」。 查下来不是步骤被删了,是这道门从一开始就**不是一道门**。「受理需求经过人」写在归一那一步的 收步条件里,是半句话: ``` doneWhen: line_total > 0 and missing_key_total = 0 and (missing_minor_total = 0 or answered_total > 0) └────────── 只在缺东西的时候才叫人 ──────────┘ ``` 于是一封字段齐全的询价进来,这半句在**拆完那一刻**就为真 —— 引擎在下一拍开头把归一自己收了, 一张卡都没发,人下一次被叫到已经是终局的验收放行。缺字段那条路上看着「正常」, 所以前面几十轮跑测都没露出来:样例 BOM 恰好总缺那么两格。 **教训:判据是「这件事办完了没有」,不是「要不要办」。** 要不要经过人是模型结构的事;写进判据,就成了「不缺就不必经过人」。 而 PRD 的目标件早就把答案写在页脚了 —— `A1-询价受理需求.html` 的落款是 「AI 拟稿,你审定;可就地修改后再确认」,按钮是【修改】【确认受理记录】: **它从来就是一张人签的页**,不是缺东西时才弹的追问。 处置:`S3_confirm` 独立成项,与放行同款 —— **人工激活 + 不给 doneWhen** (触达世界的项一律不给 doneWhen,给了就是给引擎一把替人签字的钥匙); S4 的入场前提改读 `confirmed_total` —— 一个只有「人把卡交回来」才产生的数。 规范 §7.1 立为纪律 3.5。 ### 同一次跑测挖出的零条死锁:**没写过 ≠ 写了 0 行** 拿一封「全齐、无 C 类」的询价复跑,卡在了另一处:验收判据 `quote_intake_alt_request_rows = type_c_total`(没有 C 类 → 0 = 0,本该成立) 连拒五拍熔断。原因是行数读数**只数写过的表** —— 一行没写,那个键整个不存在, 而 S-FEEL 里未绑定的名字参与比较求值为假。数据完全合规,门却永远打不开。 已修:按 `declarations.ledger` 把每张**声明过**的表先铺成 0,再让实际行数盖上去。 声明面就是授权面,不在声明里的表本来也数不着。 验收:常路 11 拍 released / 被拒 0;全齐无 C 类那条 10 拍 released、去向 `to-sourcing` (那 2 次被拒是模型真忘了给 B 类开询价请求,门拦住它并被它改对了 —— 门在做它该做的事)。 ## 版面重做:块齐了不等于长得像(2026-08-01) 用户第三次指出「HTML 报告跟我给你的两份完全不一样」。我之前两轮的「25 块到位」 是**结构断言**(某个 class 在不在),而结构断言对**版面一点约束都没有** —— 块可以全在,而版面是另一套语言。截图并排一比,差的是这些: | | PRD 目标件 | 我的(重做前) | |---|---|---| | 题头下 | 直接进内容 | 多一段灰色 summary | | 元信息 | 没有 | 多一条「单号/客户码/主题」带 | | 章节头 | 小号加粗字 + 细线 + 右端 mono 小签 | 彩色方块编号 + 大标题 | | 指标带 | 没有 | 插了 5-7 格 | | 字号 | 固定 11/12/18px | clamp 响应式,整体偏大偏松 | **教训:视觉目标件不能用 DOM 断言验收,只能看。** 这一轮把 PRD 的 CSS 数值逐条抄过来 (题头 16/20/13、章节头 11px/800/.12em、正文 12px、表头 10.5px、落款 13/20/15), `section()` 去掉编号参数(六个活动一并迁移),删掉 summary / meta / statsBand —— 数字改进**章节右签**(目标件就是这么摆的:数挨着它说明的那一段站着)。 顺带修掉截图里才看得见的两处: - **刻度条格子里冒出数字**(`meter` 同时带了 `data-slot`,九格排着「0 0 0 … 1」)—— 只保留 `data-class-of` 上色,格子不填字; - **空成交轨像坏掉的进度条** —— 没数据就别画那一格,把「为什么没有」写在卡脚。 画一个空壳比不画更像故障。 ### 后续:抄数值仍然不够,最后是**整份搬过来**(同日) 上面这一轮把 CSS 数值逐条抄了过来,用户看完仍然说不一样 ——「你直接把两份报告 copy 过来替换掉你的 UI,重新适配功能层」。于是不再用 `@sdk` 那套外壳复述: `PRD_CSS` / `PRD_SPRITE` 是**机械抽取**的整段样式表与图标精灵(Python 抽,不手敲, 只把反引号换成 `'` 好活过 TS 模板串),标记逐块搬进 `report.ts`,只换掉四样东西 (写死的行→`data-rows`、JS 算的数→汇总列、JS 插的胶囊→`:empty::after`、 JS 拼的 class 与 `style="flex:N"`→`data-class-of`)。规范 §10.3.1 已立此法。 搬的过程中挖出**渲染器一个静默失色的洞**:`data-class-of` 从前只在**带 `data-slot` 的元素**上生效(两者写在同一条正则里)。于是分布条那种纯色块 (``,格里不写字、只按值上色)整条不着色 —— 属性还留在成品上,一个 class 都没长出来,屏上是一排灰格子。 **而灰格子看着像「还没算出来」,不像「没上色」**,查了两遍才认出来; 老用例每一条都同时写了 `data-slot`,所以谁都没拦住它。 现在拆成两遍:取值填字是一件事,按值上色是另一件事,后者不以前者为条件(补了 4 条用例)。 同批做实的三件: - **去向注入视图**(`case:routeKey/routePort/routeTo/routeWhy`)—— 判定表上命中哪一行 由基座按契约的出口谓词求值后注入,与放行时发口是**同一次判定**;报告不自己算、也不抄判据。 - **分布条按条数分宽** —— 原型是 JS 写 `style="flex:N"`;改由 `data-class-of` 把 **条数本身**投影成 `v-5`,样式表备一把 `v-1…v-20` 的梯子取宽。模版仍然不许算。 - **`contact_name` 落进 `quote_intake.verdict`** —— 联系人卡上那个人。它本在 `customer.contact`(主数据)里,可老客户不重写主数据、本单草稿里根本没有那一行; 顶层裸键又与 `customer.profile.name` 撞名(后写的赢)。 **一张写着公司名的联系人卡比空着更坏**,所以跟 `customer_name` 同一笔写下。 ## 算出来的口只在前端算数(2026-08-02,A2 e2e 逼出来的,已销) A2 第一轮端到端:七拍零被拒、去向已算出 `to-sourcing`、验收 pass —— 死在最后一步:「放行未确认分支结论:选择组 alt-route 未选出口」,台账已合并、单落 `blocked`。 查下来是 2026-08-01 那次「routedPort 那一路 selectedPorts 恒空」的修法**只修了一半**: 补的是**前端**(chat-bubbles 回传时把算出的口塞进 `selectedPorts`)与 **服务端返回值**(human-tool-service 的 outcome)。而放行落地读的是第三样东西 —— **人事件载荷**(`publishHumanEvent({ payload: { ...reply } })`,原始回传), 那一份谁都没动。于是浏览器活着(它自带口)、返回值好看(没人读它), 换个客户端(脚本、API 调用)就死在落地,而服务端注释还写着 「人回传里带不带都不作数」—— **注释描述的行为在引擎读的那条路上从来不成立**。 修法:`replyWorkOrder` 在发事件**之前**算好有效口(routedPort 优先、算不出才认人选), 事件载荷与返回值用同一份。**服务端说了算的事,不靠每个客户端自觉** —— 前端那段照旧发,发什么都会被有效口盖掉。 顺带两条实测账(代答脚本要知道的): - 执行卡 `fields` 缺席时基座兜底加一枚**必填**「结果」回执字段(`fillCard`), 交卷载荷要带 `values.result`,否则「必填字段未回填:结果」; - A3 的提交闸第一次真拦人:单飞归一出两颗料、脚本只填一颗,交卷被点名 「还有 1 条必填没填 —— SRC-…/XC6206P332MR 缺底价」—— 与件上红带、放行门 L1 同一个数同一套算法,拦的位置和话术都对。 ## 2026-08-02 · A2 判稳该 decisionTask 化 —— 说明书拦不住「不查工具直接编」 单飞案例连续两轮走岔:S3 判稳一拍,模型**一次都没调** `crm.history-price-read`, 直接给替代料写「历史 43 天·行情稳 → 转 A」(数是编的),去向从 to-sourcing 岔成 to-pricing。说明书已写到头(「判稳的行情只有一条来路 —— 这一拍亲自查 alt_pn」「不许拿上游的数顶替」都在),流内两案例被这些话治住了,单飞治不住: 没有上游数可抄时它就自己编,措辞层面无解。 **该走的路**:判稳是死规则(≤90 天且稳 → A,否则 → B)+ 机器可得的入参 (工具出参)—— 「能算的不问 LLM」,这一步本该像 A1 的 S5 那样交给引擎: ① 判稳规则进 DMN 表(`ALT_DMN` 现在是空的);② S3 改 decisionTask 或 第一方工具出参直接带 `type_tag`/`basis`(模型只照抄,抄错有对号检查); ③ 收步判据加「凭据齐」(原 C 行 `history_days` 非空 —— 需要 `requiredWhen` 或「非空行数」读数承载)。 **现状兜底没断**:验收页 `editableAtRelease` 含 `type_tag`,人改标去向即变 (routedPort 读 tally 现算)—— AI 第一稿错,人签字前看得见改得动。 ## 2026-08-02 · 入场哨兵读自报列 —— A2/A3 也有同一条缝 A1 踩实的病(用户实测单 584655b6,humanCalls 4/4 耗尽 exhausted):`s_parsed` 读 模型自报的 `line_total`,模型把 record 三行在 S1 拍写完、没记这个数,S2 空手收步 —— 门永不开,引擎报「依赖断点」,提示词「断点请调人」把模型推去发**补全卡**绕路, 两张多余的卡烧光人环预算。已修:s_parsed 改读基座数的 `quote_intake_record_rows` (A1@46.0.0),该形态复跑 released、被拒 0、断点 0、补全卡 0。 **同一条缝还在 A2/A3 的入场哨兵里**(尚未咬人,记账待修): - A2 `s_normalized` 读自报 `alt_line_total`、`s_filled` 读自报 `filled_total`; - A3 对应哨兵同款。 S1 的 guide 都教了「第一件事写这个数」,所以节奏对时不踩;但 A1 证明了 节奏是模型抖出来的,不是承诺。照 A1 的方子换成 `_rows` 类基座读数即可 (s_filled 这种「人环收回」型要另想承载 —— filled_total 是「答复处理过」的记账, 基座数不出来,或可读 DONE 计数 `_sel_stage_done`)。 ## 2026-08-02 · 补全卡退役的扫尾 行为已全局退役(归一层 ask→零输入执行工单;引擎直发拒;提示词/规范/三活动清理)。 留着的旧物,后续轮清: - 契约类型 `ChatCard` 的 elicitation 分支与 `checkChatCard`/`checkChatCardReply` 的对应校验(历史卡回放还要认,删除走三步弃用); - 前端补全卡渲染(历史对话里旧卡要能显示); - `process.start` 起单缺 required 顶层键时发补全卡的老路 —— 三活动 required 全空后实际不触发,彻底移除时一并清。 ## 2026-08-02 · A2 已落的两条全局口径,A1/A3 待跟进 - **一张模版贯穿到底**(用户:「待处理的 HTML 报告完成后直接转变为静态的工作成果」): A2 已并成一张(alt-roster 退役);A1(四张按局面换)与 A3(worksheet/roster 两张) 仍是旧制,按 A2 的样子归一。 - **空占位三级统一**(必填红/建议填橘/选填灰):A2 已上;~~A1~~(2026-08-03 已上: 品类/制造商/目标价按声明的 expect 标橘「建议」,quote-intake@48.0.0,用户点的是目标价)/ A3 的占位词还是旧两级(待填红/选填灰),跟着声明的 required/expect 换新三级。 ## 2026-08-03 · A4 补齐时发现、没动的两处共享面 - **放行门拒回仍会指向两扇打不开的门**:`_rows > 0` 但某个 `tally` 列一格没填时, 拒回只给「改数 / 改行」两条路(而两者都对),模型原样重提到熔断。 基座那条「`_rows = 0` → 直说那一步还没办」只覆盖了「一行都没写」, 没覆盖「行写了但分类/处置列没填」。**拒回的话是下一拍的行动指南**(已立纪律), 该按「哪一列还空着」点名。A4 靠独立成项 `S3_gate` 绕过去了,病根还在。 - **单飞造号会借用正式表里别单的单号**(第三次现形:A2 借走 MD14.1、A4 借走 RFQ-2026-0777)。 A2/A3/A4 三处都只靠说明书措辞防守(「只从本单来件取材」「必须 PRC- 开头」)。 **措辞治不了的结构治**(设计律第六条):造号该由基座给(同组主键 + 序号, 与 `FieldDeclaration.auto` 同一条路),不该让模型自己编。 ~~**决策卡没有幂等兜底**~~(2026-08-03 已修:`resendVerdict` 抽成纯函数, 执行工单与决策卡同一把尺,头两次无事发生、第三次起算被拒,6 测钉死)。 ## 2026-08-03 · A6 补齐时发现、没动的三处基座欠账 - **`listen` 动作在引擎里恒挂起**:`turn-runner` 对 `listen` 返回 `awaiting`,要外部 data/time 事件才唤得醒,而 desk 侧**没有任何路由能把 data 事件投给指定任务** —— 「等客户回音」这类等外部的活,活动只能落在执行工单上(人收到信、把回音填进件里), 时限那一侧靠 `declarations.listeners` 静态监听。A6 已按这条口径建模并有测钉死 (剧本不许教 `listen`)。要让 `listen` 真能用,得补一条「把外部事件投给某任务」的入口。 - **依赖断点那几拍,全局提示词只说「请调人或立监听」**,而当下可为为空时模型手上 只剩计划项的**中文名** —— 它会拿中文名当 `itemId`(实测连拒两拍)。活动侧只能靠 说明书兜(A6 把 itemId / ask / 选项值写死进说明书)。**治法在基座**:断点提示里 把可用项的英文 id 一并摆出来。 - **`mail.send` 至今没有活动敢用**:写类工具放行落地要过「入参 schema + `isInvocable` + 目标预校验」三道,而治理台白名单授权口仍挂在 §15⑤。A6 `tools: []`, 报价函靠执行单上那句话交给人发 —— 现状如实,但「谁真发了出去」系统并不知道。 ## ~~2026-08-03 · A2 判稳偶发不查工具~~(当日已结构治) **症状**:A2 单飞那条案例约十次一次 —— 替代料没查行情就判「稳」转 A, 去向从 `to-sourcing` 走成 `to-pricing`(实测 2026-08-03 全量回归一次)。 说明书已经写到头(「判稳的行情只有这一条来路 —— 这一拍亲自查 alt_pn」 「不许传原 PN」「不许拿上游带进来的数顶替」),**措辞治不了的结构治**(设计律第六条)。 **可行治法**(基座已具备一半):工具出参本来就入痕 —— `turn-runner` 每次调完写一条 `kind: 'result'` / 标题 `${tool} 返回` / detail 带 output。 所以「这一单到底调没调 `crm.history-price-read`」是**基座数得出来的事实**, 不必让活动自报。补一类判据谓词(如 `tool_called_crm_history_price_read > 0`, 与 `域_对象_rows` 同族的读数),A2 就能立一条 L2: **「转 A 的依据来自本单亲查的行情」** —— 机检只读基座数,这条纪律第一次真正覆盖到工具。 **当下兜底**(已在):验收页 `type_tag` 可改、去向跟着变;人在放行那一屏能纠。 **~~已落地~~**(2026-08-03 当日):基座新增读数 `tool_<工具名去符号>_calls` (`toolCallReadings`,按留痕数成功返回的次数,声明过的工具一律先铺 0; 调失败不算查过 —— 调不通等于没拿到行情)。静态审与包内自测的「谓词绑得上」 名单同批扩到这一类。A2@4.14.0 立 L2: `quote_alternative_list_type_tag_a = 0 or tool_crm_history_price_read_calls > 0` ——**写成析取**:一颗都没转 A 就无需查行情(全 B 那条路合法), 有 A 才必须有过这一次亲查。**「机检只读基座数」这条纪律第一次覆盖到工具**。 ## 2026-08-03 · A2 常路案例会**跳过派给 FAE 的那一步**(未修,已定位) **症状**:`a2` 案例①(两颗 C · 替代无历史)连续三轮 6 拍收口,**一张执行工单都没发**—— 轨迹里模型自称「2 颗替代料都查过行情、都转成 B 类」:替代料是它自己填的, FAE 从头到尾没被叫过,单子照样 released(去向还对)。案例②③ 正常派单(7 拍)。 **病根**:S2「派选型单」的 `doneWhen` 认的是**件上那几格填没填** (`alt_pn/alt_brand/alt_grade` 由 requiredWhen 钉在 orig_type=C 行)—— 而这几格模型自己也写得进去。它一写,收步条件当场成立,引擎就把这一步收了, **人环整个消失**。这正是「凡触达世界的项一律不给 `doneWhen`」那条纪律的反面教材: 「把清单交给 FAE 选型」触达的是人,它的完成与否不该由数据形状推出来。 同时它也踩了 `no-fake-alt` 禁令(没把握的替代不该编一个顶上)。 **治法**(与 T_release 同款,未施工):S2 改 **人工激活 + 不给 doneWhen** —— 派单那一下由模型显式做,收步由人交回触发;并在剧本里写死「替代料三列一格都不许自己填」。 **验证口径**:`a2` 三案例都必须出现「件上保存 / 交卷执行卡」两行,6 拍收口即判失败。 **排除项**:不是 2026-08-03 那条工具留痕判据引起的 —— 撤掉判据(@4.15.0)后原样复现。 ## 2026-08-03 · 件上保存偶发不落那一格(间歇,两处独立撞见) **症状**:`a1` 案例把 `DAG2400L321504XZB01-R0` 的年用量存进件上(`POST /tasks/:id/table`), 返回 200,而紧接着的提交闸仍报「缺年用量 EAU」,连拦三次判失败; 同一条案例前后几轮都正常。A6 那一轮独立撞见**同一行同一列**的同款症状。 **已排除**:不是提交闸失效(拿单直接空手交卷实测吃 400,且点名缺哪一行哪一格); 不是那一轮的说明书改动(A1@49.x 只改措辞,保存走的是基座那条链)。 **下一步怎么查**:保存那一下服务端按「收件自己说的话」(`data-edit` 地址 + `data-col` 列名) 落草稿层,主键靠行内 `data-col` 的主键格给出 —— 怀疑点在**主键补齐**那一段 (A2 走查逮过一次「主键格没挂 data-col → 服务端按全表一致列补键 → 两行对到同一行」)。 复现要碰运气,建议下次带着「保存请求体 + 落库后草稿行」两份留痕对着看。 ## 2026-08-23 · E04 / I01 的件没有窄屏改版,只能横着滑 **现状**:两包的 `.jc-tbl-wrap` 已改成 `overflow-x: auto`(用户报「表格显示不全、不能左右 滑动」),宽表在 586px 的工作区里滑得动。**但这只是没被裁掉,不是排得好** —— quote-* 六包各有一段 `@media (max-width: 960px)`:表头收起、**一行变一张卡**(`display:block` + `td::before` 摆列名),在工作区那个宽度里根本不需要横滑。E04/I01 **一条媒体查询都没有**。 **为什么先只做滑动**:用户这一轮要的就是「能左右滑」,且两份件的结构是照 `e04-html` / `i01-html` 目标件 1:1 搬来的(宽表版式)。改成卡片流是版式决策,得先有目标件或用户点头。 **下次做的话**:照 quote-intake §表段那 20 行抄一份(注意 `@media` 要写在基础规则**之后**, A2 踩过「媒体查询被基础规则盖掉」),两包各自的编辑态孪生件与红带都要跟着验一遍。 ## ~~2026-08-23 · 试跑单放行之后,资料文件点不开~~(当日销账) **现象**:I01 的资料文件在办中点得开(审核那一屏逐份打开、下载都正常), **试跑单放行之后**再从工作成果里点同一份,404。 **根因**(不是 bug,是两条既有规矩撞在一起):资料这一行落在 `content.asset` 上, 而它是**主数据**;试跑单放行时主数据只留在活动缓存、不落正式表 (ledger 的 `simulation` 那段:免得试跑往品牌内容库里塞真客户/真资产)。 文件是台账那一行的投影 —— 行不在,文件自然造不出来。**正式单不受此限** (放行合并进正式表,归档件上照旧点得开)。 **已做**:404 的话分两种说法(在办中「可能已被撤掉」/ 已收工「若是试跑单,主数据不落正式表」), 不拿一句话把人支去翻一行根本不会在的东西。 **已治**(当日,用户「检查当前产出的附件是否存档」逼出来的):走的是上面那条 ①—— 放行落地**之前**(人的修订刚落回草稿层)把每一份资料现造成字节,存进 `sys.task_asset_file`(迁移 0029),一单一行一份、重复放行覆盖。 取文件那条路**存档优先**:归档件上点开的必须是他签字那一刻的那一份, 而不是台账后来被下一单改成的样子。工作成果那一屏另起一摞「产出的资料」, 点开即预览、可下载原件。冻结这件事就此定了:**归档的资料与台账脱钩** —— 台账后来改了,归档件不改口。 ## 2026-08-24 · 两个后端连同一个库:局面被并发覆盖(已修一半) **症状**(柴浩实测,E04 一单五拍熔断):`finish S2` 被拒「从没给它发过卡」, 而重发那张卡又被拒「这件事刚问过,人也答过了」—— 两堵墙对着撞,推不动。 **根因**:本机与局域网另一台机器上各跑着一个后端,连的是同一个库。两边的推进器各自 2 秒扫一次「running 的单」,**同时推同一张**;而互斥只在进程内(`task-lock.ts` 开头 自己写着「MVP 单进程…多进程部署时再叠一层」)。两拍并发跑在同一份 `sys.loop_state` 上, **后写的整份覆盖先写的** —— 「给 S2 发过卡」这个事实(`worldTouched`)被抹掉, S1 被收了两遍(两条 rationale 只差一个标点,是两次独立采样)。 **已修**:`workers/task-claim.ts` —— 库里一列 `advancing_until` 当**跨进程租约**, 推之前一条 SQL 判+占(`where advancing_until is null or advancing_until < now() … returning`), 推完就还,带 5 分钟过期(进程崩了不锁死)。推进器与派发器两条推进路径都接了。 实测:两条真连接对着真库抢 20 轮,每轮恰好一个赢;租约过期后另一方接得上。 **还欠**:`loop_state` 本身**没有乐观并发**(没有版本号 / CAS)。租约挡住了「两边同时推」 这条主路,但只要有第三条路绕过租约去写局面,后写的仍会整份覆盖先写的,而且**悄无声息**。 该补的是:`save` 带上读到的版本号,`update … where version = ?`,不命中就当场炸 (「这一拍的局面已被别处改过」),而不是默默覆盖。等真要多机部署时补齐。 ## 2026-08-24 · 覆盖闭合是条数对账,不是集合对账 「每家候选客户都进了经营计划」(E04@2.1.1,S3 收步 / S4 哨兵 / 放行 L2 三处同一句) 数的是 `plan_distinct_customer_code = candidate_rows` —— **两个数相等,集合未必相等**: 计划里冒出一家非候选客户、同时又漏掉一家真候选,条数照样对得上。这一半靠 S3 guide 的 「抄名单逐条核对,一家都不能多」看着(实测这半边模型守得住;漏家那半边守不住, 所以才立的机检)。真要机检集合相等,得让基座出「A 表某列的值集 ⊆ B 表某列的值集」 这类读数 —— 等第二次真踩到再立,别为想象中的路修桥。 ## 2026-08-25 · CI 接上了主干,PR 之前仍是空门 **已接**(`.gitea/workflows/ci.yml`,host 模式 runner、label `ubuntu-latest`):push 到 `mvp-foundation` 时跑 五道门禁 → 生成物漂移检查 → 建一次性库并从空库跑完整条迁移链 → 单元与集成测试 → 三份生产构建(api / web / 活动包)。取代码是纯 git,**一个第三方 action 都不依赖**;包与镜像源走 npmmirror。实测本地全程约 30 秒(静态门禁 14s、迁移 1s、 1705 条测试 7s、三份构建 6s)。 **还欠三处**: ① **PR 合并之前不跑**(用户当场定的口径:「只允许主支推送或合并进主支才触发」)—— 坏代码得先进了主干才发现,`docs/编码规范.md` §12.7 写的「合入前五门禁全绿」这道要求 在机器那一侧仍然没人看着。要改成合并前把关,`on:` 下补一段 `pull_request: branches: [mvp-foundation]` 即可,别的一行不用动。 ② **活动 e2e 不进 CI**(`tools/e2e-a2.mjs` 那八套):要真模型密钥、单案例 20 分钟封顶、 一跑几十万 token。也就是说「E04 / I01 跑不跑得通」这件事**没有任何自动化在看着**, 仍然靠人手工跑。要接的话它该是另一条手动/定时的 workflow,不是 PR 门禁。 ③ **构建产物不归档**:上传要 `actions/upload-artifact`,又是一个得出网拉的第三方 action; 本轮口径是能不用就不用。要归档时再加。 **另记一处失败开口**:`tools/api-report.mjs` 的 git 比对一旦抛错(换 uid 后的 dubious ownership 之类),`catch` 里印一句「非 git 仓库,跳过比对」然后 **exit 0** —— 契约门禁当场变成空门。CI 侧已由「生成物无漂移」那一步(跑完自己再 `git status` 一遍) 堵住,**本地 `pnpm gates` 仍然是开口的**:谁在一个 git 出问题的环境里跑,它会安静地放行。 **顺带一条实测账(接 CI 时踩的)**:`createdb` 出来的空库**直接 `pnpm db:migrate` 迁不动** —— `db/migrations/0026_object_meta.sql` 往 `ledger.__object_meta` 建表,而迁移链只会替自己 建 `sys`(drizzle 的 `migrationsSchema`)。四个 schema 一直是 docker-compose 把 `db/init/` 挂进 `/docker-entrypoint-initdb.d`、在**首次起卷**时灌的 —— 换任何一条不经 compose 的路子建库,都得自己先灌一遍。`tools/ci.mjs db-create` 已经这么做(灌的是 `db/init/` 那份文件本身,不另抄一份)。 ## 2026-08-25 · CD 接上了,五处「信号全绿而事情没办成」各配了一道机检 **已接**(`.gitea/workflows/ci.yml` 后半段 14 步 + `tools/cd.mjs` 八个子命令 + `docs/部署.md`): 门禁全绿 → 前置断言 → 备份 → 确保库在 → 发布到 `releases/` → 迁移 → 迁移到位断言 → 判首次/升级 → 播种或装活动包 → 回查 → 发前端 → pm2 重启 → 探活 → 收尾。 部署在 runner 本机(host 模式),路径类配置从 Gitea Variables 读,机密从 Secrets 读。 接的过程中逐条对上了五个**会静默成功**的机制,每个都配了一道机检: | 机制 | 它会怎么骗人 | 机检 | |---|---|---| | `main.ts` 的入口守卫是 `import.meta.url === \`file://${process.argv[1]}\`` 字符串比较 | 经软链启动两边不等 → `boot()` 永不执行 → **进程零输出 exit 0**(实测过) | pm2 一律用 `releases/` 真实路径起;探活兜底 | | `install-activities` 所有失败路径都是「打印一行然后 continue」 | 一个包都没装成也 exit 0 | 装完回查 `sys.activity_package`(`activities-verify`) | | `savePackage` 是 `onConflictDoNothing`,主键 `(activity_id, version)` | 版本没升就什么都不更新,日志照说「已装」 | 版本没变但产物文本变了 → **当场红**(`activities-plan`) | | drizzle 只取 `max(created_at)` 当水位线,`hash` 列只写不读 | 新迁移的 `when` ≤ 水位线会被永久静默跳过,而 migrate 照样打印「完成」 | 已应用条数 == journal 条数(`migrate-verify`) | | `/api/health` 用 `c.json()` 没传 status | 库挂了照样返 200,只是 body 里 `db:"down"` | 探活验 body(`health`) | **还欠**: ① **无版本可见性**。`/api/health` 的 `version` 是写死的 `CONTRACTS_VERSION = '0.1.0-m1'`, 从 M1 至今没动过;全仓没有任何地方注入 git sha(`tsup.config.ts` 无 `define`,`vite.config.ts` 也没有)。 部署完只能靠 `releases//RELEASE` 文件与 `sys.activity_package.installed_at` 间接确认「新版真上去了」。 要补的话最小侵入是 tsup 加一个 `define: { __GIT_SHA__ }` 再塞进 health 的出参 —— 但那是契约变更(`/api/health` 还是**免鉴权**的,暴露 commit sha 要先想清楚)。 ② **`main.ts` 没有 graceful shutdown**。`serve()` 的返回值没被接住,HTTP server 从不 `close()`; SIGTERM 直接 `process.exit(0)` —— 在途请求与**所有 SSE 长连接**硬切。 换版本时必然掉一次连接(前端 EventSource 会自己重连,所以影响小,但不是零)。 ③ **换版本有几秒空窗**。pm2 是 `delete` + `start`(脚本路径每版都变,`restart` 会留在旧路径), 中间那几秒 502。要做到零停机得上两个端口 + nginx upstream 切换,MVP 阶段先不做。 ④ **升级时 `activities:install` 的副作用还在**:它会按主键 upsert 覆盖 8 行样例主数据 (含 `CU-0142` 的 `coverage_score`),并发出 8 个 data 事件 —— 生产库里若有启用的 data 触发器, 会凭空起 8 张单。当前是靠「版本没变就不装」把它挡在门外,而不是根治。 根治要一个「只装包不写样例」的入口,那是改代码。 ⑤ **`reset-runs.js` 会被 tsup 打进 dist 一起发到生产机**(`tsup.config.ts` 里它是显式 entry)。 没人调它,但它就在 `apps/api/dist/reset-runs.js` 躺着,一条命令就能 TRUNCATE 33 张表。 ⑥ **每版一份 node_modules**(`pnpm install --prod` 在发布目录里跑,实测 214M)。 pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人。留最近 5 版。 ## 2026-08-25 · 邮件收发接上了,留下十五笔账(ADR-013) ① **channel → event 按 `cause.ref` 不幂等**:同一封信投两次会起两单,所以 mail 的分发只能自动投一次; 根治 = `event.ingestInbound` 按 `cause.ref` 去重(`sys.event` 上钉唯一索引),那之后分发可以改成自动重投(退避阶梯与 event 同款)。 ② **wecom 发出的信拿不到 Message-ID**(`compose_send` 只回 errcode):线程级归属对它不成立;③级地址窗仍可用。腾讯不给,只能记着。 ③ **`read_mail` 的 `mail_data` 编码文档未说**:适配器先按原文解、不像 EML 再按 base64;探明是哪一种之后写死一档,删掉兜底。 ④ **IMAP IDLE 未做**:最快 10 秒延迟(`pollIntervalSec` 下限);IDLE 要一条常驻连接,与租约模型要另设计。 ⑤ **`createSimulatedMailTransport` 与 `sys.outbound_mail` 暂留**:装配已换成真传输件,下一轮连表一起退役(reset-runs 里那一行同删)。 ⑥ **附件走 `sys.upload` 的 8MB 上限**:更大的只留注;原件 .eml 超 16MB 不存。要真存得给上传件换存放策略(对象存储或文件系统 + 两台后端共享)。 ⑦ **channel 配置仍是只读种子**:没有 upsert 路由,`SEED_QUOTE_FLOW=false` 之后库里没有任何 mail 通道 —— 账号绑不上通道,来信只会 `skipped`。 要按业务收信得补 `PUT /api/gov/channels`(含提取规则 / 映射的编辑面)或先手工种一条通道 + 触发器。 另:订阅方不落库,新加的订阅方收不到历史信(运营面可手动重投)。 ⑧ **`setState(id,'sent',…)` 的「不留错误话」是展开顺序撑出来的,不是真不变量**(`mail-store.ts`): `{ ...(state==='sent'?{error:null}:{}), ...(patch.error===undefined?{}:{error:patch.error}) }` 里 `patch.error` 排在后面, `setState(id,'sent',{error:'x'})` 在类型上合法且会让它赢,静默破掉「sent 的行没有错误话」这条 store 侧不变量。 当下没有调用点传得到这个组合,只是没人踩雷;把 `patch.error` 那条挪到 `state==='sent'` 判断前面即可让它成为真不变量。 ⑨ ~~**`parseCursor` 只查类型不查取值范围**(`cursor.ts`)~~(2026-08-25 终审修复轮销账): `{kind:'wecom', lastTime: 1e13}` 这类脏游标能让 `cursorSummary` 的 `new Date(lastTime*1000).toISOString()` 抛 `RangeError`,而它渲的正是账号视图 `status.cursor` 那一格 —— 一行脏游标能让 `listAccounts` 整个 500。 已修:两形的数值都要**非负安全整数且在界内**(imap 的 `lastUid` ≤ 0xFFFFFFFF,RFC 3501 的 32 位; wecom 的 `lastTime` ≤ 8.64e12 秒 = JS Date 的上限)—— 只写「安全整数」还不够,`1e13` 正是安全整数, 乘 1000 才出界。不合格的当没有游标(照原注释「形状不对当没有游标,不抛」)。`cursor.test.ts` 钉住。 ⑩ **Message-ID 撞车(providerRef 是新的)留孤儿附件**:`storeOne` 先按 `provider_ref` 查重、再解析存附件、最后才 `insert`; 若真正撞车的是 `(account_id, message_id)` 那把键(provider_ref 没撞),附件已经落进 `sys.upload` 而 `insert` 报 `inserted:false`, 没人再引用这批附件。根治要给 `InboundStore` 加一个按 `messageId` 查重的口,插入前先挡。 ⑪ **批内逐封持久化游标那条代码路径没有测试钉死**:要在中途注入一次 provider 失败才验得到「游标停在最后一封成功处」, 现有测试夹具还没有故障注入的口子。只影响批失败时的**返工量**,不影响正确性(两把唯一键仍挡得住重复入库)。 ⑫ **两份 IT 测试的 `provisionDatabase()` 近乎逐字重复,且换出来的库从不清理**:`mail-store.it.test.ts` 与 `route-coverage.it.test.ts` 各自实现了一遍「从 base URL 换一个新库名」,`jcai_it_mail` 与先例 `jcai_it_routes` 一样 永不 DROP DATABASE —— 长跑的开发机会慢慢攒下若干 IT 库,该抽成一个共用 helper 并在跑完后清理。 ⑬ **`mailSendTool` 的 `dryRun` 自己另写了一份校验**,与模块的 `validateSend` 是两份规则,会漂; 且 dryRun 完全不查附件的 `uploadRef` 是否真存在 —— 「dryRun 绿」不能保证「真发能过」(附件缺失要等真发才现形)。 ⑭ **`attachmentsOf` 对形状不对的附件项仍是静默丢**(`mail-wiring.ts`):数组里混进非对象项会被 `flatMap` 悄悄滤掉, 与已经修过的「缺 `uploadRef` 当场拒」是同一族问题的更窄一种,还没堵上。 ⑮ **`JCAI_MAIL_POLL_TICK_MS` 给垃圾值会得 `NaN`**:`Number(env[...] ?? 5000)` 不校验,`setInterval(fn, NaN)` 按最小间隔跑; 与既有的 `dispatchIntervalMs` 是同一款约定(未做输入校验),但这一条每拍要真打一次库,代价更大。 ### 2026-08-25 终审修复轮之后新增 / 变更的几笔 ⑯ **工具路发出的信不登记 `outbound_ref`**:`event.recordOutbound` 的唯一调用方是 `channel.sendMail`, 而 `channel.sendMail` 在 src 里**零调用方** —— 放行落地走 `deps.tools.execute` → `mail.send` 工具,不经 channel。 于是生产唯一的发信路径**不写** `sys.outbound_ref`:回信归属只剩②字段提取与③地址窗,①级(线程号对表)落空。 根治要 toolbox 把 `taskId` 透进 `ToolHandler` 签名(处理器现在只拿得到 input / connection / idempotencyKey), 再由 `mailSendTool` 在发成功后调 `event.recordOutbound` —— 那是跨模块契约变更,单独一件事。 本轮只把文档改老实(ADR-013 决策 6、`docs/API文档-邮件收发.md` §4)。 ⑰ **`imapNeedsReanchor` 导出而无人调用**:UIDVALIDITY 变没变的判断在 `imap-smtp-provider.pull` 里内联了一份 (`cursor.kind !== 'imap' || cursor.uidValidity !== box.uidValidity`)。两处同义、只有一处在跑 —— 与本轮修掉的 `threadIdOf` 是同一族(导出的那个没人用,内联的那个才是实情),下一轮并成一处。 ⑱ **降级存档行不自动分发,而「重新投递」会把降级正文投给通道**:解析不动的那一封落的是骨架行 (正文是失败原因,不是客户说的话),自动路径跳过它是对的;但人按「重新投递」时基座不拦 —— 下游会按一段「【降级存档】…」的正文起单。要么在重投前提醒,要么给这类行一个标(库里现在只能靠正文认)。 ⑲ **`SEND_STALE_MS` 与 `claimForResend` 的 5 分钟是两处各写一份**:用例算 `staleBefore = now − SEND_STALE_MS` 传进去, store 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。 ⑳ **IMAP 每一轮都新建 TLS 连接 + 登录一次**:`withImap` 用完即 `logout`,而轮询间隔默认 30s、下限 10s —— 一个账号一天 2880-8640 次登录。腾讯企业邮箱对登录频率有限制,**上线前先拿真账号把 `pollIntervalSec` 压到 10s 跑一小时**看会不会被拒;这比 IDLE 缺席更早会成为事故。根治是连接复用(或 IDLE), 两者都要先解决「跨进程租约下谁持有那条连接」。 ㉑ **wecom 的 `-1`(系统繁忙)重试没有退避**:`call` 连打 `BUSY_RETRIES` 次,中间不等 —— 对方正忙的时候我们打得更密。加指数退避即可。 ㉒ **`pollDue` 对「未配置凭证」的账号每 tick 写一行 stderr**:5 秒一次、一个账号一行, 生产日志会被这条刷屏。应按**状态变化**打印(从能拉变成拉不动那一次说一句),而不是每轮说一遍。 ㉓ **一轮里的 `notes` 不设上限**:50 封毒信就把 50 句话拼进同一个 `last_poll_error`(几 KB)。 不是错,但运营面那一格会变得没法读 —— 截断并注明「另有 N 条」即可。 ㉔ **`claimForResend` 会把抢到的那一行的 `created_at` 推到当下**:这是 CAS 判据的一部分 (抢到即离开可抢集合),代价是**这一列不再是「这一行何时建的」** —— 谁要拿它当来源凭据都会读错。 要么另立一列记真正的创建时刻,要么在读它的地方写明。目前无人拿它做凭据。 ## 成员目录冷启 8 秒(2026-08-26,ADR-012) `GET /api/gov/org/users` 第一次要 8 秒:Keycloak **没有「一次拿回全部人的生效角色」的接口** (手册 §14.4 是逐人 `GET /users/{id}/role-mappings/realm/composite`),8 个人就是 8 趟远程往返。 并发从 8 提到 24 实测无改善(9.3 秒)—— 卡的是那台 Keycloak 每个请求自身的时延,不是我们的并发。 现状:usecase 层 60 秒读缓存(`DIRECTORY_TTL_MS`),任一写成功即整体清空 —— **冷启 8 秒、60 秒内 0 毫秒**;写完立刻重拉一定看得到新结果。 结构性治法(没做):按**角色**问而不是按人问 —— `GET /roles/{name}/users` 拿直接持有者 (每个角色一趟)+ 展开 `default-roles-` 的组合(一趟),合起来就是全员的生效角色, 调用次数从 O(人数) 降到 O(角色数)。当时试过这条的半截(只用 `/roles/{name}/users`), **数错了**:`user` 是经 `default-roles-corp` 继承的,那条路报「持有 0 人」而实际人人都有 —— 所以要做就得连组合展开一起做,不能只做一半。realm 长到几十上百人时再动它。 ## 成员与组织:写完那几秒屏上还是旧的(2026-08-26,ADR-012) 组树那一趟读要 5-6 秒(逐组数成员 + 服务端 60 秒缓存被写清掉),于是**删一个组之后** 到画面更新之间有一段真空。实测三轮里有一轮**根本没刷过来** —— 网络记录里露了马脚: 中间夹着一趟 `GET /groups → 401`(token 到期,typed-fetch 强刷重试),那一次重取就此失败, 而 react-query 拿不到新数据就继续摆旧的。 已做的两刀: - 服务端缓存**按写碰到的东西分开清**(`'users' | 'groups' | 'all'`)—— 建个组不再把 「全量成员的角色」那趟 N+1 拖下水;前端的失效键同批收窄(`ORG_KEYS`)。 - 删组成功后**先把已知事实落进缓存**(`setQueryData`,按路径前缀连子组一起摘), 再照常发重取 —— 与 `mirrorTwins` 同一条道理:值本来就在手上,不必等服务端再说一遍。 **还欠**:①**重取失败时界面继续摆旧数据,一声不吭** —— 这不是这一屏的毛病, 治理台每一屏都这样(`QueryGate` 只管首次载入,不管重取失败);要治得在共享层做 「这份数据是几时的 / 刚才那次刷新失败了」。②组树的成员数仍是逐组一趟, 组多了会线性变慢 —— 与「成员目录冷启 8 秒」是同一族问题(见上一条), 真治法都是把「按人问」换成「按组/按角色问」。 ## 推送有多密请求就有多密:广播没有合并层(2026-08-26) 08-26 的「工作台疯狂轮询三个接口」查到底是两台后端互桥回弹(`broadcast-bridge.ts`,已修, 2 测钉死)。风暴掐断之后,**同一条放大链还在**,只是没了永动机: - `task-service.tick` **每拍无条件** `setPhase` → `control` 信封 → `task` touch(不看 phase 有没有变); 每拍推帧 `view-html` 一条;推进器 `advanced` 一条、在流程里再加一条 `workflow` —— 一拍 3-4 条 `task` touch 在几十毫秒内到达; - 前端 `invalidateFor` 逐条失效、不合并:每条 touch 各让 tasks / approvals / task 详情重取一次, TanStack 默认还会取消在途请求重发(`cancelRefetch`)—— 一拍 9-12 趟请求,一条案例活动跑十几拍 就是上百趟,而画面只需要每拍刷一次。 治法两层:①源头 `setPhase` 只在 phase 真变时广播(与 `worthBroadcasting` 同一条道理); ②`invalidateFor` 加一个 50-100ms 的合并窗,同一批 touch 落地后按键去重只失效一次 (纯函数、可测)。两层都做,任一层失守另一层兜着。 ## ACT-WORK-05 走查逼出来的三条平台欠账(2026-08-27) 差异评估活动(`part-substitution-assessment`)从零到 released 的浏览器走查里, 有三处**病在平台、这一轮只在活动侧绕过去了**——绕法是措辞,治法在共享面: - **`disable` 这条路从来没通过**:`lifecycle.ts` 里 `Disabled` 只能从 `Enabled` 来, 而 `Enabled` 只由 `plan` 动作写(裁量项入场);普通计划项一直停在 `Available`, 于是任何 `disable` 都撞「生命周期迁移非法:Available → Disabled」。 全仓只有本活动用过它 —— E04/I01 一次都没用,所以没人发现。实测代价:连拒五拍熔断。 两条路二选一:要么让 `disable` 对 `Available` 的项也成立(语义:这一单用不上它), 要么在拒回里明说「本平台没有弃用计划项这回事,不需要办的项什么都不用做」。 **现状**:活动侧全包清了 `disable`,并在哨兵上加了「真有料号不在库」才开门。 - ~~**前一项不收掉,后一项的件就推不出来**~~(2026-08-27 晚销账:用户送来 3ef3fc3e 当场复现 —— 模型没 finish S0d_p08 就发了 P-10 的卡,件停在 P-08。现在**画面跟着这一拍发出去的卡走**:human / entry / decide 把 「发给了哪一步」交回拍内,那一步声明了画面就推它,压过「第一步没干完的」;有测。**根治**(同夜,用户:「不要只修表象」): 人工激活的项在人答过卡之后照常求 doneWhen(拍首拍尾各一遍),W05 两张数据工单的 doneWhen 只读入库事实, S1 哨兵不再等模型写 created 旗 —— 上一步办完与否不再靠模型记得 finish。):引擎挑画面按「当下还没干完的第一步」取 view (`turn-runner.ts` 2058-2064 附近)。模型把「人交回 → 发下一张卡」压成一拍、忘了 `finish` 上一项时,卡是新的、件还是旧的 —— **人拿到的卡写着「把资料填到件上」,屏上一个格子都没有**, 提交闸如实拦死,整单挂在那儿。E04 的 S4→S5 撞过同一堵墙(2026-08-23 已按 `currentRuntime` 重算在办那一步治过一次),这次是**两个人环项前后相邻**的形状,重算也救不了 —— 两项都还 Active,第一步仍是上一项。 真治法:发人卡那一刻,若卡绑的项与当前推的 view 所属项不是同一项,要么按卡所属项推件, 要么把这件事如实说给模型(现在它一点回声都收不到)。 **现状**:活动侧把动作串写死「①先 finish ②再发卡」,配测试钉措辞。 - **拒回只谈行与列,数不出「缺的是一次工具调用」**:`S3_assess` 的 doneWhen 里有 `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条), 模型跳过工具、自己把推荐等级填了,于是 finish 恒被拒;而拒回文案只说 「是不是该写 N 行只写了 M 行 / 某一行缺了必填列」—— 把注意力全导向行与列, 模型连着五拍重写同样的明细,熔断。 真治法:收步判据里那半句若是 `tool_*_calls`,拒回就该直说「这一步的收步条件里还数着 某某工具的调用次数,当下是 0 —— 先去调它」。这是设计律「拒回的话是模型下一拍的行动指南」 在工具这一类上的缺口。 **现状**:活动侧在 guide 里写了写盘前自检。 - **执行工单绑的必交付台账一行都没有时,提交闸无牙**:卡不绑表时闸回落到 必交付声明的台账(`gateAddressesOf`),而 `missingReadings` 只数「已有行缺不缺必填」—— **零行 = 零缺**,闸开着。实测:模型压拍把卡发出去、清单一行没写,人对着空表, 按得下提交、按下去什么也没发生。 真治法待议:若一张必交付台账本单一行都没写,闸该拦并说「这张表还没有内容」; 但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。 **现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。 ## `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__` 目录, 确认这个方向至今只活在测试里。 ## `` 排在 `` 之后:违反 HTML5 表格内容模型、读屏器列头关联失效(2026-08-30,Ruling H31) `activities/part-substitution-assessment/src/views/report.ts` 的 `CHIP_TYPE_TABLE` / `TYPE_PARAM_TABLE`(Task 7b 修「新建行按钮克隆到表头」那次 Critical 回归)把 `` 写在两个 `` 之后——**挪位本身是那次修复的正确解法**(`data-edit` 两处写法让 `ViewContainer.bodyOf()` 解析到外层 ``,`addRow()` 在这个 host 里找 `tr:last-of-type`;`` 若排在数据行前面会被优先命中、克隆出表头文字而不是数据格), 但这个修法本身留了一笔新账: - **违反 HTML5 的 table 内容模型**:标准写法是 `` 排最前(`` 可以排在 `` 之后、`` 之前,但 `` 本身不许排在 `` 之后)。 - **会让读屏器的自动列头关联失效**:无障碍算法按**文档序**从数据格向上扫、找同列的 `` 排在最后时这条自动关联找不到表头。 呈现不受影响(CSS `table-header-group` 让 `` **视觉上**永远排最前、不看它在 DOM 里排第几,`querySelector`/`:last-of-type` 认的却是文档序——呈现与选择器认的不是 同一回事),走查看不出来,只有读屏器 / 标记校验器能测出来。复审当时判断:本仓从未 提出过可访问性(a11y)要求,故非违反既有标准、是此前不存在的隐性缺口;真要治得动 `bodyOf`/`addRow` 或重构成单个 ``,都超出了那次修复「零平台改动」的前提。 **要治**(方向,未验证,留给认真做无障碍的时候一起评估): - 平台侧治本:`bodyOf`/`addRow` 改成不依赖 ``/`` 的相对顺序(直接按 `data-rows` 标记的那个 `` 取,不用 `tr:last-of-type` 在整个 `
` 来读出「这一格是哪一列」,`
` 范围里找), `` 就能挪回最前面,两全。 - 或者把两个 ``(动态行 + 静态种子行)合并成一个,`` 天然留在最前——但 种子行「不受 `data-rows` 管辖、行数再少也在」这条设计意图需要另一种表达方式。 **影响面**:目前只有这两张表这么写(其余活动的可编辑表 `` 都在最前,因为它们 没有「零行也要有种子行」这个需求、`data-edit` 也没有写两遍)。 **排查法**:`grep -n '' activities/*/src/views/*.ts`,看它是不是排在最后一个 `` 之后——目前全仓只有这两张表命中。 ## `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`,非终态行逐单读记忆与 loop 运行态判驳回,再在内存里筛、数、切页。MVP 量级(一个活动几十到几百单)够用; 上千单后该把状态落成一列(或物化视图),过滤下推到 SQL。 - **`sys.activity_profile` 只有 seed 一个写口**:负责人 / 说明 / 图标 / 色相由 `seed-activities.ts` 的 `ACTIVITY_PROFILES` 按包填,负责人按岗位取第一个在职的人。运营面的改口(改负责人、改说明) 等「创建活动」功能一起定。 - **`format: 'file'` 的启动字段不上表单**:`launch-params` 直接省略它们(W05 的 datasheets 走数据工单)。 文档 §4 的控件种类里没有文件控件;要上得前后端一起加。 - **禁用只拦运营端启动**:`POST /api/gov/activities/:id/instances` 看 `enabled`;触发器 / 工作流 / 测试台起单 都不看它(`task.start` 里没有这道门)。文档 §0 的「禁用只拦新启动」目前就是这一个口。 - **combo 候选内联、封顶 500 行**:`x-options` 指的表按 label 升序取前 500;客户 / 料号台账更大时要走检索式候选 (文档 §4 预留的 `optionsSource`)。 - **E04 的「新客户」入参形状启动不了**:`customers` 数组元素可以是对象(新客户档案),启动表单只收字符串数组。 运营端要起新客户那条路,得给 list 控件一个对象形态或另开录入。 ## 运营中心 · 活动详情沙箱看板(2026-08-27 接数据时记下的) - **只摆最近一次已放行的实例,挑不了单**:用户定 MVP 先这样。要看别的单 / 在办中的单,`opsActivityViewRoute` 得加 `?instanceId=`(在办的单要走 `taskView` 连草稿层一起圈,`missing` 那一态也会多一种说法)。 - **行级来源标记只有 P-08 有**:P-08 借 `sub_eval.pn_check.exists_status`(found = 在库直读 / created = 本单建档)说了 每颗料是不是这一单建的;P-10 / P-06 只按「本单挂的 / 同料号历史」分两个 ``,P-10 分不出哪份资料是本单 数据工单建的。要分,得在放行留痕里记主键名单(`ledger-write` 的 detail 现在只有表与行数)。 - ~~**`vtok` / `badgeCss` 三处各一份**(`report.ts` / `views/badge.ts` / `activity.test.ts`)~~(2026-08-31 视图层 重设计时收掉一份:report.ts 改 import `views/badge.ts`,包内只剩 badge.ts 与测试镜像两份 —— 测试那份是 「与 classToken 一字不差」的回归网,按测试镜像惯例(view-render-addrow-thead 同款)留着;抽进 `@sdk` 仍可做,不急)。 - **`viewTemplates[].data` 没有装包期静态审**:`from` 指到没声明的表 / 后面的集合、`by.column` 不是那张表的列, 现在只有 W05 包内测试钉着 + 运行时「那一块空着 + stderr 点名」。该并进 `static-audit`(与 `refersTo` 目标核对同款)。 - **`docs/数据域字段说明-W05.md` §二 / §十二.4 的参数集口径过时**:仍写 `normalized_parameter_set` 是 text 列「不建子表」, 而清单早已拆成子表 `parts.parameter`(库里那一列全 null)。一览表也少 `parts.parameter` 一行。 - ~~**平台缺「确定性步骤」:确定的流程只能交给模型**~~(2026-08-29 已实现,销账)。 2026-08-28 用户点出:「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗」。 W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」是纯规则,却写成了模型的 guide —— 因为一个计划项的做法 只有一个落点(模型的 guide):纯工具只读、判定表不写台账也不发卡、`doneWhen` 只收步、哨兵只开门, **没有一个机制能整段承接**「读台账 + 写草稿 + 发人卡」。引擎这三样能力全有 (`taskView` / `draftWrite` / `issueExecution`),缺的只是**声明面**。 代价已经付过两次:08-27 原 PN 不在库却判「在库」(用 `found_pn_ref` 的 refersTo 兜了一半 —— 「说不在库」那一侧至今没有校验);08-28 模型把原 PN 的规格书抄到候选头上(已用 `humanWrites` 立墙)。 **`docs/adr/ADR-014-deterministic-steps.md` 已实现**(状态 提案 → 已实现):`PlanStep.auto` 声明式配方, 五个动词(`project`/`analyze`/`card`/`submit`/`say`,比 2026-08-28 出设计时的「三动词」多了 `analyze` 与 `submit`)。 §11 待定的前两条随实现落了地:①「决策卡要不要给配方」—— 给了,`card.as: 'decide'`; ②「工具串接要不要做第四个动词」—— 没照原样给(那会把工具的副作用面与预算面一起引进配方), 换成了 `analyze`:一次结构化 AI 调用(系统组装入参、声明出参 schema、系统落表,不给工具不给记忆不多轮), 比工具串接窄但够用。第③④⑤条(`humanWrites` 细到列 / S2 与比对工具的 join 重复 / `T_release` 第三条裁量该删)是 W05 具体步骤的事,留给下一份计划。规范 §4.6 记了怎么写。 **机制先在纯函数与假端口上钉死**——这一轮 `git diff --stat b995148...HEAD -- activities/` 为空,九个现有活动包一个字没改 (它们的剧本里没有 `auto`,新代码在结构上就进不去);机制的第一个真实用户是下一份计划(W05 重写)。 - **配方没有 `drop` 动词**(2026-08-29):撤行只能靠 `project` 的 `prune`(「这一笔投影出来的就是全部」)。 要撤一行而不重投整张表的场合,现在写不出来。等真出现第二个用例再加。 - **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、 **用量已真的计入活动预算**(2026-08-29 终审 C1 修:装配层从裸网关改成 `createAnalyzeGateway(tracedGateway, onAgentUsage)` —— 在那之前这半句是假的, `createModelGateway` 自己不做 `addConsumption`,配方问模型是不花钱的), 但不算「被拒」——熔断看不见它。一份写坏的 `instruction` 会每拍烧一次而不触发熔断; 目前靠「auto 步发了卡就收拍」封住(analyze 那一步不发卡,`doneWhen` 不成立就交人), 真出现空转再补一道计数。 - **治理台的活动详情还摆不出「这一步系统会做什么」**(2026-08-29):配方是数据,渲染得出来 —— 这是相对提示词白赚的一件,但要动前端,不在这一轮(后端已经把 `auto` 随清单存进库了)。 - **`.stable/**` 进了 `eslint.config.js` 的 `ignores`**(2026-08-29):它是部署快照目录 (`stable.sh` 放的旧 commit 源码拷贝,已在 `.gitignore` 里),与既有的 `dist` / `.build` / `api-report` / `.api-tmp` 同类 ——「产物不是源码」,不该被门禁扫。加它是因为本轮门禁曾被 `.stable/` 下的旧代码拖红(那份代码不受这次改动约束,红也修不掉)。这条动了门禁配置, 按铁规矩③要在这里留痕;要推翻只需删掉这一条 ignore。 - **配方留痕的可见性,靠的是「kind 选对 + 不标 internal」,没有机制保证**(2026-08-29, R10 的教训 —— `result` 不进工作日志骨架、`internal:true` 会被 `machinery()` 再滤一道, 三段各自「正确」、合起来一笔都看不见,踩了才发现):将来新增一类配方留痕时,若照手感 写成 `kind: 'result'` 或顺手加 `internal: true`,它会**静默地**从工作日志上消失 —— 没有测试会红、装包也不会拒,人只会发现「这一步好像没留痕」。目前靠代码注释提醒 (`turn-runner.ts` R10 附近),没有静态检查兜底。 ## 收尾复审二审(2026-08-29,B 项记账) - **「保存后唤醒的收窄」几乎不生效**:`afterSave`(`save-wake.ts`)的 F1 门槛是 `pendingCards` 为空才不叫醒(见 `apps/api/src/app/__tests__/save-wake.test.ts` 那条改过的 用例标题)——而 `pendingCards`(`human-tool-service.ts:374`)把**未办结工单**也算在内, 人能在件上按保存的时刻(工单开着让他填)几乎恒有一张这样的工单还没结。也就是说 「没有卡在等人」这个分支在人真正会走到的场景里几乎从不成立,`wake` 照发、一步预算照烧 —— F1 那道门在设计上是对的,只是它保护到的场景比想象的窄得多。真要治得重新定义 「什么时候算需要唤醒」(比如分清「这个工单是不是这次保存所在的那一张」),是新设计, 不在本分支拍。 - **模型路径的写同样会 un-land,只补了配方那一半**(复审判**中等偏高**):`DraftStore.put` 尾部 `landed_at = null` 是无差别的 —— 同一行落草稿层一次,不论落笔的是配方(`project` 动作)还是模型自己的 `write` 行动。本轮把配方那一侧堵上了(`skipUnchanged: true` + `detail.changed` 只在真写了/真撤了时为真,见 `auto-runner.ts` 的 `project` 分支与 `turn-runner.ts` 的 `revised` 判据),**模型那一侧完全没动**——模型的 `write` 行动没有 `skipUnchanged` 这层,值没变时 `draftWrite` 照样把 `landed_at` 打回 null。 而 W05 的数据工单**今天**就在跑「确认即入库」(2026-08-27 的 `landed` 记号机制), 模型只要对已入库的同一行同值重写一次(比如没读现值、把台账里已有的值原样再报一遍), landed 记号就没了 —— 整单放行会再写一遍正式表、再抛一遍 data 事件,而 CLAUDE.md 对 landed 行的承诺是「不再写它、不抛事件」。**同一个洞只补了还没人走的那半边** —— 配方(`auto`)目前一个活动包都没在用,模型路径每天都在跑。**判中等偏高**(复审原话): 不在本分支动,原因两条 —— ①这是**共享面**(`turn-runner.ts` 的 `write` 行动、 `engines.ts:1055` 的 `draftWrite`),改法要么给模型路径也接上 `skipUnchanged` (得先核实它是否会悄悄影响 :980 那道「一行都没变的写不是一个行动」拒回门 —— 逐行看目前是不会,`changed` 在 `skipUnchanged` 判断之前就已经按值算好了,但没有测试钉死 这条不变式,改了要重新证明一遍并补测)、要么另开一条更窄的路径,两种都要走全量回归 + 三套 e2e;②本轮改的是配方那一条新路,`git diff --stat b995148...HEAD -- activities/` 为空这条硬约束意味着不能顺手把模型路径也捎带上、再靠一次浏览器走查收尾 —— 排查与验收的工作量不是这一条闭合修复能捎带的。**应当独立起一条、排在 W05 重写之前**。 ## S1 配方回归测试里躲不掉的手抄(2026-08-29,Task 4 评审二轮) `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 证的是**引擎逐 action 刷新时机**这件事(案卷投影必须排在读它的基座读数所依赖的那几笔之后)—— 这一条只有把真引擎跑起来 才测得出;`activities/part-substitution-assessment/__tests__/steps.test.ts` 的"六笔,次序是…"用例 用精确数组相等钉的是**真** `ASSESSMENT_STEPS` 本身,两份测试各守各的,互不重复。 这份引擎级测试**手写了六个 `ACT_*` 常量**,在形状上代表 `case-model.ts` 里 `S1_inputs.auto.do` 当前用到的四种地址写法(字面 `rows` / 单层 `each+where` / 两层 `each+where+outer` / `once`)—— 这是**躲不掉**的手抄:`.dependency-cruiser.cjs` 的 `activities-isolated` / `apps-not-into-activities` 两条规则对测试文件没有例外,`apps/*` 与 `activities/*` 互相不许 import,所以测试文件不能直接 `import { ASSESSMENT_STEPS } from '.../case-model.ts'`。 (读数层四个纯函数 `rowReadings` / `missingReadings` / `tallyReadings` / `distinctReadings` **不在 此列**——它们是 `apps/api/src/app/wiring/engines.ts` 的具名导出,从 `apps/api/src/modules/ loop-engine/__tests__/` import 它们是 `apps → apps`,两条隔离规则一条都不沾。第一版误判"读数层也 躲不掉"而手抄了一份,评审查出与生产版已经漂了一处(`missingReadings` 拼缺项名用 `f.label ?? f.name`,手抄件漏了 `?? f.name` 那半句),已改为直接 import 真身,删掉约 150 行手抄, 换成 `@app/*` 路径别名〔`tsconfig.base.json` + `vitest.config.ts` 各新增一行,与既有 `@modules/*` / `@shared/*` 同一种写法〕——之前的三层相对路径撞了 `eslint.config.js` 的 `no-restricted-imports`。) **欠账**:这份镜像**不会随 `case-model.ts` 自动同步**——它守的是引擎机制而不是"逐字等于当前配方" 这个没人能守的承诺,所以次序变化不影响它的有效性,但如果 S1 配方的**形状**变了(比如新增了 `analyze` 动词、某一笔的 `from` 换了一种从没出现过的地址写法、或某个 `when` 链换了结构),这份 文件的代表性可能跟不上而不自知——没有测试会为此变红,装包也不会拒。 **谁改了 `S1_inputs.auto` 的形状(不是次序),该顺手做的事**:打开 `w05-gate-reason-order.test.ts`,核一遍 `ACT_*` 六个常量描述的地址/字段写法是否还能代表 `case-model.ts` 当前用到的构造种类;不必逐字同步(那不是这份测试的目标),但要能回答 "如果生产那边这一类写法出了刷新时机的 bug,这份镜像能不能测得出来"。 ## `analyze` 出参靠 `requiredWhen` 兜底的字段,漏填时全 auto 步没有就近恢复点(2026-08-29,Task 5 评审) `S2_assess` 的 `analyze` 出参 schema 里 `unknown_disposition` 不在 `required` 名单——只靠提示词 约束模型「判了 unknown 就必须在这一格写清楚」,漏填由 `manifest.ts:397` 的 `requiredWhen: {column: 'difference_status', value: 'unknown'}` 兜底拦成 `substitution_difference_item_missing > 0`(评审已核过:机制是对的,提交闸与放行门都看得见)。 但 `S2_assess` 全程 `auto`、零 `guide`、零人工卡口——模型这一拍真漏填,当下没有任何东西能就近 接住它,只能等清单声明的 `harness`(`memoryWindow:24, stallAfter:'PT4H', nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢兜底, 比人工激活步「重发已答过的卡=无事发生」那类近距离恢复慢得多。 **这是 ADR-014 `analyze` 动词的通用基础设施特性,不是 W05 这一个活动的病**——凡是拿 `analyze` 出参配合 `requiredWhen` 兜底可选字段的活动,都会有同一个洞。**把它改进 `required` 是错的**: `unknown_disposition` 只有 `difference_status = unknown` 的那几行才该填,MATCH 的行填了反而是 编造,逼模型给不需要的行也填一格。 **要治得在 ADR-014 层面**:给 `analyze` 一个「出参不合规(含 `requiredWhen` 这类 JSON Schema 本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时 「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法 (2026-08-03,见 CLAUDE.md「兜底卡不再摆模型原话」条)——`analyze` 目前没有对应机制。 ## 静态审核认不出未绑定的 `{in:X}`(2026-08-29,Task 6 评审 Critical 逼出来的平台缺口) `activity-registry` 的 `static-audit.ts`(`walkExpr`,约 `:458-471`)会递归校验一条 `AutoExpr` 里的 `when`(→ `checkWhen`,核名字绑得上绑不上)与 `lookup`(→ `checkAddress`,核地址声明过没有), 也会递归进 `cat`/`fmt`/`then`/`else` 继续往下找 —— **唯独没有 `'in' in node` 这一支**。裸的 `{in:X}` 引用(配方级 `let` 算出来的名字、或起单入参、或 `case:` 读数)在装包那一刻**一声不响**, 名字到底绑不绑得上、活着不活着,静态审一个字都不查。 这个洞在 W05 上真的咬过人(见本文件件头 Critical 记录 / `case-model.ts` 的 `SHARED_LET` 头注 / `apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts`):`S3_review.auto` 一度没有自己的 `let`,而它的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`)里五个 `{in:X}` 引用的名字**只在 S2 的 `let` 里绑过**——装包、`pnpm gates`(2395 条全绿)、六条纯结构 断言(`steps.test.ts`)全部放行,只有把真引擎(`createAutoRunner`)跑起来求值才现形:一条主键算成 空串、整笔写被「主键算不出来先不写」悄悄丢掉;另一条主键因为 `cat` 拼接把空串垫成非空前缀 (`'VAL-'`),反而写出一行垃圾,破了「一单固定一条」的表不变量。 **这是平台缺口,不是 W05 一个活动的病**——凡是拿 `{in:X}` 却没在**这一步自己的** `let` 里定义 过(哪怕在别的步的 `let` 里定义过,`let` 不跨步传递,`auto-runner.ts:93-97`),都会在装包时蒙混 过关、只在运行期以「写丢了」或「拼出垃圾行」两种面目现形,且不出声、不留痕说清原因(只在 `memory.append` 的机械留痕里能看到「主键算不出来没写」,人不会去读那一行)。 **要治**:`walkExpr` 补 `'in' in node` 这一支,核 `node['in']` 这个名字是否落在 `letNames ∪ declaredNames`(与现有 `checkWhen` 同一套判断)里,绑不上就 `schema-invalid` 拒装 —— 与 `checkWhen` 现有的「S-FEEL 对未绑定的名字求值为假,这一笔将永远被跳过」同一条纪律,只是 `{in:X}` 的失败面目更隐蔽(空串会被拼接、被当合法值继续往下算,不像 S-FEEL 谓词那样直接短路)。 `auto` 是 ADR-014 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。 ## `auto:true` 又是整数型的主键,平台两处独立机制都会踩(2026-08-30,Task 7b 评审 Critical 逼出来的发现) `resolveRowKey`(`apps/api/src/app/routes/desk.ts:111-181`)第③段「续号」只判断 declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `auto:true`** —— 它是为 `req_seq` 这类「非自增、按行数续的序号型主键」(I01 的 `req_seq`)设计的,却对**任何** 整数型主键一视同仁。若某张表的主键**恰好**既是 `auto:true` 又是 `type:'integer'`,这一段会 **抢在** `writeEditedRows` 的 `mintable`/`autoKeys()`(真正为 `auto:true` 设计的续号器,数 `taskView` 正式版+草稿叠加、按声明的前缀+两位补零规则铸号)**前面**动手,拿「这一单自己的 草稿行数+1」铸一个号——这个数不数**正式表**里已有的行,新建的第一行几乎必然撞上任何已存在的 `type_id=1`(种子数据、或早前任务放行落地的行),**静默覆盖**那一行的其余字段。 `autoKeys()` 补零续出的号(如 `'03'`)还有第二个坑:**引用这一列的其它表**(外键列)若也声明 `type:'integer'`,写入时会被 `coerceToColumn`(`apps/api/src/app/wiring/engines.ts:590-598`) 按 `Number()` 解析,**削掉前导零**——`'03'` 存成整数 `3`,而被引用的原表仍是字符串 `'03'`, `refersTo` 校验(`engines.ts:1119-1143`)与 `data-by` 嵌套查表(`view-render.ts` 的 `byParent`)全按 `String(...)` 比对,两边永远对不上:引用列的写入会被 `refersTo` 拒(「在字典里 不存在」),或下拉/查表显示不出刚建的那一行。 **本仓迄今唯一一个 `auto:true` 又是 `integer` 的主键是 `part-substitution-assessment`(W05)的 `parts.chip_type.type_id`**(`auto:true` 的既有先例 `contact_code`——E04/quote-intake 两处—— 从来都是 `type:'text'`,没人先撞上这个组合)。Task 7b 补「件① 新建芯片类型」时踩上了(种子数据 `type_id: 1/2` 会被第一次新建的类型静默覆盖;引用列若也是 `integer` 会导致新建的类型写不进 `parts.profile.chip_type_id` 等)。**这次只是把 `type_id` 自身与五处引用列(`type_param.type_id` / `profile.chip_type_id` / `sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` / `substitution.relation.chip_type_id`)在活动自己的声明层统一改成 `text` 绕开——`resolveRowKey` 与 `coerceToColumn` 这两处平台代码本身一行没改,坑原样留着**。 **要治**(两处,各自独立就够,不必都做): - `resolveRowKey` 第③段续号加一条 `if (input.autos?.includes(k)) continue;`(与第②段 `barred` 检查同一条精神:`auto:true` 的列有自己专用的续号器,不该被这段通用的 「整数型主键续号」抢跑)。 - 或者更保守地,给 `writeEditedRows`/`static-audit` 加一条装包期检查:某张表的主键**同时** `primaryKey:true`、`auto:true`、`type:'integer'|'number'` 时报一条 warning,提醒作者 「这个组合会撞上 `resolveRowKey` 的续号步骤,改成 `text` 或联系平台修 `resolveRowKey`」—— 比事后重新发现这条坑更便宜。 **排查法留一条**:某张 `auto:true` 主键表的**种子/早前数据在没人手动改过的情况下自己变了 名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否 `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` 的 ``,值恒是空字符串——`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 的芯片类型), `project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果 已经因为「数据工单确认即入库」(`landsOnConfirm`,2026-08-27)落进了正式表,`draftDrop` 在 `apps/api/src/app/wiring/engines.ts:1217-1231` 那道显式检查前会被拒 (`「…已入库的,撤不掉;要改由人在件上改」`),配方抛 `AutoRecipeError`,这一步再也办不下去, 单子吊死在 `awaiting`,只能靠人工干预。 复现路径(W05 `part-substitution-assessment@2.1.5`,案例「两颗都在库但分属不同芯片类型」): ①人对着两颗类型不一致但各自字段都齐的料,第一次提交(哪怕什么都没改,提交闸也放行, 因为两颗料本就不缺格子)——这一步**已经落地**,把其中一颗按它*当时*的类型字段集 (如 7 项)写进了正式表;②人接着把它转挂到*另一个*类型(字段集不同,如 5 项)、补上新类型 要求的字段;③配方按新类型重新铺 `parts.parameter` 骨架,`prune:true` 想把①落地的那 7 项里、 不在新的 5 项字段集中的几项(如 3 项)撤掉——它们已经落地,③在这里被 `engines.ts:1217` 拒,`AutoRecipeError`。 **这是真人会走的路径,不是测试造出来的**:人确认一张数据工单之后才发现类型挑错了、 需要改类型,是完全正常的操作顺序。 **两条机制各自都对**:`prune` 是活动清理陈旧行的正当手段(design spec §4.1 就是这么设计的: 「计算出的新集合就是这张表的全部,多出来的撤掉」);「已落地行不许 `draftDrop`」是防止 撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**: `prune` 想撤、平台不让撤。 与上一条(**范围放行没有对 `refersTo` 做传递闭包**)同族——两条都是「数据工单确认即 入库」这个 2026-08-27 新加的机制与**别的**既有机制(这一条是 `prune`,上一条是 `refersTo` 的传递闭包)之间没有对齐接缝造成的。 **要治**(方向,未验证,留给真正动手时评估): - 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于 `markLanded` 的反操作(清 `landed_at`),把它重新变回「本单还没最终确定」的草稿状态, 再走正常的 `draftDrop`;risk:这一改会让正式表暂时保留一条其实已经不该存在的行, 直到下一次真正放行(`releaseWrite`)才会被撤下——需要确认这段「暂时不一致」窗口对下游 (读表的其它任务、报表)是否可接受。 - 或者 `prune` 对已落地行降级成「跳过、留着但记一条观察」,不当错误——把「这几行已经落地、 这一笔没法帮你清」的实情说给人听,交给他在**放行那一步**的判据/清单里手动确认要不要紧, 而不是让整单卡死。 **排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」, 先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在 某张数据工单的 `gateLedgers` 上时,概率最高。