句柄归属限当前会话 + 补上习得贴纸的开关命令 #107

Merged
Yushu merged 2 commits from fix/102-handle-ownership-and-sticker-toggle into main 2026-07-30 12:53:41 +00:00
Member

Closes #102

两条都是全 ADR 查漏那 8 条「高紧迫」里从分类里掉出去的(既没进任何票、也没进任何清单)。

1. self_recall/edit 可用 A 群的句柄操作 B 群的消息

chat 作用域出自 ADR-0012 §2 触发(「撤/改近期已发消息(限当前会话 + 平台撤回窗口)」);§6 治理的「硬约束(结构不变量)」给的是整组约束的 fail-closed 精神(「句柄必须解析,否则工具失败不动(fail-closed:误撤比不撤更糟)」)。而这两个函数拿到 entry从不比较 entry.chat_id 与本轮 chat_id —— 查漏方已实测「在 gB 群用 gA 群的句柄撤回成功」。

风险不在「句柄被猜到」(uuid4 前 12 位跨会话猜不到),而在模型记错归属:同一模型在多个 chat 间轮转,句柄形如 m7 的短标识极易串。

既有测试为什么抓不到test_self_message_control.py 全部用例都在单会话内 —— 典型的「测试与被测守卫同构,守卫不存在也全绿」。新增 test_handle_ownership.py,每条用例都跨两个 chat(那是唯一能触达这道闸的形状),并额外断言状态也没被改(否则 A 群那条会变成「已撤回」,之后 A 群自己想撤就撤不了了)。

闸放在能力位分叉之前:无原生 edit 能力时这条路降级成「撤回 + 重发」,第一步就是 egress.recall —— 放在分叉之后等于给降级路径开后门。有单独用例钉住这个顺序,代码现场也写了这句取舍。

