This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/debt.md
T
xcsandClaude Fable 5 816389a185 邮件收发终审修复轮:token 按凭证指纹、毒信有退路、只改发信设置不丢信、重发先抢、列表不搬原件、save 不盖运行列、重锚出声、脏游标不炸页、线程口径归一
全分支终审(跨任务视角)逮出的十条 P0 + 八条 P1,一次修完。每条都配了测试。

P0-1 企业微信 token 缓存改按**凭证指纹**(sha256(corpId,corpSecret),secret 不进 Map 的键):
  按 account.id 存有两条真实串号路径 —— 草稿探测共用 'draft'(探完 A 公司再探 B 公司会命中 A 的
  token,回的是 A 的应用邮箱地址,凭证填错的 B 也显示成功);已存账号换了凭证 id 不变,
  下一轮轮询继续拿旧 token 拉旧 corp 的收件箱,分发给新账号绑的通道(窗口 ≤ 7200s)。
P0-2 一封「毒信」不许卡死整条路:①domain 的 buildInboundRecord 把落 text/jsonb 的字段一律剥 NUL
  (PG 拒收它,MIME 解出二进制垃圾时真会出现);②单封失败先落**降级骨架**(原件原样存、
  主题从原始字节扒、正文写明失败原因)→ 落成了就推进游标并把「第 N 封(providerRef)解析失败,
  已降级存档」写进 last_poll_error;骨架也落不下才停在这一封,那时 error **点名是哪一封**。
  降级行不自动分发(正文是失败原因,不是客户的话)。
P0-3 cursorReset 只比**收侧**键(imapHost/imapPort/imapTls/login/folder;corpId/apiBase):
  从前比全部 settings,把 465 改成 587 也会清游标 → IMAP 重锚 → 上次游标到重锚之间的来信永远不拉。
P0-4 复用旧行重发**先抢**(OutboundStore.claimForResend,一条条件更新;真 store 与内存件同款):
  两个后端连同一个库,两台同时重放同一次放行都判「超时可复用」→ 两封真邮件,破掉「同键不发两封」。
P0-5 列表与只要元信息的读**不取 raw**(一封上限 16MB × 200 行);rawAvailable 由 `raw is not null`
  投影,不靠取回字节再判;只有「下载原件」走全列的 get()。InboundRecord 新增 rawAvailable。
P0-6 账号 save() 只写**配置列**:运行列(cursor/last_polled_at/last_poll_error/last_inbound_at/
  polling_until)只经 savePoll/claimPolling/releasePolling 改 —— 整行 upsert 会把另一台正持着的租约
  与刚推进的游标盖回读那一刻的快照(08-24 loop_state 整份覆盖同款指纹)。清游标改为显式单写那一列。
P0-7 UIDVALIDITY 变化不再静默:provider 回 note,用例落进 last_poll_error(「已重锚在当下;
  期间来信未拉取」)—— 丢了一段信而账号页一片绿是界面撒谎。
P0-8 parseCursor 连取值范围一起查(非负安全整数 + 界内:UID ≤ 0xFFFFFFFF、秒 ≤ Date 上限)——
  1e13 正是安全整数,乘 1000 出界,一行脏游标能让 listAccounts 整页 500。debt ⑨ 划线销账。
P0-9 线程口径归一:threadIdOf 改成**取根**(references[0] ?? inReplyTo,ADR-013 决策 3),
  mail-wiring 调它、不再内联一份。原测试改断言,不删。
P0-10 文档说老实话:mail.send 工具路发出的信**不登记 outbound_ref**(recordOutbound 的唯一调用方
  channel.sendMail 在 src 里零调用方)—— API 文档 §4 与 ADR-013 决策 6 各补一段,根治记 debt。

