fix(shortcut): harden DING write routing evidence

This commit is contained in:
Dennis
2026-08-20 20:19:39 +08:00
parent 91974ce981
commit 03001eb4e0
6 changed files with 97 additions and 62 deletions
+51 -52
View File
@@ -5,74 +5,73 @@
> 对比基线:lark-cli 1.0.87
> 范围:Shortcut-only DING surface
## 1. 执行摘要
## 1. 最终 surface
- 源码 Shortcut:5;公开:1;unavailable:4。
- 读取严格化:PASS。`+list` 与 `+receiver-status` 只接受显式布尔成功、准确结果路径、正确集合类型和合法元素。
- 分页真实性:PASS(拒绝虚假成功)。`+list` 仅在 `hasMore` 与数值 `nextCursor` 一致且游标前进时发布续页信息;真实续页返回停滞游标,严格实现返回 `stalled_cursor`,因此该能力保持 unavailable。
- 真实读取:PASS。`+list` 已执行已知非空页和保证零项页;`+receiver-status` 已对列表中稳定身份执行精确非空查询。
- 写安全:PASS。3 个写 Shortcut 均需确认,确认前 0 次下游调用;因缺少可逆 fixture、精确接收人读回和零残留清理,确认后也由本地能力门禁停止。
- 脱敏:PASS。本文仅保留聚合数量与 PASS 标签,不保存 ID、标题、人名、正文、原始响应或请求追踪信息。
- 公开 `+receiver-status` fully unlocked;其余 4 条保持 fail-closed,不能把 DING 整体写效果称为完成。
- 读取严格化:PASS。只接受显式布尔成功、准确结果路径、正确集合类型、合法元素和稳定 DING 身份。
- 本地可修缺口:PASS。`app/sms/call` 已在原子 helper 映射为下游要求的 `APP/SMS/PHONE`,未知类型在调用前拒绝。
- 写门禁:PASS。3 个写 Shortcut 均需用户确认,且当前 capability gate 不发起远端调用;隔离诊断只通过精确 atomic/raw 路径执行。
## 2. 用户任务与 Lark 映射
## 2. Residual-gap 分类
| 用户任务 | DWS Shortcut | lark-cli 1.0.87 | 状态 |
| 能力 | 最终分类 | 归因 | Shortcut 状态 |
|---|---|---|---|
| 查询 DING 历史并取得稳定身份 | `ding +list` | 无直接任务 | unavailable;真实续页游标停滞 |
| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | PASS / public;平台身份模型不同 |
| 基于已有消息发送应用内加急 | `ding +send-by-message --type app` | `im messages urgent_app` | unavailable |
| 基于已有消息发送短信加急 | `ding +send-by-message --type sms` | `im messages urgent_sms` | unavailable |
| 基于已有消息发送电话加急 | `ding +send-by-message --type call` | `im messages urgent_phone` | unavailable |
| 以用户身份新建 DING | `ding +send-personal` | 无直接任务 | unavailable;DWS 产品原生机会 |
| 撤回用户身份 DING | `ding +recall-personal` | 无直接任务 | unavailable;DWS 产品原生机会 |
| `+receiver-status` | fully unlocked | exact 稳定 DING 身份读取返回 1 项且全部匹配 | public |
| `+list` | delivery hardened but downstream blocked | 真实 continuation 不前进并返回重复边界项;本地不能安全猜测跳游标或去重 | unavailable |
| `+send-personal --type app` | delivery hardened but downstream blocked | 稳定 DING 回执已证明;接收项没有稳定接收人身份,撤回无可查询终态 | unavailable |
| `+send-by-message --type app` | delivery hardened but downstream blocked | 源消息和 DING 稳定身份已证明;接收人与清理终态仍不可证明 | unavailable |
| `+send-personal/+send-by-message --type sms/call` | unavailable | 没有隔离号码、显式成本授权和通知残留核验 fixture,本轮未执行 | unavailable |
| `+recall-personal` | delivery hardened but downstream blocked | 撤回只返回布尔成功,不返回目标或终态;撤回后精确接收状态仍保留记录 | unavailable |
| Lark `urgent_app/sms/phone` 用户任务 | routed | 精确原子命令存在;仅在用户明确目标、提醒方式与成本后执行,不等于 Shortcut 已解锁 | 非公开 Shortcut 路线 |
## 3. 真实 E2E 聚合矩阵
## 3. Lark 用户任务映射
| Exact Shortcut | 已知非空 | 保证零项 | 身份/分页 | 写后读回 | 清理 | 结论 |
|---|---:|---:|---|---|---|---|
| `+list` | PASS(50) | PASS(0) | PASS(严格拒绝);续页游标停滞 | 不适用 | 不适用 | unavailable |
| `+receiver-status` | PASS(1) | 不适用:精确 ID 查询 | PASS:全部返回项身份匹配 | 不适用 | 不适用 | public |
| `+send-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable |
| `+send-by-message` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable |
| `+recall-personal` | 不适用 | 不适用 | 不适用 | BLOCKED | BLOCKED | unavailable |
| 用户任务 | DWS 路线 | lark-cli 1.0.87 | 结论 |
|---|---|---|---|
| 查询 DING 历史 | `ding +list` | 无直接任务 | delivery hardened but downstream blocked |
| 查询 DING 接收状态 | `ding +receiver-status` | `im messages read_users` | fully unlocked;平台身份模型不同 |
| 基于消息发送应用内加急 | `ding message send-by-message --type app` | `im messages urgent_app` | routed;Shortcut unavailable |
| 基于消息发送短信加急 | `ding message send-by-message --type sms` | `im messages urgent_sms` | routed,需成本授权;Shortcut unavailable |
| 基于消息发送电话加急 | `ding message send-by-message --type call` | `im messages urgent_phone` | routed,需成本授权;Shortcut unavailable |
| 新建个人 DING | `ding message send-personal` | 无直接任务 | routed;Shortcut unavailable |
| 撤回个人 DING | `ding message recall-personal` | 无直接任务 | routed;Shortcut unavailable |
## 4. 下游需求
## 4. Exact Shortcut 与隔离 raw 证据
### DS-DING-001 — 稳定写回执与接收人身份
| 能力 | Exact Shortcut | Atomic/raw 隔离事实 | 结论 |
|---|---|---|---|
| `+list` | 已知非空 50;保证零项 0;真实续页返回 `stalled_cursor` | continuation 页仍为 50 项、下一游标不前进、相邻稳定身份交集 1;尝试包含式边界后仍不前进 | downstream blocked |
| `+receiver-status` | 精确身份返回 1,全部 DING 身份匹配 | 集合为数组、业务成功为布尔值 | fully unlocked |
| `+send-personal` | capability gate,远端调用 0 | APP 自收件:稳定 DING 回执 1、接收状态 1、DING 身份匹配;稳定接收人身份字段 0 | downstream blocked |
| `+send-by-message` | capability gate,远端调用 0 | 自发源消息稳定任务/消息/会话身份均通过;APP DING 回执 1、接收状态 1;稳定接收人身份字段 0 | downstream blocked |
| `+recall-personal` | capability gate,远端调用 0 | 撤回业务成功为布尔值,但目标回执 0;撤回后精确接收状态仍为 1 | downstream blocked |
隔离 APP fixture 撤回后,`SEND`、`DELETED`、`ALL` 首屏均未出现目标;但 `+list` 的 continuation 已被证明停滞,首屏缺席不能升级为全局零残留证明。源聊天消息也在执行撤回后仍可按稳定身份读取,因此不能把“记录仍可查询”擅自解释为已删除或未删除。
## 5. 下游需求
### `DS-DING-001` — 稳定接收人身份读回
- 优先级:P1;类型:contract insufficient。
- 影响:`+send-personal`、`+send-by-message`。
- 需要明确布尔业务成功、稳定 `openDingId`、逐接收人稳定身份与受理状态;集合必须始终存在且类型固定,不能用空响应或仅 HTTP 成功表示业务成功。
- 写后读取必须能按同一 `openDingId` 返回稳定接收人身份,以精确比对请求集合,而不是仅返回显示名。
- 幂等键重复提交必须返回同一资源或稳定冲突语义,并标明是否已开始执行与是否可安全重试。
- 验收:确认前 0 调用;确认后写入一次;按稳定身份精确读回全部接收人;清理后再次读取证明无残留。
- 已关闭部分:`PASS-DING-WRITE-RECEIPT`,两种 APP 发送路径均返回唯一稳定 `openDingId`;提醒类型枚举映射已在本地修正。
- 剩余缺口:接收状态项只有 DING 身份、确认状态和显示名,没有可与请求 `openDingTalkId` 集合精确比对的稳定接收人身份。
- 验收:返回逐接收人的稳定身份和 typed 受理状态;多接收人集合与请求集合精确一致;缺项、重复项、额外项和错型均机器可检测。
### DS-DING-002 — 可验证撤回终态
### `DS-DING-002` — 可查询撤回终态
- 优先级:P1;类型:missing capability。
- 影响:`+recall-personal` 及两种发送能力的清理闭环。
- 撤回应返回稳定目标身份与明确终态,或提供按该身份查询撤回状态的只读接口。
- `success=true` 单一回执不足以证明接收端通知已撤回;需要可查询的 terminal 状态,并区分未开始、已完成、部分撤回与未知效果。
- 验收:创建专用可回滚 fixture;精确撤回;按同一身份读回终态;重复撤回具有稳定幂等语义;无通知残留。
- 当前事实:撤回响应不含目标身份或终态;精确接收状态在撤回后仍保留;列表首屏缺席不能越过坏分页证明全局终态。
- 验收:按同一 `openDingId` 返回 typed `recalled` 终态或提供专用只读查询;重复撤回语义稳定;能够区分完成、处理中、部分撤回和 commit-unknown,并证明无残留通知。
### DS-DING-003 — 游标边界语义
### `DS-DING-003` — 游标边界与前进语义
- 优先级:P2;类型:contract insufficient。
- 影响:`+list` 的跨页聚合调用方。
- 当前真实续页返回的 `nextCursor` 未严格前进,且基线相邻页还观察到 1 个稳定身份边界重复项。下游需声明 `nextCursor` 是包含式还是排除式边界,并保证续页凭据可以推进。
- 验收:连续两页均返回合法分页事实;游标严格前进;跨页无重复,或合同明确要求调用方按稳定身份去重;终页明确 `hasMore=false` 且无虚假续页凭据。
- 当前事实:首屏 50 项;真实 continuation 再返回 50 项,下一游标不前进,跨页身份交集 1。对包含式边界加一仍返回不前进页面,排除安全的本地 `+1` 修补。
- 验收:连续页凭据严格前进;跨页无重复,或合同明确包含式边界且可证明去重不遗漏;终页明确 `hasMore=false`。
## 5. Fixture、权限与清理
## 6. Fixture、安全与脱敏
| 需求 | 安全资源模型 | 到期/回收 | 状态 |
|---|---|---|---|
| 应用内 DING 写 fixture | 专用测试接收人和无业务正文 | 用后立即撤回并读回终态 | BLOCKED |
| 短信/电话 DING fixture | 隔离租户、显式成本授权、专用号码 | 用后核对计费与通知残留 | BLOCKED |
| 消息转 DING fixture | 专用测试会话与可删除源消息 | 撤回 DING 后删除源消息 | BLOCKED |
| 接收状态读取 fixture | 由公开列表取得稳定 DING 身份 | 只读,无残留 | PASS |
## 6. 安全与脱敏声明
- PASS:不含用户、组织、租户、profile、消息、会话或 DING 的真实 ID。
- PASS:不含标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。
- PASS:真实证据仅记录命令级标签和聚合计数;未将本地诊断响应提交到仓库。
- PASS:APP 仅使用当前账号自收件、合成无业务正文与立即撤回;没有执行短信或电话提醒。
- PASS:没有提交用户、组织、会话、消息、DING 的真实 ID,也没有标题、正文、人名、邮箱、电话、精确业务时间、trace/request ID、token 或原始响应。
- PASS:文档只保留命令级标签与聚合计数;DING 写 Shortcut 仍为 unavailable,未把原子诊断回执当成公开能力完成。
+20 -2
View File
@@ -17,6 +17,16 @@ import (
// remindType: 服务端 API 1=应用内 2=短信 3=电话
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
func dingPersonalRemindType(value string) (string, error) {
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return "", fmt.Errorf("--type must be one of app, sms, call")
}
return remindType, nil
}
func newDingCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/ding.json
// products.ding). Catalog assembly stamps provenance contract_final.
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"receiverOpenDingTalkIds": users,
"content": mustGetFlag(cmd, "content"),
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
return err
}
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
if err != nil {
return err
}
users := parseCSVValues(mustGetFlag(cmd, "users"))
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openMessageId": mustGetFlag(cmd, "message-id"),
"receiverOpenDingTalkIds": users,
"remindType": mustGetFlag(cmd, "type"),
"remindType": remindType,
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "testing"
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
got, err := dingPersonalRemindType(input)
if err != nil || got != want {
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
}
}
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ import (
const (
dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until a dedicated reversible fixture proves confirmation-before-call, a stable write receipt, exact receiver/readback identity, and cleanup without residual notifications."
dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
)
type dingPageEvidence struct {
@@ -304,7 +304,7 @@ func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page
}
func dingUnavailable(operation string) error {
return apperrors.NewDiscovery("该 DING 写 Shortcut 尚无可逆真实 fixture、精确接收人读回与零残留清理证据,当前不可执行",
return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
apperrors.WithOperation(operation),
apperrors.WithOrigin("shortcut_registry"),
apperrors.WithFailureStage("capability_gate"),
+3 -3
View File
@@ -100,7 +100,7 @@ var ReceiverStatus = shortcut.Shortcut{
var SendPersonal = unavailableDingWrite(
"+send-personal", "以本人身份发送 DING 给指定人",
"需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供精确回执、接收人读回和可验证清理前不可执行。",
"需要以当前用户身份向明确的 openDingTalkId 接收人发送 DING 时使用;下游提供稳定接收人身份与可查询撤回终态前不可执行。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
@@ -117,7 +117,7 @@ var SendPersonal = unavailableDingWrite(
var SendByMessage = unavailableDingWrite(
"+send-by-message", "针对某条消息发起 DING 提醒",
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但安全 fixture 与精确清理闭环未具备。",
"需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
shortcut.RiskWrite, false,
[]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true},
@@ -136,7 +136,7 @@ var SendByMessage = unavailableDingWrite(
var RecallPersonal = unavailableDingWrite(
"+recall-personal", "撤回本人发起的 DING",
"需要撤回本人发出的 DING 时使用;必须先有稳定写回执,并能精确证明目标已撤回且没有残留通知,当前尚不满足。",
"需要撤回本人发出的 DING 时使用;必须能按稳定目标精确读回撤回终态且证明没有残留通知,当前下游尚不满足。",
shortcut.RiskHighWrite, true,
[]shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
[]contract.ParamDecl{{Name: "id", Property: "openDingId"}},
+3 -3
View File
@@ -20,21 +20,21 @@
},
"+send-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "用户身份发送 DING 的原子能力存在,但下游没有同时提供稳定接收人回执、精确读回和可验证零残留清理;确认后也在本地 capability gate 停止。",
"semantic_delta": "已修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;隔离自收件 APP fixture 返回稳定 openDingId 并可按该 ID 读回,但接收项只有显示名而没有稳定接收人身份,且撤回无可查询终态,保持不可用。",
"risk": "write",
"public": false,
"reviewed": true
},
"+send-by-message": {
"disposition": "semantic_adapter",
"semantic_delta": "覆盖基于消息的应用内、短信和电话 DING,映射 lark-cli 三种 urgent 任务;缺少可逆 fixture、稳定回执和精确清理证明,保持不可用。",
"semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。",
"risk": "write",
"public": false,
"reviewed": true
},
"+recall-personal": {
"disposition": "semantic_adapter",
"semantic_delta": "个人 DING 撤回缺少能证明目标已撤回且通知无残留的精确终态读取;不接受单一 success 回执作为成功,保持不可用。",
"semantic_delta": "个人 DING 撤回返回布尔成功但不回显目标或终态;撤回后精确接收状态仍保留原记录,而消息列表分页已知不可靠,不能把首屏缺席解释为全局终态,保持不可用。",
"risk": "high-risk-write",
"public": false,
"reviewed": true