范围:§2 那句里的「平台撤回窗口」那一半不在本票 —— ADR-0012 §7 本来就把 Sent Log 定义成带 time 字段,而 SentLogEntry 至今没有它,补它属第四批(#103)。常量 docstring 里写明了。同族还有一处未闭合也一并记下send_messagequote_message_id 是模型直给的平台 id,不经 Sent Log、没有 chat_id 可比,这道闸射程外。

2. 习得贴纸的 opt-in 开关根本没有命令可以打开

ADR-0024 决策第 4 条逐字:「默认关闭(opt-in),per-chat 开关:心智模型同既有 /settool(CONTEXT.md 管理命令)」。set_sticker_learning_enabled 在 issue #70 就建好了,但 src/零调用点 —— 于是识别 → 去重 → 人设漂移守护 → 落库整条链永不触发search_stickers 恒查空库。249 行 learned_sticker.py + 一张表 + 一个 Qdrant collection + 工具 schema + 一整套测试,产出为零。 那份文档把跟进挂在「ADR-0010 运营面建好后」,而运营面在 #83 就宣布全部落地了,只是没人回来接这根线。

补上 /setsticker on|off(第八条管理命令,走 ports.is_admin)。issue #88 建的穷举守卫按预期变红了 —— 那正是它该做的;新命令因此自动获得了全套边界用例(force_whitespace、粘连后缀等)。

端到端断言走的是模型真正会用的那个出口 —— search_stickers 工具本身,不是存储里那个布尔、也不是存储层的检索方法。布尔为真而链路不通、或落库了但工具查不出来,这条用例都会红。

没有贴纸能力的平台上不假装开启(两轴 review 抓到的):_learn_stickers 第一道闸就是 capabilities.sticker,而 CapabilitySet 全字段默认 False —— 照写照回「好,开始学了」就是本仓反复点名的那个反模式(admin.py/subagent.py 都写着「命令就承诺了它没兑现的事」)。改成拒绝 + 说明 + 不写库(存一个恒不生效的 True 只会让下一个排查的人以为开关是好的);关闭方向不设这道闸 —— 能力位可能刚从真变假而记录还留着。

顺带把 on|off 抽成 admin.parse_on_off 给两条命令共用(对 /settool 行为等价),并配一条把两者钉在一起的守卫:分两份实现的话,「大小写不敏感是刻意的」这句取舍只写在一处,另一处很容易被写成大小写敏感,而那种差异要等管理员打了 ON 才暴露。

开启的回复如实说清在采集什么(他人发的内容、未经其同意,ADR-0024 明确拒绝了 opt-out),有断言钉住。

两轴 review 抓到的(第二个 commit)

  • 命令撒谎(见上,最重的一条)。
  • 第七次错引 ADR:我把 §2 的「限当前会话」、§6 的「结构不变量」、以及挂在「句柄必须解析」上的「误撤比不撤更糟」拼成了一句话。已按原文分开陈述。ADR-0024 那句我用引号呈现却改了标点、丢了「既有」,已改成逐字。
  • 计数又散在四处:只改了「七条→八条」,另有四处硬编码的「9 条/9 处」现在是 10。这次不是把数字加一,而是删掉数字 —— issue #88 已经因为同一件事修过一遍(Shotgun Surgery),继续填数字只是把下一次返工排上日程。
  • 半空转断言:非管理员那条与「参数不合法」那组原来断 is False,而 False 本就是默认值 —— 分不出「没写」和「写了 False」。改成先置 True 再打命令。
  • e2e 从存储层改成走真实工具;_native_edit_egress() 从文件底部 + 函数内 import 挪到顶部。

已知未做

  • 「平台撤回窗口」那一半(要 Sent Log 加 time 字段,属 #103 第四批)。
  • quote_message_id 的同族缺口(模型直给的平台 id,无从校验归属)。
  • docs 分支 CONTEXT.md 的管理命令清单需加 /setsticker —— 已随 ADR 回填一并做(docs 分支本地提交)。

验证

uv run pytest -n auto1537 passed(新增 36)。ruff check 全过。

变异复验(每次都先核实变异真写进文件):self_recall 不查归属 → 2 红;edit 不查归属 → 3 红;开关命令不写存储 → 2 红;开关命令不查权限 → 1 红;on/off 改成大小写敏感 → 3 红;去掉能力位拒绝 → 1 红;edit 归属闸挪到分叉之后 → 3 红。还原后各文件 md5 与备份一致。

Closes #102 两条都是全 ADR 查漏那 8 条「高紧迫」里**从分类里掉出去**的(既没进任何票、也没进任何清单)。 ## 1. `self_recall`/`edit` 可用 A 群的句柄操作 B 群的消息 chat 作用域出自 ADR-0012 §2 触发(「撤/改**近期已发**消息(限当前会话 + 平台撤回窗口)」);§6 治理的「硬约束(结构不变量)」给的是整组约束的 fail-closed 精神(「句柄必须解析,否则工具失败**不动**(fail-closed:误撤比不撤更糟)」)。而这两个函数拿到 `entry` 后**从不比较** `entry.chat_id` 与本轮 `chat_id` —— 查漏方已实测「在 gB 群用 gA 群的句柄撤回成功」。 风险不在「句柄被猜到」(uuid4 前 12 位跨会话猜不到),而在**模型记错归属**:同一模型在多个 chat 间轮转,句柄形如 `m7` 的短标识极易串。 **既有测试为什么抓不到**:`test_self_message_control.py` 全部用例都在单会话内 —— 典型的「测试与被测守卫同构,守卫不存在也全绿」。新增 `test_handle_ownership.py`,每条用例都**跨两个 chat**(那是唯一能触达这道闸的形状),并额外断言**状态也没被改**(否则 A 群那条会变成「已撤回」,之后 A 群自己想撤就撤不了了)。 **闸放在能力位分叉之前**:无原生 edit 能力时这条路降级成「撤回 + 重发」,第一步就是 `egress.recall` —— 放在分叉之后等于给降级路径开后门。有单独用例钉住这个顺序,代码现场也写了这句取舍。 **范围**:§2 那句里的「平台撤回窗口」那一半不在本票 —— ADR-0012 §7 本来就把 Sent Log 定义成带 `time` 字段,而 `SentLogEntry` 至今没有它,补它属第四批(#103)。常量 docstring 里写明了。**同族还有一处未闭合也一并记下**:`send_message` 的 `quote_message_id` 是模型直给的平台 id,不经 Sent Log、没有 `chat_id` 可比,这道闸射程外。 ## 2. 习得贴纸的 opt-in 开关根本没有命令可以打开 ADR-0024 决策第 4 条逐字:「**默认关闭(opt-in),per-chat 开关**:心智模型同既有 `/settool`(CONTEXT.md 管理命令)」。`set_sticker_learning_enabled` 在 issue #70 就建好了,但 `src/` 里**零调用点** —— 于是识别 → 去重 → 人设漂移守护 → 落库整条链**永不触发**、`search_stickers` 恒查空库。**249 行 `learned_sticker.py` + 一张表 + 一个 Qdrant collection + 工具 schema + 一整套测试,产出为零。** 那份文档把跟进挂在「ADR-0010 运营面建好后」,而运营面在 #83 就宣布全部落地了,只是没人回来接这根线。 补上 `/setsticker on|off`(第八条管理命令,走 `ports.is_admin`)。issue #88 建的**穷举守卫按预期变红**了 —— 那正是它该做的;新命令因此自动获得了全套边界用例(`force_whitespace`、粘连后缀等)。 **端到端断言走的是模型真正会用的那个出口** —— `search_stickers` **工具本身**,不是存储里那个布尔、也不是存储层的检索方法。布尔为真而链路不通、或落库了但工具查不出来,这条用例都会红。 **没有贴纸能力的平台上不假装开启**(两轴 review 抓到的):`_learn_stickers` 第一道闸就是 `capabilities.sticker`,而 `CapabilitySet` 全字段默认 `False` —— 照写照回「好,开始学了」就是本仓反复点名的那个反模式(`admin.py`/`subagent.py` 都写着「命令就承诺了它没兑现的事」)。改成拒绝 + 说明 + **不写库**(存一个恒不生效的 `True` 只会让下一个排查的人以为开关是好的);**关闭方向不设这道闸** —— 能力位可能刚从真变假而记录还留着。 顺带把 `on|off` 抽成 `admin.parse_on_off` 给两条命令共用(对 `/settool` 行为等价),并配一条把两者钉在一起的守卫:分两份实现的话,「大小写不敏感是刻意的」这句取舍只写在一处,另一处很容易被写成大小写敏感,而那种差异要等管理员打了 `ON` 才暴露。 开启的回复如实说清在采集什么(**他人**发的内容、未经其同意,ADR-0024 明确拒绝了 opt-out),有断言钉住。 ## 两轴 review 抓到的(第二个 commit) - **命令撒谎**(见上,最重的一条)。 - **第七次错引 ADR**:我把 §2 的「限当前会话」、§6 的「结构不变量」、以及挂在「句柄必须解析」上的「误撤比不撤更糟」拼成了一句话。已按原文分开陈述。ADR-0024 那句我用引号呈现却改了标点、丢了「既有」,已改成逐字。 - **计数又散在四处**:只改了「七条→八条」,另有四处硬编码的「9 条/9 处」现在是 10。**这次不是把数字加一,而是删掉数字** —— issue #88 已经因为同一件事修过一遍(Shotgun Surgery),继续填数字只是把下一次返工排上日程。 - **半空转断言**:非管理员那条与「参数不合法」那组原来断 `is False`,而 `False` 本就是默认值 —— 分不出「没写」和「写了 False」。改成先置 `True` 再打命令。 - e2e 从存储层改成走真实工具;`_native_edit_egress()` 从文件底部 + 函数内 import 挪到顶部。 ## 已知未做 - 「平台撤回窗口」那一半(要 Sent Log 加 `time` 字段,属 #103 第四批)。 - `quote_message_id` 的同族缺口(模型直给的平台 id,无从校验归属)。 - docs 分支 CONTEXT.md 的管理命令清单需加 `/setsticker` —— 已随 ADR 回填一并做(docs 分支本地提交)。 ## 验证 `uv run pytest -n auto` → **1537 passed**(新增 36)。`ruff check` 全过。 变异复验(每次都先核实变异真写进文件):self_recall 不查归属 → 2 红;edit 不查归属 → 3 红;开关命令不写存储 → 2 红;开关命令不查权限 → 1 红;on/off 改成大小写敏感 → 3 红;去掉能力位拒绝 → 1 红;edit 归属闸挪到分叉之后 → 3 红。还原后各文件 md5 与备份一致。
两条都是全 ADR 查漏那 8 条「高紧迫」里**从分类里掉出去**的(既没进任何票也没进任何
清单)。

## 1. `self_recall`/`edit` 可用 A 群的句柄操作 B 群的消息

ADR-0012「撤/改限当前会话」把这条称作**结构不变量**,理由是"**误撤比不撤更糟**"。
而这两个函数拿到 `entry` 后**从不比较** `entry.chat_id` 与本轮 `chat_id`——查漏方已
实测"在 gB 群用 gA 群的句柄撤回成功"。

风险不在"句柄被猜到"(uuid4 前 12 位跨会话猜不到),而在**模型记错归属**:同一模型
在多个 chat 间轮转,句柄形如 `m7` 的短标识极易串。

既有测试抓不到,是因为 `test_self_message_control.py` **全部用例都在单会话内**——
典型的"测试与被测守卫同构,守卫不存在也全绿"。新增 `test_handle_ownership.py`,每条
用例都跨两个 chat(那是唯一能触达这道闸的形状),并断言**状态也没被改**(否则 A 群
那条会变成"已撤回",之后 A 群自己想撤就撤不了了)。

edit 的校验放在**能力位分叉之前**:无原生 edit 能力时它降级成"撤回 + 重发",那条路
第一步就是 `egress.recall`,放在分叉之后等于给降级路径开个后门。有单独用例钉住。

**ADR 那句里的「平台撤回窗口」那一半不在本票**(要 Sent Log 加时间字段,属第四批
见 #103),常量 docstring 里写明了,别让下一个人以为整句 AC 已兑现。

## 2. 习得贴纸的 opt-in 开关根本没有命令可以打开

ADR-0024 写着"默认关闭(opt-in),per-chat 开关,心智模型同 `/settool`"。
`set_sticker_learning_enabled` 在 issue #70 就建好了,但 `src/` 里**零调用点**——于是
识别 → 去重 → 人设漂移守护 → 落库整条链**永不触发**、`search_stickers` 恒查空库。
249 行 `learned_sticker.py` + 一张表 + 一个 Qdrant collection + 工具 schema + 一整套
测试,**产出为零**。那份文档把跟进挂在"ADR-0010 运营面建好后",而运营面在 #83 就宣布
全部落地了,只是没人回来接这根线。

补上 `/setsticker on|off`(第八条管理命令,走 `ports.is_admin`)。`#92` 建的穷举守卫
按预期变红了——那正是它该做的;新命令因此自动获得了全套边界用例(`force_whitespace`、
粘连后缀等),一条没白写。

端到端断言按 AC 要求锁**最末端那个查询**(命令 → 识别 → 落库 → `search_stickers`
查得到),不是只看存储里那个布尔——布尔为真而链路不通的话那种断言分不出来。

顺带把 `on`/`off` 那条规则抽成 `admin.parse_on_off` 给两条命令共用,并配一条把两者
钉在一起的守卫:分成两份实现的话,"大小写不敏感是刻意的"这句取舍只写在一处,另一处
很容易被写成大小写敏感,而那种差异要等管理员打了 `ON` 才暴露。

开启的回复如实说清在采集什么(**他人**发的内容、未经其同意,ADR-0024 明确拒绝了
opt-out),别让管理员以为自己只是打开了一个无害的小功能。有断言钉住。

顺带订正计数:管理命令七条 → 八条(`__init__.py` 注册块注释、`_admin_gate` docstring、
`ports.py` 的 `is_admin` 说明);`learned_sticker.py` 那句"本片不实现那个命令本身"
现在不成立了,改成指向 `/setsticker` 并写明这条流水线在 #102 之前一次都没执行过。

变异复验:self_recall 不查归属 → 2 红;edit 不查归属 → 3 红;开关命令不写存储 → 2 红;
开关命令不查权限 → 1 红;on/off 改成大小写敏感 → 3 红。还原后三个文件 md5 均一致。
全量 1535 passed(新增 34)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 1. `/setsticker on` 在没有贴纸能力的平台上撒谎

`_learn_stickers` 第一道闸就是 `capabilities.sticker`,而 `CapabilitySet` 全字段默认
`False`——原实现照样写库并回"好,这个会话开始学群里的贴纸了",链路一次都不会跑。这正是
本仓反复点名的反模式(`admin.py`/`subagent.py` 都写着"命令就承诺了它没兑现的事"),
而 `/settool` 打错工具名当场拒绝、`/setmodel` host 不接受就如实说,都是同一取向的先例。

改成:开启方向若无 sticker 能力则**拒绝并说明**、**不写库**(存一个恒不生效的 `True`
只会让下一个排查的人以为开关是好的);**关闭方向不设这道闸**——能力位可能刚从真变假、
而那条记录还留着。两条用例钉住。

## 2. 第七次错引 ADR

`_FOREIGN_HANDLE_ERROR` 原文写"ADR-0012「撤/改限当前会话」把这条称作**结构不变量**,
理由是误撤比不撤更糟"——把三处拼成了一句。实际:§2 触发写"限当前会话 + 平台撤回窗口"
(chat 作用域出自这里);§6 治理的"硬约束(结构不变量)"那条写的是"只针对智能体
**自己的、窗口内**消息;句柄必须解析,否则工具失败不动(fail-closed:误撤比不撤更糟)"
——那句 fail-closed 精神管的是整组硬约束,不是专门为 chat 作用域写的。已按原文分开陈述。

顺带:ADR-0024 那句引文原文是"**默认关闭(opt-in),per-chat 开关**:心智模型同**既有**
`/settool`(CONTEXT.md 管理命令)",我用引号呈现却改了标点、丢了"既有"。已改成逐字。

**同族未闭合的一处也写进去了**:`send_message` 的 `quote_message_id` 是模型直给的平台
id,不经 Sent Log、没有 `chat_id` 可比,这道闸射程外(不在本票范围)。另外 ADR-0012 §7
本来就把 Sent Log 定义成带 `time` 字段,而 `SentLogEntry` 至今没有——"撤回窗口那一半要
加时间字段"这个说法因此有 ADR 原文支撑,不是我推测的。

## 3. 计数又散在四处

只改了"七条→八条",另有四处硬编码的"9 条/9 处"现在是 10。**这次不是把数字加一,而是
删掉数字**——issue #88 已经因为同一件事修过一遍(Shotgun Surgery:加一条命令要改六处
数字),继续填数字只是把下一次的返工排上日程。

## 4. 半空转断言与其它

- 非管理员那条与"参数不合法"那组原来断言 `is False`,而 `False` 本就是默认值——分不出
  "没写"和"写了 False"。改成先人为置 `True` 再打命令。
- e2e 原来断到存储层的 `search_learned_stickers`,而 AC 说的是 `search_stickers`。改成
  走**真实工具调用**并断言工具结果文本里有贴纸含义——落库了但工具查不出来现在也会红。
- `edit` 的归属闸补一句"必须在能力位分叉之前"的现场说明(那是最容易被重构掉的取舍,
  而代码现场原来一个字都没有)。
- `_native_edit_egress()` 从文件底部 + 函数内 import 挪到顶部。

变异复验:去掉能力位拒绝 → 1 红;edit 归属闸挪到分叉之后 → 3 红。还原后两文件 md5
一致。全量 1537 passed(新增 36)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit 0853ab76ef into main 2026-07-30 12:53:41 +00:00
Yushu deleted branch fix/102-handle-ownership-and-sticker-toggle 2026-07-30 12:53:41 +00:00
Sign in to join this conversation.
No description provided.