diff --git a/docs/DEBTS.json b/docs/DEBTS.json index ce39cf5..538dfe8 100644 --- a/docs/DEBTS.json +++ b/docs/DEBTS.json @@ -198,7 +198,7 @@ "due": "**失败报告抑制机制落地时**必须一并建(当前该机制**尚未实现** —— 服务端 grep `suppress|抑制` = 0,所以这条判据此刻无对象可测)。判据形状: 构造「同一父信下的两封并列失败报告」,断言 `relayed_mails` 里**两行都在**(而不是被 root-keying 压成一行)。", "where": "待建。落点取决于抑制修法落在哪一层(`server/internal/repo/relayhops.go` 的 `CountTrailingRelayHops` 一族 / 发送侧桥);口径复算工具在 `deploy/recount-relay-counts.sh`", "kind": "scope", - "note": "★★ 2026-09-25 pi 提出(`a948cdbb`)、我复核确认并登记。**这条钉的是机制,不是当下恰好成立。**\n\n## 当前为什么「看起来不需要它」\n```\nB 口径(agent, failure-父链根): 98 封 → 92 组、抑制 **6**\n 组内「两个成员共享同一父」的组数 = **0**(我逐组实测)\n ⇒ 5 个多成员组**全是真链式**(成员互为父子链),今天确实不会误合并\n```\n★ 但那是**当前数据的性质,不是设计的保证** —— 只要出现「同一封来信被两个不同 agent 各回一封失败报告」(或同一 agent 对同一封回两次),root-keying 就会把它们合并掉,而那些是**内容各异的并列失败**。\n\n## 这形状在本系统**真实发生过**(所以不是假想)\n```\nsession d042cc4c: 22 封失败报告、**22 个 parent 两两不同**\n (agent, 线程根) 口径 ⇒ 塌成 4 组(9/7/5/1)⇒ 一次**丢 18 封**\n 其中只有 1 个 parent 本身是失败报告 ⇒ **21 封是并列**\n```\n⇒ 「同级并列」不是边角情况。判据② 定稿即按此: **22 封并列失败、(线程根误用下)塌成 4 组、一次丢 18**。\n\n## 与已否掉的修法的关系(避免下一个人重走)\n```\n✗ 修法① 跳过 kind=summary —— 实测恰好豁免它要拦的那一类(该环 100% summary)\n✗ 修法②(仅根 root-keying) —— 就是本条要防的那个: 会吞并列\n△ relay_key 前缀判「是否失败报告」—— 诊断成立,但**服务端语义变更**,待人或宿主定\n```\n⇒ 本条不预设修法;它只要求: **无论选哪种,并列失败不得被合并**必须有判据。\n★ 这就是它该进登记而不是只留在邮件里的原因: 一个没有执行者的结论会一直「在讨论」,而登记 + 到期条件会让接手的人**必须**遇到它。\n\n## ★★ 2026-09-25 追加(我实测):本条的**前置**问题 —— \"是不是失败报告\"这个谓词**没有载体**\n\n在讨论\"用哪种 keying 抑制\"之前,先要回答\"**这封是不是失败报告**\"。我把它的**全部产生点**查了一遍 —— 它散在 **8 条语句 / 5 个文件 / 3 种语言**,且**写法互不相同**:\n```\nplugins/dsh-mail-bridge/src/index.ts:1240 model-failure:${data.mail_id}\nplugins/dsh-mail-bridge/src/index.ts:1749 **empty-reply**:${mailSessionID}\nplugins/pi-mail-bridge/src/worker.mjs:625 model-failure:${...}\nplugins/pi-mail-bridge/src/worker.mjs:715 model-failure:${...}\nplugins/zcode-mail-bridge/src/index.mjs:304 zcode-failure:${...}\nplugins/homeagent-mail-bridge/plugin.go:929 \"homeagent:failure:\"+replyTo ← Go\ndeploy/service-failure-notify.mjs:92/94 service-failure:${INVOCATION_ID|sha256} ← systemd 脚本\n```\n而**网关侧对它一无所知**: `grep -c` 这四家前缀 + empty-reply 于 `server/**/*.go` = **0**。\n\n★ 所以\"拿现成的 `RelayKeyForMail` 判一下就行\"只对一半 —— 它返回 `(relay_key, kind)`,而 **`kind` 区分不了失败**:\n```\nfailure 行的 kind 分布: kind=**summary** | 98 ← 全是 summary\nkind='summary' 内部: failure 98 / **非failure 321**\n⇒ 用 kind 判 ⇒ 会连 321 行普通搬运一起豁免 —— **正是已否掉的修法①**\n```\n\n★★ **活的反例**(这条最关键): `empty-reply:` 是失败类但**不含 `failure` 字样** ⇒ `LIKE '%failure%'` **永远抓不到它**(该路径代码 1 处、当前 **0 行** ⇒ **将来第一次触发就是静默漏判**)。\n\n⇒ 推论: 若在网关里硬编码这四家前缀,第 6 家桥出现时**静默漏判**,后果是\"报告不再被抑制\" ⇒ 环回来(**假绿**)。\n⇒ 所以修法应先把**分类变成网关拥有的东西**(relay 枚举加第三类 / 或 relayed_mails 加一列),再由各桥**声明**而非拼串。\n\n⚠ 加枚举会撞上一条**故意**的锁: `TestRelayKindsIsExactlyTwo`(`relay_test.go:60-64`)\"免配额类型是白名单…新增前请确认它确实是 harness 代劳\" ⇒ 必须同步改它,而那正是它**本来就该问**的问题(failure 确实是 harness 代劳)。\n\n## ★★ 2026-09-25 追加(判据清单落定):⑥ permission 分叉点,**必须**钉住\n\npi `8f6f6e6e` 提出、我 `476d22ad` 复核后定为判据清单的第六条。\n\n### 分叉点是什么\n```\npi 的判据: 「本封来信 id ∈ relayed_mails」 ⇒ 抑制\n我的判据: 「parent 本身是 failure-relay」 ⇒ 抑制\n构造: 一封 permission 询问(kind=permission,也是 relay)发出\n → 对方处理它时失败 → 发失败报告\n pi 的判据: 来信(permission) ∈ relayed_mails 为真 ⇒ **抑制**(错: 首报该发出去)\n 我的判据: parent 不是 failure 类 ⇒ **放行** ✓\n```\n### 可达性我验了(这是它必须钉住的原因)\n```\npermission-relay 的子邮件 = **137**(mail_type: normal 58 / permission_decision 79)\n ⇒ 子邮件**确实会被产生** ⇒ 只要那一侧处理失败,就会产出失败报告 ⇒ 分叉点可达\n当前: failure 报告的 parent 是 permission-relay 的 = **0**\n ⇒ 今天**还没分叉** —— 而这正是\"两条判据现在等价(都 10 / 差集 0)\"的来源\n```\n★ 所以\"两条判据等价\"是**当前数据的性质,不是机制的保证** —— 与 pi 上封(B 口径今天干净、A 口径的 9 封并列已证明同级并列真实存在)**同一句**。\n⇒ 选判据的理由**不能是\"它们现在等价\"**,而是: **我的判据把\"是不是失败类\"这件事读了出来,pi 的没有** —— 信息量更大的一点更耐久。\n\n### 判据清单(落定版)\n```\n① 环: hop2 起被抑制 ⇒ 环里只剩 1 封\n② 反例: d042cc4c 的 22 封并列失败**一封都不能被抑制**\n③ homeagent 风格 14 封(标题正常但是失败报告)必须被认出 —— 只有元数据能过\n④ 首报必须送得出去(防全抑制)\n⑤ 同一父信下的两封并列失败报告不得被合并(本条的主体)\n⑥ **permission_request 首次失败时,其失败报告必须发出**\n (parent 是 permission-relay,不是 failure-relay)—— 需要失败(构造)\n```\n### ⚠ 一条被否掉的落点细节(免得下一个人重走)\n```\npi 建议: \"插在 :384(CountTrailingRelayHops)之前 ⇒ 被抑制者不消耗 hop,这个口径要写进判据\"\n⇒ 我读实现后判它为**假问题**: `CountTrailingRelayHops` 是**重算**(relayhops.go:54-80\n 每次按 mails LEFT JOIN relayed_mails **现扫**),不是自增计数器\n ⇒ \"被抑制者是否计入 hop\" 不是口径选项,而由\"**有没有落库**\"唯一决定: 没落库 = 不计\n ⇒ ~~**不该写进判据** —— 写进去会误导后来的人以为这是个可配的选择~~\n ⇒ ⚠️ **这一条我判错了 —— 它已被本节末的\"三个带\"取代(请读下去,别停在这里)**:\n 实测抑制点有 ③ 个带,最坏那个会**清零 hop 守卫** ⇒ 不但要写进判据,\n 还要写成**顺序约束**(\"必须在 CountTrailingRelayHops 之前 return\")。\n### ★★ 但\"插在 :384 之前\"这个落点本身**不是无关紧要的** —— 我上一条的\"假问题\"判过头了\n\n我原先写\"计不计 hop 由有没有落库唯一决定 ⇒ 不该写进判据\"。**那半对,但漏了两个形** ——实测(`/tmp/hop.db` 合成库,mails LEFT JOIN relayed_mails 现扫):\n```\n基线 3 封全绑: 序列 1 1 1 ⇒ hops = 3\n加 1 条**占位行**(mail_id NULL,无 mail 行): 序列 1 1 1 ⇒ hops = 3 **不受影响**\n加 1 封**有 mail 行但未绑 relay**: 序列 **0** 1 1 1 ⇒ break ⇒ **hops = 0**\n```\n⇒ 所以抑制点的位置有**三个带**,后果完全不同:\n```\n① 在 :384(CountTrailingRelayHops)之前 return ⇒ 该封既不落 mail 也不落 relay ⇒ 真正\"不计\" ✓\n② 在 :396(ClaimRelay)之后、:474(CreateMail)之前 ⇒ 落**占位行** ⇒ hop 不变,但**白占幂等键** ⇒ 后续重试被挡\n③ 在 :474(CreateMail)之后 ⇒ 落**有 mail 行、无 relay 绑定** ⇒ is_relay=0 ⇒\n `CountTrailingRelayHops` **break ⇒ hops 归零** ⇒ **hop 上限对该会话失效**(那封无绑定邮件停在最新位)\n```\n★ ③ 是最坏的: 它不是\"多算或少算 1\",而是**主动清零守卫** —— 环因此可以**绕过 5 跳上限**。\n而 ③ 正是\"抑制逻辑写在 CreateMail 之后\"这种最自然的写法会落进去的带(因为那里才拿到 mailID)。\n\n⇒ 结论(取代我上一条): **这个口径必须写进判据,但写法不是\"计不计\"** —— 而是\n```\n「抑制必须在 CountTrailingRelayHops 之前 return」\n 理由: 落在其后会造出\"有 mail 行无 relay 绑定\"的邮件 ⇒ 把 hop 守卫**清零**\n 判据形状: 构造一封因抑制而不发的报告,断言同会话 CountTrailingRelayHops 不降为 0\n (这条**能失败** —— 把抑制挪到 CreateMail 之后就红)\n```\n★ pi 的直觉(\"位置决定 hop 读数,所以要写进判据\")**是对的**,我错在把它当成\"可配的口径\"而整体否掉。正确的是: 它不是口径,是**必须满足的顺序约束**,而顺序约束**更要**进判据。\n```\n\n## ★★★ 补记之六(2026-09-26 我实测:回答 pi `5b0bbc33` 的\"桥侧成本\"之问 —— 并给出**它清单里漏掉的第 5/6 个产生者**)\n```\npi `5b0bbc33`(01:16 悬置未答)问: \"wire 上加可选字段这条路**桥侧成本多大** —— 若你那边能查一处桥的\npayload 构造,这个成本就清楚了\"。我实测答复如下。\n```\n### 一 ★ 成本不是\"11 处\"—— 是 **6 个产生者、4 种语言、且失败前缀有 5 种形态**\n```\npi 的清单: \"relay_key 产生点 10 处(含 dist 与 opencode)\",我按**排除 dist/node_modules/测试**重数:\n `relay: ''` 构造点 = **11 处 / 4 个包**(dsh 3 / opencode 3 / pi 3 / zcode 2)\n并**不完整** —— 我另外找到 **两个不在 plugins/ 清单里的产生者**:\n ★ ① `deploy/service-failure-notify.mjs:155-156`(**独立 systemd 脚本,非插件**)\n `relay: 'summary'` + `relay_key: service-failure:`\n ⇒ ★ **库里有 25 行**(`service-failure` × 25、全部 mail_id 已绑定)⇒ **真实在使用**\n ★ ② `plugins/homeagent-mail-bridge/`(**Go 写的桥**)`plugin.go:900 sendMailRelay` + `relay_key.go ClampRelayKey`\n ⇒ 库里 `homeagent:` **18 行**(其中 16 行含 failure)\n ⇒ 语言分布: **Go / TS / MJS / JS** 四种(.go 10 处、.ts 13、.mjs 26、.js 49 含 dist 与测试)\n```\n### 二 ★★★ 而\"失败前缀\"根本不是一种形态 —— 我实测库里**五种**并存\n```\n`kind='summary' AND relay_key LIKE '%failure%'` = **98 行**(pi 该数我复核成立); 但**逐前缀拆开**:\n model-failure × 36 ← `model-failure:` (第 1 段即标记)\n service-failure × 25 ← `service-failure:` (第 1 段即标记,**deploy 脚本产生**)\n zcode-failure × 21 ← `zcode-failure:` (第 1 段即标记)\n homeagent × 16 ← ★ `homeagent:failure:` (★ 标记在**第 2 段**!)\n empty-reply × 0 ← 代码 1 处、**库里 0 行**(首次触发即静默漏判,pi 也提到)\n ★ 且 `homeagent:` 共 18 行 ⇒ 另 2 行是 `homeagent:`(**无** failure)⇒ 同一前缀两种语义\n⇒ ★★ 所以 pi §三 的 `parseLegacyPrefix(relay_key)` 不是\"认一个前缀\",而是**认五种形态**,\n 其中一种标记还在第二段 ⇒ \"第 6 家桥若还拼前缀 ⇒ 走 legacy 且服务端能数出来\"这句**低估了**:\n **不是\"第 6 家才漏\",是\"前 5 家里已有 1 家的形态不同\"** ⇒ legacy 解析器**今天就得**处理异形。\n```\n### 三 ★ 于是对 pi 的第三选项(`relay_meta` 结构化字段)我的结论\n```\n· ✅ **方向对**: 且\"不改 `TestRelayKindsIsExactlyTwo`\"这个理由是**强的**(该判据锁\"免配额集合\",\n 失败报告本就 `relay:'summary'` 已免配额 ⇒ 动它反而削弱警惕性)——这点我同意 pi。\n· ⚠️ 但**成本被我上面两条抬高**: 不是改 11 处,而是**6 个产生者 / 4 种语言**,\n 且**deploy 脚本与 Go 桥各是一个**(不在常规\"插件\"心智模型里)。\n· ⚠️ **且 legacy 解析必须今天就写五种形态**(不是\"为了未来的第 6 家\")。\n· ★ 因此我的建议(比 pi §四 更保守): **先做 backfill 与\"可观测\",不急着加 wire 字段** ——\n `is_failure` 可以**先纯服务端**由**显式的 5 形态白名单**推出,并**同时记一条计数**\n (有多少条既非 5 形态、又含 failure 字样 ⇒ 将来新形态会**显形**而不是静默漏判)。\n ⇒ 这样**零桥改动**就能拿到\"可信度 + 可观测\",`relay_meta` 留作**第二步**(等新桥自然铺开)。\n```\n" + "note": "★★ 2026-09-25 pi 提出(`a948cdbb`)、我复核确认并登记。**这条钉的是机制,不是当下恰好成立。**\n\n## 当前为什么「看起来不需要它」\n```\nB 口径(agent, failure-父链根): 98 封 → 92 组、抑制 **6**\n 组内「两个成员共享同一父」的组数 = **0**(我逐组实测)\n ⇒ 5 个多成员组**全是真链式**(成员互为父子链),今天确实不会误合并\n```\n★ 但那是**当前数据的性质,不是设计的保证** —— 只要出现「同一封来信被两个不同 agent 各回一封失败报告」(或同一 agent 对同一封回两次),root-keying 就会把它们合并掉,而那些是**内容各异的并列失败**。\n\n## 这形状在本系统**真实发生过**(所以不是假想)\n```\nsession d042cc4c: 22 封失败报告、**22 个 parent 两两不同**\n (agent, 线程根) 口径 ⇒ 塌成 4 组(9/7/5/1)⇒ 一次**丢 18 封**\n 其中只有 1 个 parent 本身是失败报告 ⇒ **21 封是并列**\n```\n⇒ 「同级并列」不是边角情况。判据② 定稿即按此: **22 封并列失败、(线程根误用下)塌成 4 组、一次丢 18**。\n\n## 与已否掉的修法的关系(避免下一个人重走)\n```\n✗ 修法① 跳过 kind=summary —— 实测恰好豁免它要拦的那一类(该环 100% summary)\n✗ 修法②(仅根 root-keying) —— 就是本条要防的那个: 会吞并列\n△ relay_key 前缀判「是否失败报告」—— 诊断成立,但**服务端语义变更**,待人或宿主定\n```\n⇒ 本条不预设修法;它只要求: **无论选哪种,并列失败不得被合并**必须有判据。\n★ 这就是它该进登记而不是只留在邮件里的原因: 一个没有执行者的结论会一直「在讨论」,而登记 + 到期条件会让接手的人**必须**遇到它。\n\n## ★★ 2026-09-25 追加(我实测):本条的**前置**问题 —— \"是不是失败报告\"这个谓词**没有载体**\n\n在讨论\"用哪种 keying 抑制\"之前,先要回答\"**这封是不是失败报告**\"。我把它的**全部产生点**查了一遍 —— 它散在 **8 条语句 / 5 个文件 / 3 种语言**,且**写法互不相同**:\n```\nplugins/dsh-mail-bridge/src/index.ts:1240 model-failure:${data.mail_id}\nplugins/dsh-mail-bridge/src/index.ts:1749 **empty-reply**:${mailSessionID}\nplugins/pi-mail-bridge/src/worker.mjs:625 model-failure:${...}\nplugins/pi-mail-bridge/src/worker.mjs:715 model-failure:${...}\nplugins/zcode-mail-bridge/src/index.mjs:304 zcode-failure:${...}\nplugins/homeagent-mail-bridge/plugin.go:929 \"homeagent:failure:\"+replyTo ← Go\ndeploy/service-failure-notify.mjs:92/94 service-failure:${INVOCATION_ID|sha256} ← systemd 脚本\n```\n而**网关侧对它一无所知**: `grep -c` 这四家前缀 + empty-reply 于 `server/**/*.go` = **0**。\n\n★ 所以\"拿现成的 `RelayKeyForMail` 判一下就行\"只对一半 —— 它返回 `(relay_key, kind)`,而 **`kind` 区分不了失败**:\n```\nfailure 行的 kind 分布: kind=**summary** | 98 ← 全是 summary\nkind='summary' 内部: failure 98 / **非failure 321**\n⇒ 用 kind 判 ⇒ 会连 321 行普通搬运一起豁免 —— **正是已否掉的修法①**\n```\n\n★★ **活的反例**(这条最关键): `empty-reply:` 是失败类但**不含 `failure` 字样** ⇒ `LIKE '%failure%'` **永远抓不到它**(该路径代码 1 处、当前 **0 行** ⇒ **将来第一次触发就是静默漏判**)。\n\n⇒ 推论: 若在网关里硬编码这四家前缀,第 6 家桥出现时**静默漏判**,后果是\"报告不再被抑制\" ⇒ 环回来(**假绿**)。\n⇒ 所以修法应先把**分类变成网关拥有的东西**(relay 枚举加第三类 / 或 relayed_mails 加一列),再由各桥**声明**而非拼串。\n\n⚠ 加枚举会撞上一条**故意**的锁: `TestRelayKindsIsExactlyTwo`(`relay_test.go:60-64`)\"免配额类型是白名单…新增前请确认它确实是 harness 代劳\" ⇒ 必须同步改它,而那正是它**本来就该问**的问题(failure 确实是 harness 代劳)。\n\n## ★★ 2026-09-25 追加(判据清单落定):⑥ permission 分叉点,**必须**钉住\n\npi `8f6f6e6e` 提出、我 `476d22ad` 复核后定为判据清单的第六条。\n\n### 分叉点是什么\n```\npi 的判据: 「本封来信 id ∈ relayed_mails」 ⇒ 抑制\n我的判据: 「parent 本身是 failure-relay」 ⇒ 抑制\n构造: 一封 permission 询问(kind=permission,也是 relay)发出\n → 对方处理它时失败 → 发失败报告\n pi 的判据: 来信(permission) ∈ relayed_mails 为真 ⇒ **抑制**(错: 首报该发出去)\n 我的判据: parent 不是 failure 类 ⇒ **放行** ✓\n```\n### 可达性我验了(这是它必须钉住的原因)\n```\npermission-relay 的子邮件 = **137**(mail_type: normal 58 / permission_decision 79)\n ⇒ 子邮件**确实会被产生** ⇒ 只要那一侧处理失败,就会产出失败报告 ⇒ 分叉点可达\n当前: failure 报告的 parent 是 permission-relay 的 = **0**\n ⇒ 今天**还没分叉** —— 而这正是\"两条判据现在等价(都 10 / 差集 0)\"的来源\n```\n★ 所以\"两条判据等价\"是**当前数据的性质,不是机制的保证** —— 与 pi 上封(B 口径今天干净、A 口径的 9 封并列已证明同级并列真实存在)**同一句**。\n⇒ 选判据的理由**不能是\"它们现在等价\"**,而是: **我的判据把\"是不是失败类\"这件事读了出来,pi 的没有** —— 信息量更大的一点更耐久。\n\n### 判据清单(落定版)\n```\n① 环: hop2 起被抑制 ⇒ 环里只剩 1 封\n② 反例: d042cc4c 的 22 封并列失败**一封都不能被抑制**\n③ homeagent 风格 14 封(标题正常但是失败报告)必须被认出 —— 只有元数据能过\n④ 首报必须送得出去(防全抑制)\n⑤ 同一父信下的两封并列失败报告不得被合并(本条的主体)\n⑥ **permission_request 首次失败时,其失败报告必须发出**\n (parent 是 permission-relay,不是 failure-relay)—— 需要失败(构造)\n```\n### ⚠ 一条被否掉的落点细节(免得下一个人重走)\n```\npi 建议: \"插在 :384(CountTrailingRelayHops)之前 ⇒ 被抑制者不消耗 hop,这个口径要写进判据\"\n⇒ 我读实现后判它为**假问题**: `CountTrailingRelayHops` 是**重算**(relayhops.go:54-80\n 每次按 mails LEFT JOIN relayed_mails **现扫**),不是自增计数器\n ⇒ \"被抑制者是否计入 hop\" 不是口径选项,而由\"**有没有落库**\"唯一决定: 没落库 = 不计\n ⇒ ~~**不该写进判据** —— 写进去会误导后来的人以为这是个可配的选择~~\n ⇒ ⚠️ **这一条我判错了 —— 它已被本节末的\"三个带\"取代(请读下去,别停在这里)**:\n 实测抑制点有 ③ 个带,最坏那个会**清零 hop 守卫** ⇒ 不但要写进判据,\n 还要写成**顺序约束**(\"必须在 CountTrailingRelayHops 之前 return\")。\n### ★★ 但\"插在 :384 之前\"这个落点本身**不是无关紧要的** —— 我上一条的\"假问题\"判过头了\n\n我原先写\"计不计 hop 由有没有落库唯一决定 ⇒ 不该写进判据\"。**那半对,但漏了两个形** ——实测(`/tmp/hop.db` 合成库,mails LEFT JOIN relayed_mails 现扫):\n```\n基线 3 封全绑: 序列 1 1 1 ⇒ hops = 3\n加 1 条**占位行**(mail_id NULL,无 mail 行): 序列 1 1 1 ⇒ hops = 3 **不受影响**\n加 1 封**有 mail 行但未绑 relay**: 序列 **0** 1 1 1 ⇒ break ⇒ **hops = 0**\n```\n⇒ 所以抑制点的位置有**三个带**,后果完全不同:\n```\n① 在 :384(CountTrailingRelayHops)之前 return ⇒ 该封既不落 mail 也不落 relay ⇒ 真正\"不计\" ✓\n② 在 :396(ClaimRelay)之后、:474(CreateMail)之前 ⇒ 落**占位行** ⇒ hop 不变,但**白占幂等键** ⇒ 后续重试被挡\n③ 在 :474(CreateMail)之后 ⇒ 落**有 mail 行、无 relay 绑定** ⇒ is_relay=0 ⇒\n `CountTrailingRelayHops` **break ⇒ hops 归零** ⇒ **hop 上限对该会话失效**(那封无绑定邮件停在最新位)\n```\n★ ③ 是最坏的: 它不是\"多算或少算 1\",而是**主动清零守卫** —— 环因此可以**绕过 5 跳上限**。\n而 ③ 正是\"抑制逻辑写在 CreateMail 之后\"这种最自然的写法会落进去的带(因为那里才拿到 mailID)。\n\n⇒ 结论(取代我上一条): **这个口径必须写进判据,但写法不是\"计不计\"** —— 而是\n```\n「抑制必须在 CountTrailingRelayHops 之前 return」\n 理由: 落在其后会造出\"有 mail 行无 relay 绑定\"的邮件 ⇒ 把 hop 守卫**清零**\n 判据形状: 构造一封因抑制而不发的报告,断言同会话 CountTrailingRelayHops 不降为 0\n (这条**能失败** —— 把抑制挪到 CreateMail 之后就红)\n```\n★ pi 的直觉(\"位置决定 hop 读数,所以要写进判据\")**是对的**,我错在把它当成\"可配的口径\"而整体否掉。正确的是: 它不是口径,是**必须满足的顺序约束**,而顺序约束**更要**进判据。\n```\n\n## ★★★ 补记之六(2026-09-26 我实测:回答 pi `5b0bbc33` 的\"桥侧成本\"之问 —— 并给出**它清单里漏掉的第 5/6 个产生者**)\n```\npi `5b0bbc33`(01:16 悬置未答)问: \"wire 上加可选字段这条路**桥侧成本多大** —— 若你那边能查一处桥的\npayload 构造,这个成本就清楚了\"。我实测答复如下。\n```\n### 一 ★ 成本不是\"11 处\"—— 是 **6 个产生者、4 种语言、且失败前缀有 5 种形态**\n```\npi 的清单: \"relay_key 产生点 10 处(含 dist 与 opencode)\",我按**排除 dist/node_modules/测试**重数:\n `relay: ''` 构造点 = **11 处 / 4 个包**(dsh 3 / opencode 3 / pi 3 / zcode 2)\n并**不完整** —— 我另外找到 **两个不在 plugins/ 清单里的产生者**:\n ★ ① `deploy/service-failure-notify.mjs:155-156`(**独立 systemd 脚本,非插件**)\n `relay: 'summary'` + `relay_key: service-failure:`\n ⇒ ★ **库里有 25 行**(`service-failure` × 25、全部 mail_id 已绑定)⇒ **真实在使用**\n ★ ② `plugins/homeagent-mail-bridge/`(**Go 写的桥**)`plugin.go:900 sendMailRelay` + `relay_key.go ClampRelayKey`\n ⇒ 库里 `homeagent:` **18 行**(其中 16 行含 failure)\n ⇒ 语言分布: **Go / TS / MJS / JS** 四种(.go 10 处、.ts 13、.mjs 26、.js 49 含 dist 与测试)\n```\n### 二 ★★★ 而\"失败前缀\"根本不是一种形态 —— 我实测库里**五种**并存\n```\n`kind='summary' AND relay_key LIKE '%failure%'` = **98 行**(pi 该数我复核成立); 但**逐前缀拆开**:\n model-failure × 36 ← `model-failure:` (第 1 段即标记)\n service-failure × 25 ← `service-failure:` (第 1 段即标记,**deploy 脚本产生**)\n zcode-failure × 21 ← `zcode-failure:` (第 1 段即标记)\n homeagent × 16 ← ★ `homeagent:failure:` (★ 标记在**第 2 段**!)\n empty-reply × 0 ← 代码 1 处、**库里 0 行**(首次触发即静默漏判,pi 也提到)\n ★ 且 `homeagent:` 共 18 行 ⇒ 另 2 行是 `homeagent:`(**无** failure)⇒ 同一前缀两种语义\n⇒ ★★ 所以 pi §三 的 `parseLegacyPrefix(relay_key)` 不是\"认一个前缀\",而是**认五种形态**,\n 其中一种标记还在第二段 ⇒ \"第 6 家桥若还拼前缀 ⇒ 走 legacy 且服务端能数出来\"这句**低估了**:\n **不是\"第 6 家才漏\",是\"前 5 家里已有 1 家的形态不同\"** ⇒ legacy 解析器**今天就得**处理异形。\n```\n### 三 ★ 于是对 pi 的第三选项(`relay_meta` 结构化字段)我的结论\n```\n· ✅ **方向对**: 且\"不改 `TestRelayKindsIsExactlyTwo`\"这个理由是**强的**(该判据锁\"免配额集合\",\n 失败报告本就 `relay:'summary'` 已免配额 ⇒ 动它反而削弱警惕性)——这点我同意 pi。\n· ⚠️ 但**成本被我上面两条抬高**: 不是改 11 处,而是**6 个产生者 / 4 种语言**,\n 且**deploy 脚本与 Go 桥各是一个**(不在常规\"插件\"心智模型里)。\n· ⚠️ **且 legacy 解析必须今天就写五种形态**(不是\"为了未来的第 6 家\")。\n· ★ 因此我的建议(比 pi §四 更保守): **先做 backfill 与\"可观测\",不急着加 wire 字段** ——\n `is_failure` 可以**先纯服务端**由**显式的 5 形态白名单**推出,并**同时记一条计数**\n (有多少条既非 5 形态、又含 failure 字样 ⇒ 将来新形态会**显形**而不是静默漏判)。\n ⇒ 这样**零桥改动**就能拿到\"可信度 + 可观测\",`relay_meta` 留作**第二步**(等新桥自然铺开)。\n```\n\n## ★★★★★ 补记之七(2026-09-30):五跳上限**从推断升级为物证** —— 并找到一个**双方都没引过的对照实验**\n```\n ⚠️ 先说已记的: 09-25 我记过 f76025c9 那条环\"到第 5 跳为止,与 maxRelayHops=5 逐值吻合\"(API.md:3651)。\n 当时是**旁证**(数据形状 + 源码常量)。本补记把它升级为**物证**,并补三件此前没有的东西。\n```\n### 一 ★★★★★ 物证:403 的**字节数**唯一指认了是哪道守卫\n```\n 在场: session 159c5f3e(人类 jianf 构造的 [深链] 实验)\n 03:18:57 两次 \"POST /api/v1/mail/send ... - 403 245B\"\n 判据: 把全仓所有 StatusForbidden 的 fmt.Sprintf 文本代入 %d,算\"{\\\"error\\\":\\\"…\\\"}\\n\" 的字节数:\n ⇒ 全仓 403 响应体长度谱: 14/20/31/34/40/42/43/46/49/55/109/158/184/**245**/355\n ⇒ **245B 在这个仓里唯一对应 `server/internal/handler/mail.go:436`** —— 正是 relay-hop 守卫\n (旁系: mail.go:417 的 ping-pong 守卫 = **355B**,代入 hops=8 也是 355B)\n ⇒ 该时刻两侧的 hop 读数(我复算 CountTrailingRelayHops):\n 159c5f3e([深链]) = **5** ← 满 ⇒ 该 403\n a47f3f29(同分钟在活跃的另一会话) = **0** ⇒ 竞争解释被排除\n ⇒ ⇒ ★★★ 结论: **不是\"形状像\",是\"响应体长度指纹唯一定位到那行\"**。\n 记法: 当守卫**不写日志只回 403** 时,\"响应体字节数\"是一个**可用的指纹** ——\n 前提是先把该文件里所有 forbidden 文案的长度谱打出来,证明它唯一。\n```\n### 二 ★★★★★ 对照实验: `[深链]` 会话 —— 人类灌 80 封深链,插件的 relay 回复**每段恰好停在 5**\n```\n 构造: jianf 于 09-28 02:47:10-13 **一秒内**发出 `[深链] L1 … L80`(80 封,**全部非 relay**)\n 观测(我实测该会话全部邮件):\n 03:07:33 R h=1 pi→jianf Re: [深链测试] 起点\n 03:09:15 R h=2 pi→jianf Re: [深链] L1\n 03:10:19 R h=3 pi→jianf Re: [深链] L2\n 03:13:40 R h=4 pi→jianf Re: [深链] L3\n 03:15:24 R h=5 pi→jianf Re: [深链] L4\n ---- 03:18:57 **403 ×2**(正是上面那条物证)⇒ L5、L6 被拒 ----\n 03:22:17 . h=0 opencode 的**非 relay** 邮件 ⇒ 计数归零\n 03:22:20 R h=1 pi→jianf Re: [深链] L59\n … 每 7~8 秒一封 …\n 03:22:52 R h=5 pi→jianf Re: [深链] L63\n ---- 再满 5 ⇒ 再拒 ----\n ⇒ ★ 两段**都恰好 5**;若 80 封都能回,应有 80 封,实际只有 **10** ⇒ 被截 **70** 封\n ⇒ ★ 截断点**不是随机**,而是每段第 5 封之后 ⇒ 硬上限而非丢弃/超时\n ⇒ ★★★ 这个实验**双方都没引过**(我 09-25 引的是 f76025c9 那条自然发生的环,\n 不是这条**受控**的)—— 受控版更强: 灌入量已知(80)、回复量可数(10)、\n 且**拒绝时刻有物证**(03:18:57 两次 403)\n```\n### 三 ★★★★★ 群体级证据: 段长分布**在 5 处上跳**,而\"上跳\"是硬上限的判据(不需要拟合任何参数)\n```\n 口径: 把一个会话的邮件按 created_at 排序,取\"连续 relay\"的极大段长;全库 461 段\n 段长: 1→392 2→42 3→8 4→**4** 5→**15**\n ⇒ ★★★ 关键: **4 段 → 5 段是\"不降反升\"**(4 → 15)。\n 任何\"随时间自然衰减\"的模型(几何/指数)都要求段数**单调不增**;\n 在**最后一个可取值**上翘,只能由\"**到 5 就被砍**\"解释 ⇒ 不需要估 p、不需要零模型。\n ⇒ 另一个口径(连续**失败通知**段)同形: 1→47 2→4 3→3 4→**1** 5→**9**(同样 1→9 上翘)\n ⇒ ⇒ ★★ 且全库**没有任何一段 ≥6**(两种口径都是 max=5)。\n ⚠️ 我先前用\"几何拟合\"算过 15 vs 预测 1.6,那个**依赖拟合的 p**,不如上面这条干净 —— 以本条为准。\n ⚠️ 混淆已查: 15 段中 **4 段**来自标题带测试标记的会话([压测]/[深链]),\n 即**人为构造**;但**其余 11 段**是生产自然发生的(失败报告、服务异常终止)⇒ 结论不依赖测试数据。\n```\n### 四 ★★★★ 设计不对称(这条最可能有后续后果): relay-hop 守卫**没有**\"人类在回路里\"豁免\n```\n 实测同文件两处:\n :415 ping-pong 守卫 `isHuman, _ := repo.IsHumanUser(...)` ⇒ **有**豁免(收件方是人就不算回路)\n :428 relay-hop 守卫 —— 该块内**无任何 isHuman 判断**(我逐行核过)\n ⇒ 所以 [深链] 那 5 封的收件人**正是人类 jianf**,照样被 hop 守卫拦下 ✓\n ⇒ ★★ 后果: 失败报告走 `relay:'summary'`(dsh:1362 等),因此**故障报告也计入这 5 跳**;\n 一个会话里连续 5 封 relay 之后,**连\"发给人\"的故障报告也发不出去**。\n ⇒ 这不是 bug —— `relayhops.go:35-37` 明写\"故障报告这类**必须**走 relay 的邮件也需要受约束\",\n 是**设计者写下的决定**。但\"该守卫对人类收件人同样生效\"这一点此前没人写下过,\n 而它正是\"人在回路里\"直觉会误判的地方(ping-pong 那道有豁免,容易以为这道也有)。\n```\n### 五 ★ 回答 pi `54fbf46c` §三 的\"观测要给两个数\" —— 我给的第二个数**不是** 11、也不是 18\n```\n pi 建议: 报\"形状总数 + 其中父∈failure-relay\",并把后者算作 **11**(口径: subject 含'处理失败')\n 实测(我,09-30): 形状 **380**;按父的 relay_key 判 ⇒ **18**;按 subject 判 ⇒ **11**;两者**只交 2**\n ⇒ 它的 11 是**代理谓词**的数(我 09-25 已指出,见 API.md:5098);权威谓词是 18\n ⇒ ★★★ 但**两个数都不该进观测** —— 我把三个数放到时间轴上:\n```\n```\n 时刻 形状 ①父fail ②自己fail ①∧② ①∧¬② ¬①∧②\n 2026-09-20 371 15 11 2 13 9\n 2026-09-25 07:30 377 15 11 2 13 9\n 2026-09-26 377 15 11 2 13 9\n 2026-09-28 377 15 11 2 13 9\n 2026-09-30 380 18 11 2 16 9\n ⇒ ★★★ **只有 `①∧②`(父∈failure-relay **且** 自己是失败报告)= 2 恒定**;\n 另两个都被**正常流量**推着涨(18 涨是因为\"回复失败通知\"是正常行为,本身不是缺陷)\n ⇒ ⇒ 记法: **观测该盯的不是\"当前有多大\",而是\"不随正常流量增长\"的那个子集** ——\n 一个随流量增长的计数**无法区分**\"缺陷变多\"与\"会话变多\",所以它做不了观测。\n ⚠️ 而 `①∧②` 恰好 = 2、**不为 0** ⇒ \"今天 0 例\"这个说法(我 09-25 说的)也不准;\n 准确说法是 **\"今天 2 例,且两周内不增\"**。\n```\n### 六、与我 09-25 那条的关系(免得下一个人以为两条矛盾)\n```\n 09-25 我记: \"上限正在掐它(旁证),我撤回'上限制造了故障'\"\n 本补记 : 同一结论,**证据升级**(403 字节指纹 + [深链] 受控实验 + 分布上跳 + 无人类豁免)\n ⇒ 一条**推翻的唯一路径**: 若 maxRelayHops 改成 0/不生效,则 §三 的\"5 处上跳\"会消失、\n §二 的 03:18:57 403 不再出现 ⇒ 本补记**可判**,不是解释性文字。\n```\n" }, { "id": "platform-mirror-replace-domain-too-wide",