P1:imap anchor 的 Math.min(...uids) 改 reduce(十万级 UID 会栈溢出)· wecom 翻页加页数上限
(服务端一直回 has_more:1 会死循环)· MAIL_TOOL 导出并对**真契约**断言(原 v1/v2 是手抄夹具、
自证,且 v1 版本号写错)· reset-runs 清运行数据时把邮箱账号的游标与拉取状态一并清(留着游标而信没了,
wecom 十分钟重叠窗会把最近的信当新信再投一次)· due.test 的 leaseMsFor(30) 断言改写死 300_000(原为自证)·
cursor.test 删掉指向评审报告的注释 · 设计稿稿头标注三处已被 ADR-013 取代 · ADR-013 补记 wecom
compose_send 不带 inReplyTo/references(它发出的信在对方那里不串线程)。

契约变更(§12.4):port.ts 的 InboundStore 加 getMeta、OutboundStore 加 claimForResend、
savePoll 的 cursor 允许显式 null、MailProvider.pull 回参可带 note —— 实现方只有本模块 adapter 与
__tests__/fakes.ts 的内存件(两边语义一致,已同改),CONTRACT.md 已同步,api-report 已刷新。

测试:1922 → 1942(mail 模块 102 → 113,含真 PG 的 save 不盖运行列 / 不搬原件 / 两条真连接抢同一行)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TM4kUBvekALu9qqmp7D3wr
2026-08-26 03:47:48 +08:00

1171 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术债登记簿
> 编码规范 §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.<sub>.<scope>.<exp>.<mac>`,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` 缺席时仍退回 `<pre>` 原文 —— **不假装渲染过**这条没变。
仍不认的:内联 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。
<details><summary>原记录</summary>
**现象**:MVP 工作流第二轮,A2 的执行工单载荷完全正确 —— `mode: execution`、两张 `t:"table"`
且 `editable: true`(已用 `/api/desk/tasks/:id` 核过接口出参),页签停在「待处理」、
`disabled` 为假(提交钮在)、两张表都渲染出来了,**但一个 `<input>` 都没有** ——
即 `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,是三轮端到端的**第一个硬阻塞**。
</details>
## ⑨ 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`)、判据表、落款动作 —— 然后把活动推的那一屏
`<ViewContainer>` 塞进自己身体里的一个「数据与分析」章节。两套版面语言叠在一起,
外面那套还先声夺人。搬 PRD 目标件那几轮全在改里面那一层,而人一眼看见的是外面那层。
**分家口径(用户定)**:
| 区 | 放什么 |
|---|---|
| 工作区 · 待处理 | **只放那份件**,可就地改 |
| 工作区 · 工作成果 | **只放那份件**,不加编辑态 |
| 对话区的卡 | 作业指导书 · 依据 · 判据 · 填报表单 · **提交与裁决** |
就地改的做法:可改的格子挂 `data-col="台账列名"`、行容器挂 `data-edit="域.对象"`,
渲染时每格多渲一个 `<input class="jc-cell">` 孪生件,**只读那份与输入那份由样式表二选一**
(根节点带不带 `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`
的元素**上生效(两者写在同一条正则里)。于是分布条那种纯色块
(`<i data-class-of="type_tag"></i>`,格里不写字、只按值上色)整条不着色 ——
属性还留在成品上,一个 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/<sha>` → 迁移 → 迁移到位断言 →
判首次/升级 → 播种或装活动包 → 回查 → 发前端 → pm2 重启 → 探活 → 收尾。
部署在 runner 本机(host 模式),路径类配置从 Gitea Variables 读,机密从 Secrets 读。
接的过程中逐条对上了五个**会静默成功**的机制,每个都配了一道机检:
| 机制 | 它会怎么骗人 | 机检 |
|---|---|---|
| `main.ts` 的入口守卫是 `import.meta.url === \`file://${process.argv[1]}\`` 字符串比较 | 经软链启动两边不等 → `boot()` 永不执行 → **进程零输出 exit 0**(实测过) | pm2 一律用 `releases/<sha>` 真实路径起;探活兜底 |
| `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/<sha>/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 只按传进来的时刻判 —— 目前只有一个调用方所以对得上,但这个数没有一处共同的定义点。