Compare commits

...
Author SHA1 Message Date
chichuan 09c1f28862 feat(schema): govern conditional idempotent retries 2026-08-13 13:22:58 +08:00
37 changed files with 1832 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Agent retry safety** — adds a conditional idempotency contract and base-owned compatibility governance for deduplication-key-aware retries.
+11 -4
View File
@@ -1414,10 +1414,17 @@ jobs:
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
set -eu
authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
cleanup_schema_authority() {
git worktree remove --force "$authority_worktree" >/dev/null 2>&1 || true
}
trap cleanup_schema_authority EXIT HUP INT TERM
git worktree add --detach "$authority_worktree" "$COMPATIBILITY_BASE_REF"
"$authority_worktree/scripts/policy/check-authoritative-schema-compatibility.sh" \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF" \
--candidate-ref "$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
+8 -1
View File
@@ -47,7 +47,7 @@ Schema contract) keep separate authorities — do not merge them with
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. `idempotency=conditional` additionally requires `Contract.RetryPolicy`: reference a declared string parameter with an explicit interface property and require the same payload; never infer retry safety from a flag/property name. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
- Package seam:
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
@@ -423,6 +423,13 @@ lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
Preserve all candidates and the selected source in provenance, and fail
same-precedence conflicts rather than silently merging them.
`conditional` is a static command classification, not an unconditional retry
grant. It must carry a typed `retry_policy`; runtime computes effective
idempotency from the actual invocation. A missing/empty/non-string
deduplication key is `non_idempotent` for that invocation. Only the same key
and same business payload may be replayed, and the transport/error
`retryable` signal must fail closed when the invocation is not safe to replay.
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
final Agent projection must keep `required=true` and cannot be lowered by a
lower-precedence source. A higher-precedence declaration may still raise an
+46
View File
@@ -229,6 +229,52 @@ tool、parameter、mapping、positional execution、constraint 与 safety 语义
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## 条件幂等与重试策略迁移
静态 `idempotency` 描述命令级分类;当它是 `conditional` 时,再由实际调用参数计算
本次调用的 effective idempotency。不能因为命令“存在一个可选 `--uuid` flag”就把
整个命令从 `unknown` 改成无条件 `idempotent`。Schema 增加 `conditional` 状态以及
与它配套的 `retry_policy`:
```json
{
"idempotency": "conditional",
"retry_policy": {
"mode": "deduplication_key",
"key_parameter": "uuid",
"same_payload_required": true
}
}
```
只有调用实际提供 `uuid`,并且框架重用同一个 key 和同一份请求 payload 时,调用级
有效幂等性才是 `idempotent`;未提供时仍是 `non_idempotent`。本契约没有发布后端
未证明的去重时间窗。`conditional` 仅允许用于单 RPC 的 `mcp` leaf,key 必须对应
一个存在且有非空 interface property 的真实参数。其余三种静态状态不得携带
`retry_policy`。
兼容迁移分三步治理:
1. 独立治理 PR 在 Schema surface 不变时先加入契约、校验器和精确登记。本轮只登记
57 个 Chat 写命令:5 个 `unknown -> conditional`,52 个
`unknown -> non_idempotent`。
2. 后续业务 PR 只消费这些已登记转换。5 个条件幂等命令必须逐字发布上面的策略;
PR #965 原先的 `unknown -> idempotent` 仍会失败。未登记工具、反向迁移、策略漂移
以及同一 PR 夹带的其它历史契约变化也仍会失败。
3. 当 `main` 与当前 stable baseline 都已经覆盖迁移后,再由独立治理 PR 删除失效的
静态登记,避免把一次迁移审批永久留在规则中。
Interface Integrity job 的兼容性 step 在 PR merge-base 建立独立 authority worktree,
并从该 worktree 启动权威 wrapper;它不经过 candidate checkout 中的
Makefile 或 wrapper。权威 wrapper 再从 merge-base 构建 `schema-compat` checker,
分别比较 merge-base 和 stable。因此,在该 step 实际执行时,业务 candidate
修改 candidate checkout 中的 Makefile、wrapper、checker 或登记表都不能给
自己授权;只有先合入主干、经过独立评审的登记会被后续 PR 使用。
CLI 评测和 Agent 消费方应把 `conditional` 与 `retry_policy` 作为一个整体读取:静态
`conditional` 不是扣分意义上的“不知道”,也不是无条件可重试;评测应根据
`key_parameter` 是否出现在实际调用中计算本次调用的有效幂等性。
## Required GitHub repository settings
The `main` quality ruleset must enable strict required-status-check policy
+2
View File
@@ -81,6 +81,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
| `Safety`(`contract.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
| `Contract.RetryPolicy` | `idempotency=conditional` 时的调用级重试条件;当前仅支持显式去重 key | 结合实际参数计算 effective idempotency;缺 key 时禁止自动重试 | 与 `conditional` 成组进入 full/compact leaf 和 `ResolveMeta`;禁止按 flag 名推断 |
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
@@ -170,6 +171,7 @@ NewLeafCommand(LeafSpec{
| Constraints | **声明** | `Constraints` |
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
| RetryPolicy | **声明**(仅 `idempotency=conditional`) | `ContractDecl.RetryPolicy`;key 必须引用显式映射到 RPC property 的 string `ParamDecl`,并要求相同 payload |
| DryRun | 评审源 | dry-run capabilities registry |
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
+2 -2
View File
@@ -357,7 +357,7 @@ Definition(仅声明;不可编译)
| **Constraints** | `require_one_of`, `mutually_exclusive`, `require_together` | **声明** | `Constraints` → `AnnotateConstraints` | **是** |
| **Positionals** | 位置参数名/必填/说明 | **声明** 或显式 annotate | 目标 `Args`/`PositionalSpec`;今日少量 cobra Args + 注解 | 受管命令应声明,禁止推断 |
| **Safety** | `effect`, `risk`, `confirmation`, `idempotency` | **声明**(完整 `contract.SafetySpec`)**或标注**(`runtime_gate`) | `Safety` / `AnnotateRuntimeGate`(metadata 壳 `tools: {}`,不再承载 reviewed Safety) | 四字段独立;confirmation 单独驱动运行时 |
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
| | `retry_policy` | **声明**(仅 `idempotency=conditional`) | `ContractDecl.RetryPolicy` + 显式 `ParamDecl.Property` + MCP `interface_ref` | 从实际调用参数计算 effective idempotency;缺 key 时失败闭合,禁止按参数名推断 |
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
@@ -1167,7 +1167,7 @@ cmd.RunE = func(cmd *cobra.Command, args []string) error {
| 非 Shortcut 受管定义的 Cobra 命令 Hidden | 可执行 Contract | 挂载的命令可见性与声明匹配 |
| Shortcut 列表成员资格与语义 disposition | 经评审的 Shortcut 可见性解析器 | public/all 列表成员资格与经评审决策匹配 |
| Runtime Schema / Agent 暴露 | identity collector 收集结果加精确排除 | 每个暴露叶子解析到活 Contract;排除显式且不重叠 |
| Safety 与运行时确认 | 可执行 Contract 的完整 `contract.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 |
| Safety、条件幂等与运行时确认 | 可执行 Contract 的完整 `contract.SafetySpec`,或迁移期显式 annotate(如 `runtime_gate`);条件幂等另由 `ContractDecl.RetryPolicy` 声明;见 §5.0 | `confirmation` 单独驱动运行时门,`effect` / `risk` / `idempotency` 原样发布,禁止跨字段机械推导;任一 Safety 字段非空时四字段必须齐全,否则构造期 panic;`conditional` 必须引用显式映射到 RPC property 的 string 参数,并在缺 key 时禁用重试;`ConfirmFirst` 只在 `confirmation=user_required` 时合法 |
| 后端 product/tool/载荷绑定 | mcpbind + 后端元数据 | 每个绑定引用真实的 flag/属性 |
| Agent 选择文案(`use_when`、`avoid_when`、摘要) | 声明:`ContractDecl.Selection` / `ProductDecl`(交付 provenance `contract_final`);`schema_hints/` 已退役,禁止回潮 | 身份解析到活契约;选择文案不得创建 CLI 表面 |
+308
View File
@@ -0,0 +1,308 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func retryGateTestRunner() *runtimeRunner {
client := transport.NewClient(nil)
return &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "test-token"},
auditSink: audit.NopSink{},
}
}
func TestCrossPlatformCoverageRuntimeRetryGateControlsTransportAndAPIHint(t *testing.T) {
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
testseam.Swap(t, &runnerCaptureRuntimeFailure, func(executor.Invocation, error, error) {})
tests := []struct {
name string
decision *contract.RetryDecision
wantRetries int
wantRetryable bool
}{
{name: "missing contract fails closed", wantRetries: 0, wantRetryable: false},
{
name: "conditional contract without key fails closed",
decision: &contract.RetryDecision{
EffectiveIdempotency: "non_idempotent",
SafeToRetry: false,
Reason: "deduplication_key_missing",
},
wantRetries: 0,
wantRetryable: false,
},
{
name: "resolved idempotent contract preserves retry",
decision: &contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
},
wantRetries: 1,
wantRetryable: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotRetries := -1
testseam.Swap(t, &runnerCallTool, func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
gotRetries = client.MaxRetries
return transport.ToolCallResult{}, apperrors.NewAPI(
"server unavailable",
apperrors.WithReason("http_503"),
apperrors.WithRetryable(true),
apperrors.WithRetryAfterSeconds(3),
)
})
runner := retryGateTestRunner()
_, err := runner.executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{"uuid": "stable-key"},
Retry: tt.decision,
})
if gotRetries != tt.wantRetries {
t.Fatalf("transport MaxRetries = %d, want %d", gotRetries, tt.wantRetries)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T %v, want typed API error", err, err)
}
if !typed.RetryableSet || typed.Retryable != tt.wantRetryable {
t.Fatalf("retryable = (%v, %v), want (true, %v)", typed.RetryableSet, typed.Retryable, tt.wantRetryable)
}
if tt.wantRetryable {
if typed.RetryAfterSeconds == nil || *typed.RetryAfterSeconds != 3 {
t.Fatalf("safe retry_after_seconds = %v, want 3", typed.RetryAfterSeconds)
}
} else if typed.RetryAfterSeconds != nil || typed.NextRetryAt != nil {
t.Fatalf("unsafe retry kept backoff hints: after=%v at=%v", typed.RetryAfterSeconds, typed.NextRetryAt)
}
if runner.transport.MaxRetries != 1 {
t.Fatalf("shared transport MaxRetries = %d, want unchanged 1", runner.transport.MaxRetries)
}
})
}
}
func TestCrossPlatformCoverageRuntimeRetryGateDoesNotClampAuthErrors(t *testing.T) {
testseam.Swap(t, &runnerPreflightDocDownload, func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
return nil
})
testseam.Swap(t, &runnerCaptureRuntimeFailure, func(executor.Invocation, error, error) {})
authErr := apperrors.NewAuth("expired", apperrors.WithRetryable(true))
testseam.Swap(t, &runnerCallTool, func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, authErr
})
_, err := retryGateTestRunner().executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T %v, want typed auth error", err, err)
}
if typed.Category != apperrors.CategoryAuth || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("auth retryability was changed: %#v", typed)
}
}
func TestCrossPlatformCoverageRuntimeRetryDecisionPrecedesDryRun(t *testing.T) {
resolverCalls := 0
runner := retryGateTestRunner()
runner.globalFlags.DryRun = true
runner.resolveToolCallRetry = func(productID, rpcName string, args map[string]any) contract.RetryDecision {
resolverCalls++
if productID != "chat" || rpcName != "send_message" || args["uuid"] != "stable-key" {
t.Fatalf("resolver input = %q/%q %#v", productID, rpcName, args)
}
return contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
}
result, err := runner.Run(context.Background(), executor.NewHelperInvocation(
"chat send",
"chat",
"send_message",
map[string]any{"uuid": "stable-key"},
))
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if resolverCalls != 1 || result.Invocation.Retry == nil || !result.Invocation.Retry.SafeToRetry {
t.Fatalf("dry-run retry decision = %#v, resolver calls = %d", result.Invocation.Retry, resolverCalls)
}
if got, ok := result.Response["retry"].(*contract.RetryDecision); !ok || !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("dry-run response retry = %#v", result.Response["retry"])
}
explicit := contract.RetryDecision{EffectiveIdempotency: "unknown", SafeToRetry: false, Reason: "explicit"}
invocation := executor.NewHelperInvocation("chat send", "chat", "send_message", nil)
invocation.Retry = &explicit
result, err = runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("Run(explicit) error = %v", err)
}
if resolverCalls != 1 || result.Invocation.Retry == nil || result.Invocation.Retry.Reason != "explicit" {
t.Fatalf("explicit retry decision was overwritten: %#v, resolver calls = %d", result.Invocation.Retry, resolverCalls)
}
}
func TestCrossPlatformCoverageExecuteInvocationDryRunIncludesRetryDecision(t *testing.T) {
decision := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
result, err := retryGateTestRunner().executeInvocation(context.Background(), "https://example.test", executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
DryRun: true,
Retry: &decision,
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
got, ok := result.Response["retry"].(*contract.RetryDecision)
if !ok || got != &decision || !got.SafeToRetry {
t.Fatalf("dry-run response retry = %#v, want original decision", result.Response["retry"])
}
}
func TestCrossPlatformCoverageToolCallerDryRunIncludesRetryDecision(t *testing.T) {
runner := retryGateTestRunner()
runner.resolveToolCallRetry = func(_ string, _ string, args map[string]any) contract.RetryDecision {
if key, _ := args["uuid"].(string); key == "" {
return contract.RetryDecision{
EffectiveIdempotency: "non_idempotent",
Reason: "deduplication_key_missing",
}
}
return contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
}
var captured executor.Invocation
testseam.Swap(t, &toolCallerDryRun, func(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
captured = invocation
return (executor.EchoRunner{}).Run(ctx, invocation)
})
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true})
result, err := caller.CallTool(context.Background(), "chat", "send_message", map[string]any{"uuid": "stable-key"})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if captured.Retry == nil || !captured.Retry.SafeToRetry || captured.Retry.EffectiveIdempotency != "idempotent" {
t.Fatalf("captured retry decision = %#v", captured.Retry)
}
if len(result.Content) != 1 || !bytes.Contains([]byte(result.Content[0].Text), []byte(`"effective_idempotency":"idempotent"`)) {
t.Fatalf("ToolResult does not expose effective idempotency: %#v", result)
}
result, err = caller.CallTool(context.Background(), "chat", "send_message", map[string]any{"content": "hello"})
if err != nil {
t.Fatalf("CallTool(without uuid) error = %v", err)
}
if captured.Retry == nil || captured.Retry.SafeToRetry || captured.Retry.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("missing-key retry decision = %#v", captured.Retry)
}
if len(result.Content) != 1 || !bytes.Contains([]byte(result.Content[0].Text), []byte(`"effective_idempotency":"non_idempotent"`)) {
t.Fatalf("ToolResult does not expose missing-key idempotency: %#v", result)
}
}
func TestCrossPlatformCoverageRuntimeSafeRetryReusesRequestBody(t *testing.T) {
var mu sync.Mutex
var bodies [][]byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("ReadAll(request) error = %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
mu.Lock()
bodies = append(bodies, append([]byte(nil), body...))
attempt := len(bodies)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if attempt == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, `{"jsonrpc":"2.0","id":3,"result":{"content":{"messageId":"m1"}}}`)
}))
defer server.Close()
runner := retryGateTestRunner()
runner.transport = transport.NewClient(server.Client())
runner.transport.RetryDelay = 0
runner.transport.RetryMaxDelay = 0
runner.auditSink = audit.NopSink{}
decision := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "deduplication_key_present",
}
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "chat",
Tool: "send_message",
Params: map[string]any{"uuid": "stable-key", "content": "hello"},
Retry: &decision,
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
if !result.Invocation.Implemented {
t.Fatal("successful retry was not marked implemented")
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 {
t.Fatalf("request attempts = %d, want 2", len(bodies))
}
if !bytes.Equal(bodies[0], bodies[1]) {
t.Fatalf("retry body changed:\nfirst: %s\nsecond: %s", bodies[0], bodies[1])
}
}
+61 -19
View File
@@ -30,6 +30,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
@@ -139,23 +141,25 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
transportClient := transport.NewClient(httpClient)
transportClient.FileLogger = FileLoggerInstance()
return &runtimeRunner{
transport: transportClient,
globalFlags: flags,
scanner: newRuntimeContentScanner(),
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
transport: transportClient,
globalFlags: flags,
resolveToolCallRetry: cli.ResolveToolCallRetry,
scanner: newRuntimeContentScanner(),
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
}
}
type runtimeRunner struct {
transport *transport.Client
globalFlags *GlobalFlags
fallback executor.Runner
scanner safety.Scanner
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
agentMetadata *agentMetadataSnapshot
transport *transport.Client
globalFlags *GlobalFlags
fallback executor.Runner
scanner safety.Scanner
enforceContentScan bool
includeScanReport bool
auditSink audit.Sink
agentMetadata *agentMetadataSnapshot
resolveToolCallRetry func(productID, rpcName string, args map[string]any) contract.RetryDecision
}
var (
@@ -174,6 +178,7 @@ var (
)
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
r.resolveInvocationRetry(&invocation)
// Global dry-run is an execution barrier, not merely a transport option.
// Return a deterministic local preview before profile resolution, catalog
// discovery, Keychain/token prefetch, auth, stateful preflight or transport.
@@ -216,6 +221,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) resolveInvocationRetry(invocation *executor.Invocation) {
if invocation == nil || invocation.Retry != nil || r == nil || r.resolveToolCallRetry == nil {
return
}
decision := r.resolveToolCallRetry(invocation.CanonicalProduct, invocation.Tool, invocation.Params)
invocation.Retry = &decision
}
// RunReadOnly executes one already-classified read lookup for a semantic
// Shortcut that is building a dry-run plan. It clones the runtime flags and
// clears DryRun only on that clone: the process-wide caller and every ordinary
@@ -500,6 +513,7 @@ func endpointNotResolvedError(productID, toolName, detail string) error {
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
defer func() { enforceInvocationRetrySafety(invocation, retErr) }()
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
@@ -542,6 +556,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
retErr == nil, time.Since(invokeStart), errCat, errReason)
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
}()
// Register this defer after the audit defer so the fail-closed safety value
// is what logging and audit observers see as well as what the caller gets.
defer func() { enforceInvocationRetrySafety(invocation, retErr) }()
// Check whether this product belongs to an HTTP plugin. Every accepted
// plugin has an ownership record; credentials within that record are
@@ -575,14 +592,18 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
}
response := map[string]any{
"dry_run": true,
"endpoint": transport.RedactURL(endpoint),
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
}
if invocation.Retry != nil {
response["retry"] = invocation.Retry
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"dry_run": true,
"endpoint": transport.RedactURL(endpoint),
"request": executor.ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
},
Response: response,
}, nil
}
@@ -632,6 +653,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
tc = r.transport.WithAuth(authToken, resolveMCPRequestHeadersForInvocation(invocation))
}
}
if invocation.Retry == nil || !invocation.Retry.SafeToRetry {
tc.MaxRetries = 0
}
callCtx := ctx
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
@@ -786,6 +810,24 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
return executor.Result{Invocation: invocation, Response: response}, nil
}
// enforceInvocationRetrySafety intersects server/API retry hints with the
// invocation's reviewed idempotency decision. Auth and PAT recovery have
// independent one-shot retry protocols and are deliberately left untouched.
func enforceInvocationRetrySafety(invocation executor.Invocation, err error) {
if invocation.Retry != nil && invocation.Retry.SafeToRetry {
return
}
var typed *apperrors.Error
if errors.As(err, &typed) && typed.Category == apperrors.CategoryAPI {
typed.Retryable = false
typed.RetryableSet = true
// Server backoff hints describe transience, not replay safety. Keeping
// them next to retryable=false invites an Agent to bypass the contract.
typed.RetryAfterSeconds = nil
typed.NextRetryAt = nil
}
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
@@ -22,6 +22,7 @@ import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
@@ -205,10 +206,16 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
retry := contract.RetryDecision{
EffectiveIdempotency: "idempotent",
SafeToRetry: true,
Reason: "static_idempotent",
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
Retry: &retry,
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
+5
View File
@@ -44,6 +44,11 @@ func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.To
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
if a != nil {
if runtime, ok := a.runner.(*runtimeRunner); ok {
runtime.resolveInvocationRetry(&inv)
}
}
// Defense in depth for direct helper callers: global dry-run must never
// reach an injected/real Runner, even if a command bypasses the normal
// Schema leaf wrapper. EchoRunner produces the same stable dry_run envelope
+19 -2
View File
@@ -28,6 +28,8 @@ import (
"strings"
"sync"
"sync/atomic"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// CommandMeta is the complete runtime metadata view for a single command.
@@ -56,8 +58,9 @@ type CommandSelection struct {
}
var (
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
metaByCLIPathOnce sync.Once
metaByCLIPath map[string]CommandMeta
toolCallRetryByInterface map[InterfaceRefKey]toolCallRetryResolution
)
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
@@ -75,10 +78,12 @@ func installDeliveryCommandMeta(loaded loadedSchemaCatalog, err error) {
if err != nil {
runtimeDeliverySchemaMetaIndexErr = err
metaByCLIPath = nil
toolCallRetryByInterface = nil
metaByCLIPathOnce.Do(func() {})
return
}
metaByCLIPath = buildMetaByCLIPath(loaded)
toolCallRetryByInterface = buildToolCallRetryLookup(loaded)
runtimeDeliverySchemaMetaIndexErr = nil
metaByCLIPathOnce.Do(func() {})
}
@@ -124,6 +129,7 @@ func buildMetaByCLIPathFromRegistry(registry SchemaRegistry) map[string]CommandM
Risk: tool.Safety.Risk,
Confirmation: tool.Safety.Confirmation,
Idempotency: tool.Safety.Idempotency,
RetryPolicy: cloneRetryPolicy(tool.RetryPolicy),
},
Selection: CommandSelection{
AgentSummary: tool.Selection.AgentSummary,
@@ -163,6 +169,7 @@ func buildMetaByCLIPathFromSnapshotTools(tools map[string]map[string]any) map[st
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
},
Selection: CommandSelection{
AgentSummary: schemaString(tool["agent_summary"]),
@@ -212,3 +219,13 @@ func ResolveMeta(cliPath string) (CommandMeta, bool) {
m, ok := metaByCLIPath[cliPath]
return m, ok
}
// ResolveInvocationSafety returns the effective retry decision for one CLI
// invocation. The arguments map is keyed by declared CLI parameter names.
func ResolveInvocationSafety(cliPath string, arguments map[string]any) (contract.RetryDecision, bool) {
meta, ok := ResolveMeta(cliPath)
if !ok {
return contract.RetryDecision{}, false
}
return meta.Safety.Resolve(arguments), true
}
+13 -1
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -30,7 +31,18 @@ type CommandSafety struct {
Effect string // read / write / destructive
Risk string // low / medium / high
Confirmation string // not_required / user_required
Idempotency string // idempotent / non_idempotent
Idempotency string // idempotent / non_idempotent / unknown / conditional
RetryPolicy *contract.RetryPolicySpec
}
// Resolve derives invocation-scoped retry safety from reviewed metadata and
// actual CLI arguments. It never changes the static Schema contract.
func (s CommandSafety) Resolve(arguments map[string]any) contract.RetryDecision {
key := ""
if s.RetryPolicy != nil {
key = s.RetryPolicy.KeyParameter
}
return contract.ResolveRetryDecision(s.Idempotency, s.RetryPolicy, arguments, key)
}
// ShouldRender returns true when the safety metadata warrants a visible
+3 -2
View File
@@ -15,6 +15,7 @@ package cli
import (
"fmt"
"reflect"
"strings"
"testing"
)
@@ -79,7 +80,7 @@ func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
if !ok2 {
t.Fatal("trimmed lookup failed; whitespace not trimmed")
}
if s1 != s2 {
if !reflect.DeepEqual(s1, s2) {
t.Errorf("whitespace-trimmed result differs: %+v vs %+v", s1, s2)
}
}
@@ -161,7 +162,7 @@ func TestResolveMetaAliasLookup(t *testing.T) {
if aliased.Identity.CLIPath != primary.Identity.CLIPath || aliased.Identity.Canonical != primary.Identity.Canonical {
t.Fatalf("alias metadata = %+v, want same identity as primary %+v", aliased.Identity, primary.Identity)
}
if aliased.Safety != primary.Safety {
if !reflect.DeepEqual(aliased.Safety, primary.Safety) {
t.Fatalf("alias safety = %+v, want %+v", aliased.Safety, primary.Safety)
}
}
+26 -2
View File
@@ -35,7 +35,13 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Title: "Final Title",
Safety: &contract.SafetySpec{
Effect: "write", Confirmation: "user_required", Idempotency: "none",
Effect: "write", Confirmation: "user_required", Idempotency: "conditional",
},
RetryPolicy: &contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "mode", SamePayloadRequired: true},
Parameters: []contract.ParamDecl{{Name: "mode", Property: "mode"}},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP, Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "dev", RPCName: "create_thing"},
},
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
Result: &contract.ResultSpec{
@@ -69,9 +75,12 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
if spec.Title != "Final Title" {
t.Fatalf("title = %q", spec.Title)
}
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "none" {
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "conditional" {
t.Fatalf("safety = %#v", spec.Safety)
}
if spec.RetryPolicy == nil || spec.RetryPolicy.KeyParameter != "mode" {
t.Fatalf("retry_policy = %#v", spec.RetryPolicy)
}
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
t.Fatalf("dry_run = %#v", spec.DryRun)
}
@@ -89,6 +98,21 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *tes
}
}
func TestCrossPlatformCoverageRuntimeToolSpecRejectsInvalidRetryPolicy(t *testing.T) {
cmd := &cobra.Command{Use: "send"}
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "send", CLIName: "send",
CLIPath: "chat send", PrimaryCLIPath: "chat send", ProductName: "Chat", Command: cmd,
}
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{
Safety: &contract.SafetySpec{Idempotency: "conditional"},
RetryPolicy: &contract.RetryPolicySpec{Mode: "inferred", KeyParameter: "uuid", SamePayloadRequired: true},
}, runtimeSchemaMetadataSources{})
if err == nil || !strings.Contains(err.Error(), "unsupported mode") {
t.Fatalf("invalid retry policy error = %v", err)
}
}
func TestRuntimeToolSpecHidesUnifiedResultForInactiveRollout(t *testing.T) {
for _, state := range []output.RolloutState{output.RolloutLegacyOnly, output.RolloutDualValidate} {
t.Run(string(state), func(t *testing.T) {
+261
View File
@@ -0,0 +1,261 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
func conditionalRetryTool() RuntimeToolSpecInput {
return RuntimeToolSpecInput{
Identity: contract.ToolIdentitySpec{ProductID: "chat", Name: "send", CLIPath: "chat send"},
Parameters: []ParameterSpec{{
Name: "uuid", Type: "string", Property: "requestUuid",
FieldProvenance: map[string]contract.FieldProvenance{
"property": resolvedFieldProvenance(
"requestUuid", "native_annotation", "contract.ParamDecl", "native_annotation",
"contract_pass_through", "explicit retry key property",
),
},
}},
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "conditional"},
RetryPolicy: &contract.RetryPolicySpec{
Mode: contract.RetryModeDeduplicationKey,
KeyParameter: "uuid",
SamePayloadRequired: true,
},
Interface: contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send"},
},
FieldProvenance: map[string]contract.FieldProvenance{
"retry_policy": resolvedFieldProvenance(
contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true},
"corecmd.contract", "corecmd.ContractDecl", "contract_final", "contract_pass_through", "reviewed retry policy",
),
},
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyValidatesAndProjects(t *testing.T) {
spec, err := ToolSpecFromRuntime(conditionalRetryTool())
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
full, err := spec.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
policy, ok := full["retry_policy"].(map[string]any)
if !ok || policy["mode"] != contract.RetryModeDeduplicationKey || policy["key_parameter"] != "uuid" || policy["same_payload_required"] != true {
t.Fatalf("retry_policy = %#v", full["retry_policy"])
}
compact := stripSchemaPayloadCompact(full)
if !schemaJSONEqual(compact["retry_policy"], full["retry_policy"]) {
t.Fatalf("compact retry_policy = %#v, want %#v", compact["retry_policy"], full["retry_policy"])
}
summary, err := spec.ToSummaryPayload()
if err != nil {
t.Fatalf("ToSummaryPayload() error = %v", err)
}
if _, exists := summary["retry_policy"]; exists {
t.Fatalf("navigation summary must omit retry_policy: %#v", summary)
}
wire, err := schemaToolWireFromPayload(full)
if err != nil {
t.Fatalf("schemaToolWireFromPayload() error = %v", err)
}
roundTrip, err := schemaToolSpecFromWire(wire)
if err != nil || roundTrip.RetryPolicy == nil || roundTrip.RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("snapshot retry_policy = %#v, error = %v", roundTrip.RetryPolicy, err)
}
registry := SchemaRegistry{Products: []ProductSpec{{ID: "chat", Tools: []ToolSpec{spec}}}}
meta := buildMetaByCLIPathFromRegistry(registry)["chat send"]
if meta.Safety.RetryPolicy == nil || meta.Safety.RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("ResolveMeta projection lost retry_policy: %#v", meta.Safety)
}
if decision := meta.Safety.Resolve(map[string]any{"uuid": "request-1"}); !decision.SafeToRetry || decision.EffectiveIdempotency != "idempotent" {
t.Fatalf("invocation decision = %#v", decision)
}
if decision := meta.Safety.Resolve(nil); decision.SafeToRetry || decision.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("missing-key invocation decision = %#v", decision)
}
t.Cleanup(restorePackageCLISchemaDeliveryForTest)
storeSchemaSourceRootFn(func() *cobra.Command { return &cobra.Command{Use: "dws"} })
assembleDeliverySchemaCatalogFn = func(*cobra.Command) (loadedSchemaCatalog, error) {
return loadedSchemaCatalog{Registry: registry}, nil
}
resetSchemaDeliveryState()
decision, ok := ResolveInvocationSafety("chat send", map[string]any{"uuid": "request-1"})
if !ok || !decision.SafeToRetry || decision.EffectiveIdempotency != "idempotent" {
t.Fatalf("ResolveInvocationSafety() = %#v, ok=%v", decision, ok)
}
if _, ok := ResolveInvocationSafety("chat missing", nil); ok {
t.Fatal("ResolveInvocationSafety() must not invent missing commands")
}
transportDecision := ResolveToolCallRetry("chat", "send", map[string]any{"requestUuid": "request-1"})
if !transportDecision.SafeToRetry || transportDecision.EffectiveIdempotency != "idempotent" {
t.Fatalf("ResolveToolCallRetry() = %#v", transportDecision)
}
if missing := ResolveToolCallRetry("chat", "missing", nil); missing.SafeToRetry || missing.Reason != "interface_not_declared" {
t.Fatalf("missing interface decision = %#v", missing)
}
lookup := buildToolCallRetryLookup(loadedSchemaCatalog{Registry: registry})
resolution := lookup[retryInterfaceRefKey("chat", "send")]
resolvedDecision := contract.ResolveRetryDecision(resolution.idempotency, resolution.policy, map[string]any{"requestUuid": "request-1"}, resolution.argumentKey)
if !resolvedDecision.SafeToRetry {
t.Fatalf("interface retry decision = %#v", resolvedDecision)
}
fullTool, _ := spec.ToPayload()
metaSnapshot := SchemaCatalogSnapshot{Version: SchemaCatalogSnapshotVersion, SourceHash: "retry-source", Tools: map[string]map[string]any{"chat.send": fullTool}}
metaIndex, err := BuildSchemaMetaIndex(metaSnapshot)
if err != nil || len(metaIndex.Entries) != 1 || metaIndex.Entries[0].RetryPolicy == nil {
t.Fatalf("BuildSchemaMetaIndex() = %#v, error = %v", metaIndex, err)
}
encodedIndex, err := EncodeSchemaMetaIndex(metaIndex)
if err != nil {
t.Fatalf("EncodeSchemaMetaIndex() error = %v", err)
}
decodedIndex, err := DecodeSchemaMetaIndex(encodedIndex)
if err != nil || decodedIndex.Entries[0].RetryPolicy == nil || decodedIndex.Entries[0].RetryPolicy.KeyParameter != "uuid" {
t.Fatalf("DecodeSchemaMetaIndex() = %#v, error = %v", decodedIndex, err)
}
if err := ValidateSchemaMetaIndexAgainstSnapshot(metaIndex, metaSnapshot); err != nil {
t.Fatalf("ValidateSchemaMetaIndexAgainstSnapshot() error = %v", err)
}
snapshotLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{
"chat.send": fullTool,
"chat.local": {"interface_mode": contract.InterfaceModeLocal},
}}})
snapshotResolution := snapshotLookup[retryInterfaceRefKey("chat", "send")]
if snapshotResolution.argumentKey != "requestUuid" || snapshotResolution.policy == nil {
t.Fatalf("snapshot retry resolution = %#v", snapshotResolution)
}
invalidSnapshot := map[string]any{
"interface_mode": contract.InterfaceModeMCP,
"interface_ref": map[string]any{"product_id": "chat", "rpc_name": "invalid"},
"idempotency": "conditional",
"retry_policy": map[string]any{"mode": "inferred", "key_parameter": "uuid", "same_payload_required": true},
}
invalidLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{"chat.invalid": invalidSnapshot}}})
invalidResolution := invalidLookup[retryInterfaceRefKey("chat", "invalid")]
if invalidResolution.policy != nil || contract.ResolveRetryDecision(invalidResolution.idempotency, nil, nil, "").Reason != "invalid_retry_policy" {
t.Fatalf("invalid snapshot retry resolution = %#v", invalidResolution)
}
missingBindingSnapshot := map[string]any{
"interface_mode": contract.InterfaceModeMCP,
"interface_ref": map[string]any{"product_id": "chat", "rpc_name": "missing_binding"},
"idempotency": "conditional",
"retry_policy": map[string]any{"mode": contract.RetryModeDeduplicationKey, "key_parameter": "uuid", "same_payload_required": true},
"parameters": map[string]any{"uuid": map[string]any{"type": "string"}},
}
missingBindingLookup := buildToolCallRetryLookup(loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{
"chat.missing_binding": missingBindingSnapshot,
}}})
missingBindingResolution := missingBindingLookup[retryInterfaceRefKey("chat", "missing_binding")]
missingBindingDecision := contract.ResolveRetryDecision(
missingBindingResolution.idempotency,
missingBindingResolution.policy,
map[string]any{"uuid": "request-1"},
missingBindingResolution.argumentKey,
)
if missingBindingDecision.SafeToRetry || missingBindingDecision.Reason != "deduplication_key_binding_missing" {
t.Fatalf("snapshot missing property inferred a key binding: %#v", missingBindingDecision)
}
if retryPolicyFromSchemaValue(func() {}) != nil || retryPolicyFromSchemaValue("invalid") != nil || retryPolicyFromSchemaValue(nil) != nil {
t.Fatal("invalid snapshot policy values must fail closed")
}
conflict := spec
conflict.Identity.Name = "reply"
conflict.Identity.CanonicalPath = "chat.reply"
conflict.Identity.CLIPath = "chat reply"
conflict.Identity.PrimaryCLIPath = "chat reply"
conflict.Safety.Idempotency = "unknown"
conflict.RetryPolicy = nil
conflict.FieldProvenance = nil
conflictRegistry := SchemaRegistry{Products: []ProductSpec{{ID: "chat", Tools: []ToolSpec{spec, conflict}}}}
assembleDeliverySchemaCatalogFn = func(*cobra.Command) (loadedSchemaCatalog, error) {
return loadedSchemaCatalog{Registry: conflictRegistry}, nil
}
resetSchemaDeliveryState()
conflictDecision := ResolveToolCallRetry("chat", "send", map[string]any{"requestUuid": "request-1"})
if conflictDecision.SafeToRetry || conflictDecision.Reason != "interface_retry_policy_conflict" {
t.Fatalf("conflicting interface decision = %#v", conflictDecision)
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyFailsClosed(t *testing.T) {
for name, mutate := range map[string]func(*RuntimeToolSpecInput){
"missing policy": func(in *RuntimeToolSpecInput) { in.RetryPolicy = nil },
"missing parameter": func(in *RuntimeToolSpecInput) { in.Parameters = nil },
"missing property": func(in *RuntimeToolSpecInput) { in.Parameters[0].Property = "" },
"non string parameter": func(in *RuntimeToolSpecInput) { in.Parameters[0].Type = "integer" },
"inferred property": func(in *RuntimeToolSpecInput) {
in.Parameters[0].FieldProvenance["property"] = resolvedFieldProvenance(
"requestUuid", "flag_name_inference", "cobra.flag", "inference", "fallback", "inferred",
)
},
"non MCP interface": func(in *RuntimeToolSpecInput) {
in.Interface = contract.InterfaceSpec{Mode: contract.InterfaceModeLocal, Availability: contract.InterfaceAvailable, Reason: "local"}
},
"policy on static idempotent": func(in *RuntimeToolSpecInput) { in.Safety.Idempotency = "idempotent" },
"invalid policy mode": func(in *RuntimeToolSpecInput) { in.RetryPolicy.Mode = "inferred" },
} {
t.Run(name, func(t *testing.T) {
input := conditionalRetryTool()
mutate(&input)
if _, err := ToolSpecFromRuntime(input); err == nil {
t.Fatal("invalid retry contract must fail closed")
}
})
}
}
func TestCrossPlatformCoverageConditionalRetryPolicyRequiresFinalProvenance(t *testing.T) {
spec, err := ToolSpecFromRuntime(conditionalRetryTool())
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
delete(spec.FieldProvenance, "retry_policy")
err = validateFinalSchemaProvenanceCoverage(SchemaRegistry{Products: []ProductSpec{{
ID: "chat", Tools: []ToolSpec{spec},
}}})
if err == nil || !strings.Contains(err.Error(), "has no provenance for retry_policy") {
t.Fatalf("validateFinalSchemaProvenanceCoverage() error = %v", err)
}
}
func TestCrossPlatformCoverageInterfaceRetryPolicyConflictFailsClosed(t *testing.T) {
lookup := map[InterfaceRefKey]toolCallRetryResolution{}
key := retryInterfaceRefKey("chat", "send")
idempotent := toolCallRetryResolution{idempotency: "idempotent"}
mergeToolCallRetryResolution(lookup, key, idempotent)
mergeToolCallRetryResolution(lookup, key, idempotent)
mergeToolCallRetryResolution(lookup, key, toolCallRetryResolution{idempotency: "unknown"})
mergeToolCallRetryResolution(lookup, key, idempotent)
if got := lookup[key]; !got.conflict {
t.Fatalf("conflicting interface retry semantics = %#v", got)
}
decision := contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_retry_policy_conflict"}
if decision.SafeToRetry || !strings.Contains(decision.Reason, "conflict") {
t.Fatalf("conflict decision = %#v", decision)
}
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cli
import (
"encoding/json"
"reflect"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
type toolCallRetryResolution struct {
idempotency string
policy *contract.RetryPolicySpec
argumentKey string
conflict bool
}
func retryInterfaceRefKey(productID, rpcName string) InterfaceRefKey {
return InterfaceRefKey{
ProductID: strings.TrimSpace(productID),
RPCName: strings.TrimSpace(rpcName),
}
}
func buildToolCallRetryLookup(loaded loadedSchemaCatalog) map[InterfaceRefKey]toolCallRetryResolution {
if len(loaded.Registry.Products) == 0 {
return buildToolCallRetryLookupFromSnapshot(loaded.Snapshot.Tools)
}
lookup := make(map[InterfaceRefKey]toolCallRetryResolution)
for _, product := range loaded.Registry.Products {
for _, tool := range product.Tools {
if tool.Interface.Mode != contract.InterfaceModeMCP || tool.Interface.Ref == nil {
continue
}
resolution := toolCallRetryResolution{idempotency: tool.Safety.Idempotency, policy: cloneRetryPolicy(tool.RetryPolicy)}
if tool.RetryPolicy != nil {
for _, parameter := range tool.Parameters {
if parameter.Name == tool.RetryPolicy.KeyParameter {
resolution.argumentKey = parameter.Property
break
}
}
}
mergeToolCallRetryResolution(lookup, retryInterfaceRefKey(tool.Interface.Ref.ProductID, tool.Interface.Ref.RPCName), resolution)
}
}
return lookup
}
func buildToolCallRetryLookupFromSnapshot(tools map[string]map[string]any) map[InterfaceRefKey]toolCallRetryResolution {
lookup := make(map[InterfaceRefKey]toolCallRetryResolution)
for _, tool := range tools {
ref, _ := tool["interface_ref"].(map[string]any)
productID, rpcName := schemaString(ref["product_id"]), schemaString(ref["rpc_name"])
if schemaString(tool["interface_mode"]) != contract.InterfaceModeMCP || productID == "" || rpcName == "" {
continue
}
policy := retryPolicyFromSchemaValue(tool["retry_policy"])
resolution := toolCallRetryResolution{idempotency: schemaString(tool["idempotency"]), policy: policy}
if policy != nil {
parameters, _ := tool["parameters"].(map[string]any)
parameter, _ := parameters[policy.KeyParameter].(map[string]any)
resolution.argumentKey = schemaString(parameter["property"])
}
mergeToolCallRetryResolution(lookup, retryInterfaceRefKey(productID, rpcName), resolution)
}
return lookup
}
func mergeToolCallRetryResolution(lookup map[InterfaceRefKey]toolCallRetryResolution, key InterfaceRefKey, candidate toolCallRetryResolution) {
current, exists := lookup[key]
if !exists {
lookup[key] = candidate
return
}
if current.conflict || current.idempotency != candidate.idempotency || current.argumentKey != candidate.argumentKey || !reflect.DeepEqual(current.policy, candidate.policy) {
lookup[key] = toolCallRetryResolution{conflict: true}
}
}
func cloneRetryPolicy(in *contract.RetryPolicySpec) *contract.RetryPolicySpec {
if in == nil {
return nil
}
out := *in
return &out
}
func retryPolicyFromSchemaValue(value any) *contract.RetryPolicySpec {
if value == nil {
return nil
}
data, err := json.Marshal(value)
if err != nil {
return nil
}
var policy contract.RetryPolicySpec
if err := json.Unmarshal(data, &policy); err != nil {
return nil
}
normalized, err := contract.NormalizeRetryPolicySpec(&policy, "<snapshot>")
if err != nil {
return nil
}
return normalized
}
// ResolveToolCallRetry resolves transport retry safety from an exact reviewed
// MCP interface identity and actual RPC arguments. Missing or conflicting
// interface mappings fail closed; no command, flag, or property name is inferred.
func ResolveToolCallRetry(productID, rpcName string, arguments map[string]any) contract.RetryDecision {
_ = deliverySchemaCatalog()
panicIfMetaIndexUnusable(runtimeDeliverySchemaMetaIndexErr)
resolution, ok := toolCallRetryByInterface[retryInterfaceRefKey(productID, rpcName)]
if !ok {
return contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_not_declared"}
}
if resolution.conflict {
return contract.RetryDecision{EffectiveIdempotency: "unknown", Reason: "interface_retry_policy_conflict"}
}
return contract.ResolveRetryDecision(resolution.idempotency, resolution.policy, arguments, resolution.argumentKey)
}
+1
View File
@@ -1057,6 +1057,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"retry_policy": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"result": true, "pagination": true,
+63
View File
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
"pagination",
"positionals",
"result",
"retry_policy",
}
var schemaCatalogToolEnums = map[string][]string{
@@ -89,6 +90,7 @@ var schemaCatalogToolEnums = map[string][]string{
"confirmation": {"not_required", "user_required"},
"interface_mode": {contract.InterfaceModeMCP, contract.InterfaceModeComposite, contract.InterfaceModeLocal},
"availability": {contract.InterfaceAvailable, contract.InterfaceUnavailable},
"idempotency": {"idempotent", "non_idempotent", "unknown", "conditional"},
}
// schemaCatalogParamRequiredKeys is the required core of every parameter.
@@ -239,6 +241,7 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
validateCatalogInterface(toolID, entry, violations)
validateCatalogRetryPolicy(toolID, entry, parameters, paramsOK, violations)
if result, exists := entry["result"]; exists {
if _, ok := result.(map[string]any); !ok {
report("field %q must be an object", "result")
@@ -280,6 +283,66 @@ func validateCatalogToolEntry(toolID string, entry map[string]any, violations *[
}
}
func validateCatalogRetryPolicy(toolID string, entry, parameters map[string]any, paramsOK bool, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{tool: toolID, message: fmt.Sprintf(format, args...)})
}
idempotency, _ := entry["idempotency"].(string)
raw, hasPolicy := entry["retry_policy"]
if idempotency == "conditional" && !hasPolicy {
report("idempotency=conditional requires retry_policy")
return
}
if idempotency != "conditional" && hasPolicy {
report("retry_policy requires idempotency=conditional")
}
if !hasPolicy {
return
}
policy, ok := raw.(map[string]any)
if !ok {
report("retry_policy must be an object")
return
}
for key := range policy {
if key != "mode" && key != "key_parameter" && key != "same_payload_required" {
report("retry_policy has unknown field %q", key)
}
}
if mode, _ := policy["mode"].(string); mode != contract.RetryModeDeduplicationKey {
report("retry_policy.mode = %q, want %q", mode, contract.RetryModeDeduplicationKey)
}
keyParameter, _ := policy["key_parameter"].(string)
if strings.TrimSpace(keyParameter) == "" {
report("retry_policy.key_parameter must be a non-empty string")
} else if paramsOK {
rawParameter, exists := parameters[keyParameter]
parameter, parameterOK := rawParameter.(map[string]any)
if !exists || !parameterOK {
report("retry_policy.key_parameter references missing parameter %q", keyParameter)
} else {
if property, _ := parameter["property"].(string); strings.TrimSpace(property) == "" {
report("retry_policy key parameter %q must have a non-empty property", keyParameter)
}
if parameterType, _ := parameter["type"].(string); parameterType != "string" {
report("retry_policy key parameter %q must have type string", keyParameter)
}
fieldProvenance, _ := parameter["field_provenance"].(map[string]any)
propertyProvenance, _ := fieldProvenance["property"].(map[string]any)
if source, _ := propertyProvenance["source"].(string); source != "native_annotation" {
report("retry_policy key parameter %q property must come from an explicit ParamDecl", keyParameter)
}
}
}
if required, ok := policy["same_payload_required"].(bool); !ok || !required {
report("retry_policy.same_payload_required must be true")
}
mode, _ := entry["interface_mode"].(string)
if mode != contract.InterfaceModeMCP || entry["interface_ref"] == nil {
report("retry_policy requires interface_mode=mcp with interface_ref")
}
}
func validateCatalogInterface(toolID string, entry map[string]any, violations *[]schemaCatalogStructureViolation) {
report := func(format string, args ...any) {
*violations = append(*violations, schemaCatalogStructureViolation{
@@ -102,6 +102,63 @@ func TestValidateCatalogStructureAcceptsValidEntry(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateCatalogStructureConditionalRetryPolicy(t *testing.T) {
valid := func() map[string]any {
entry := validCatalogToolEntry()
entry["idempotency"] = "conditional"
parameter := entry["parameters"].(map[string]any)["base-id"].(map[string]any)
parameter["property"] = "baseId"
parameter["field_provenance"].(map[string]any)["property"] = map[string]any{"source": "native_annotation"}
entry["retry_policy"] = map[string]any{
"mode": contract.RetryModeDeduplicationKey,
"key_parameter": "base-id",
"same_payload_required": true,
}
return entry
}
entry := valid()
if err := ValidateCatalogStructure(catalogPayload(t, entry)); err != nil {
t.Fatalf("ValidateCatalogStructure() error = %v", err)
}
for _, test := range []struct {
name string
mutate func(map[string]any)
want string
}{
{name: "missing policy", mutate: func(entry map[string]any) { delete(entry, "retry_policy") }, want: "requires retry_policy"},
{name: "policy on non conditional", mutate: func(entry map[string]any) { entry["idempotency"] = "unknown" }, want: "retry_policy requires idempotency=conditional"},
{name: "non object", mutate: func(entry map[string]any) { entry["retry_policy"] = "invalid" }, want: "retry_policy must be an object"},
{name: "unknown field", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["inferred"] = true }, want: "unknown field"},
{name: "bad mode", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["mode"] = "always" }, want: "retry_policy.mode"},
{name: "empty key", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["key_parameter"] = " " }, want: "key_parameter must be a non-empty string"},
{name: "missing key parameter", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["key_parameter"] = "uuid" }, want: "references missing parameter"},
{name: "missing property", mutate: func(entry map[string]any) {
delete(entry["parameters"].(map[string]any)["base-id"].(map[string]any), "property")
}, want: "non-empty property"},
{name: "non string key", mutate: func(entry map[string]any) {
entry["parameters"].(map[string]any)["base-id"].(map[string]any)["type"] = "integer"
}, want: "must have type string"},
{name: "inferred property", mutate: func(entry map[string]any) {
entry["parameters"].(map[string]any)["base-id"].(map[string]any)["field_provenance"].(map[string]any)["property"].(map[string]any)["source"] = "flag_name_inference"
}, want: "must come from an explicit ParamDecl"},
{name: "same payload false", mutate: func(entry map[string]any) { entry["retry_policy"].(map[string]any)["same_payload_required"] = false }, want: "same_payload_required must be true"},
{name: "non MCP", mutate: func(entry map[string]any) {
entry["interface_mode"] = contract.InterfaceModeLocal
delete(entry, "interface_ref")
entry["interface_reason"] = "local"
}, want: "requires interface_mode=mcp"},
} {
t.Run(test.name, func(t *testing.T) {
entry := valid()
test.mutate(entry)
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateCatalogStructure() error = %v, want %q", err, test.want)
}
})
}
}
func TestValidateCatalogStructureAcceptsOptionalResultObject(t *testing.T) {
entry := validCatalogToolEntry()
entry["result"] = map[string]any{
+51 -1
View File
@@ -62,6 +62,7 @@ type ToolSpec struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -137,6 +138,7 @@ type RuntimeToolSpecInput struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Safety contract.SafetySpec
Interface contract.InterfaceSpec
Selection contract.SelectionSpec
@@ -224,6 +226,8 @@ func (t ToolSpec) provenanceValue(field string) (any, bool) {
return t.Safety.Confirmation, true
case "idempotency":
return t.Safety.Idempotency, true
case "retry_policy":
return t.RetryPolicy, true
case "interface_ref":
return t.Interface.Ref, true
case "interface_mode":
@@ -556,6 +560,42 @@ func (t ToolSpec) Validate() error {
return fmt.Errorf("tool %s pagination cursor_parameter %q is not a declared parameter", id.CanonicalPath, pagination.CursorParameter)
}
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(t.RetryPolicy, id.CanonicalPath)
if err != nil {
return err
}
if t.Safety.Idempotency == "conditional" {
if retryPolicy == nil {
return fmt.Errorf("tool %s idempotency=conditional requires retry_policy", id.CanonicalPath)
}
} else if retryPolicy != nil {
return fmt.Errorf("tool %s retry_policy requires idempotency=conditional", id.CanonicalPath)
}
if retryPolicy != nil {
if t.Interface.Mode != contract.InterfaceModeMCP || t.Interface.Ref == nil {
return fmt.Errorf("tool %s retry_policy requires an MCP interface_ref", id.CanonicalPath)
}
var keyProperty, keyType string
var keyPropertyProvenance contract.FieldProvenance
for _, parameter := range t.Parameters {
if parameter.Name == retryPolicy.KeyParameter {
keyProperty = strings.TrimSpace(parameter.Property)
keyType = strings.TrimSpace(parameter.Type)
keyPropertyProvenance = parameter.FieldProvenance["property"]
break
}
}
if keyProperty == "" {
return fmt.Errorf("tool %s retry_policy key_parameter %q must name a declared parameter with a non-empty interface property", id.CanonicalPath, retryPolicy.KeyParameter)
}
if keyType != "string" {
return fmt.Errorf("tool %s retry_policy key_parameter %q must be a string parameter", id.CanonicalPath, retryPolicy.KeyParameter)
}
if strings.TrimSpace(keyPropertyProvenance.Source) != "native_annotation" ||
strings.TrimSpace(keyPropertyProvenance.Precedence) != "native_annotation" {
return fmt.Errorf("tool %s retry_policy key_parameter %q property must come from an explicit ParamDecl", id.CanonicalPath, retryPolicy.KeyParameter)
}
}
if t.Interface.Mode != "" || t.Interface.Availability != "" || t.Interface.Reason != "" || t.Interface.Ref != nil {
if err := t.Interface.Validate(id.CanonicalPath); err != nil {
return err
@@ -756,6 +796,12 @@ func (t ToolSpec) normalized() ToolSpec {
out.Pagination = pagination
}
}
if t.RetryPolicy != nil {
retryPolicy := *t.RetryPolicy
retryPolicy.Mode = strings.TrimSpace(retryPolicy.Mode)
retryPolicy.KeyParameter = strings.TrimSpace(retryPolicy.KeyParameter)
out.RetryPolicy = &retryPolicy
}
out.Positionals = append([]contract.RuntimeSchemaPositional(nil), t.Positionals...)
sort.Slice(out.Positionals, func(i, j int) bool {
if out.Positionals[i].Index != out.Positionals[j].Index {
@@ -990,6 +1036,10 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.Pagination)
payload["pagination"] = value
}
if t.RetryPolicy != nil {
value, _ := typedJSONValue(t.RetryPolicy)
payload["retry_policy"] = value
}
applySafetyPayload(payload, t.Safety)
applyInterfacePayload(payload, t.Interface)
applySelectionPayload(payload, t.Selection, true)
@@ -1013,7 +1063,7 @@ func (t ToolSpec) ToSummaryPayload() (map[string]any, error) {
}
for _, key := range []string{
"parameters", "has_parameters", "parameter_count", "constraints",
"positionals", "result", "examples", "effect_source", "agent_source_refs",
"positionals", "result", "retry_policy", "examples", "effect_source", "agent_source_refs",
"field_provenance", "path", "source", "product_id", "display", "is_alias",
} {
delete(payload, key)
+19 -14
View File
@@ -21,6 +21,8 @@ import (
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// SchemaMetaIndexVersion is the CommandMeta summary index format used by CI
@@ -42,19 +44,20 @@ type SchemaMetaIndexSnapshot struct {
// SchemaMetaIndexEntry is one primary-path CommandMeta record. Aliases are
// expanded into the ResolveMeta lookup at decode time.
type SchemaMetaIndexEntry struct {
CLIPath string `json:"cli_path"`
Canonical string `json:"canonical_path"`
Aliases []string `json:"aliases,omitempty"`
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Effect string `json:"effect,omitempty"`
Risk string `json:"risk,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
AgentSummary string `json:"agent_summary,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Examples []string `json:"examples,omitempty"`
CLIPath string `json:"cli_path"`
Canonical string `json:"canonical_path"`
Aliases []string `json:"aliases,omitempty"`
ProductID string `json:"product_id,omitempty"`
Title string `json:"title,omitempty"`
Effect string `json:"effect,omitempty"`
Risk string `json:"risk,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Idempotency string `json:"idempotency,omitempty"`
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy,omitempty"`
AgentSummary string `json:"agent_summary,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Examples []string `json:"examples,omitempty"`
}
// BuildSchemaMetaIndex extracts the ResolveMeta summary from a full Catalog
@@ -91,6 +94,7 @@ func BuildSchemaMetaIndex(snapshot SchemaCatalogSnapshot) (SchemaMetaIndexSnapsh
Risk: schemaString(tool["risk"]),
Confirmation: schemaString(tool["confirmation"]),
Idempotency: schemaString(tool["idempotency"]),
RetryPolicy: retryPolicyFromSchemaValue(tool["retry_policy"]),
AgentSummary: schemaString(tool["agent_summary"]),
UseWhen: schemaStringSlice(tool["use_when"]),
AvoidWhen: schemaStringSlice(tool["avoid_when"]),
@@ -203,6 +207,7 @@ func commandMetaLookupFromIndex(index SchemaMetaIndexSnapshot) (map[string]Comma
Risk: entry.Risk,
Confirmation: entry.Confirmation,
Idempotency: entry.Idempotency,
RetryPolicy: cloneRetryPolicy(entry.RetryPolicy),
},
Selection: CommandSelection{
AgentSummary: entry.AgentSummary,
@@ -294,7 +299,7 @@ func commandMetaEqual(got, want CommandMeta) error {
!metaStringSlicesEqual(got.Identity.Aliases, want.Identity.Aliases) {
return fmt.Errorf("identity mismatch: got %+v want %+v", got.Identity, want.Identity)
}
if got.Safety != want.Safety {
if !reflect.DeepEqual(got.Safety, want.Safety) {
return fmt.Errorf("safety mismatch: got %+v want %+v", got.Safety, want.Safety)
}
if got.Selection.AgentSummary != want.Selection.AgentSummary ||
+13 -2
View File
@@ -294,6 +294,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
} else if gate, ok := RuntimeContractGate(entry.Command); ok {
safety = applyContractGateToSafety(safety, gate)
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(final.RetryPolicy, canonicalPath)
if err != nil {
return ToolSpec{}, err
}
positionals := final.Positionals
if len(positionals) == 0 {
@@ -333,7 +337,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, retryPolicy, interfaceSpec, selection, final.DryRun)
result, pagination := final.Result, final.Pagination
if !output.UsesUnifiedResult(entry.Command) {
@@ -356,6 +360,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
DryRun: final.DryRun,
Result: result,
Pagination: pagination,
RetryPolicy: retryPolicy,
Safety: safety,
Interface: interfaceSpec,
Selection: selection,
@@ -402,7 +407,7 @@ func contractFinalTextProvenance(declared, cobra string, preferCobra bool) (stri
// dry_run when present), so declared leaves must emit the full set, not only
// the fields they happened to author. Title/description provenance must match
// the real text winner (cobra_help vs contract_final).
func contractFinalProvenance(identity contract.ToolIdentitySpec, title, description string, titleProv, descriptionProv contract.FieldProvenance, safety contract.SafetySpec, iface contract.InterfaceSpec, selection contract.SelectionSpec, dryRun *contract.DryRunSpec) map[string]contract.FieldProvenance {
func contractFinalProvenance(identity contract.ToolIdentitySpec, title, description string, titleProv, descriptionProv contract.FieldProvenance, safety contract.SafetySpec, retryPolicy *contract.RetryPolicySpec, iface contract.InterfaceSpec, selection contract.SelectionSpec, dryRun *contract.DryRunSpec) map[string]contract.FieldProvenance {
prov := func(value any, sourceRef string) contract.FieldProvenance {
return resolvedFieldProvenance(
value,
@@ -454,6 +459,9 @@ func contractFinalProvenance(identity contract.ToolIdentitySpec, title, descript
if dryRun != nil {
out["dry_run"] = prov(*dryRun, "corecmd.ContractDecl")
}
if retryPolicy != nil {
out["retry_policy"] = prov(*retryPolicy, "corecmd.ContractDecl")
}
return out
}
@@ -693,6 +701,9 @@ func validateFinalSchemaProvenanceCoverage(registry SchemaRegistry) error {
if tool.DryRun != nil {
require("tool "+canonical, "dry_run", tool.FieldProvenance)
}
if tool.RetryPolicy != nil {
require("tool "+canonical, "retry_policy", tool.FieldProvenance)
}
// interface_reason is part of the final interface contract only when
// the disposition requires or actually delivers a reason. An MCP or
// local available command with no reason has no resolver winner to
+2
View File
@@ -67,6 +67,7 @@ type schemaToolWire struct {
DryRun *contract.DryRunSpec `json:"dry_run"`
Result *contract.ResultSpec `json:"result"`
Pagination *contract.PaginationSpec `json:"pagination"`
RetryPolicy *contract.RetryPolicySpec `json:"retry_policy"`
Effect string `json:"effect"`
EffectSource string `json:"effect_source"`
Risk string `json:"risk"`
@@ -271,6 +272,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
DryRun: wire.DryRun,
Result: wire.Result,
Pagination: wire.Pagination,
RetryPolicy: wire.RetryPolicy,
Safety: contract.SafetySpec{
Effect: wire.Effect,
EffectSource: wire.EffectSource,
+1
View File
@@ -77,6 +77,7 @@ func resetDeliverySchemaCatalogState() {
func resetSchemaDeliveryState() {
metaByCLIPathOnce = sync.Once{}
metaByCLIPath = nil
toolCallRetryByInterface = nil
runtimeDeliverySchemaMetaIndexErr = nil
runtimeDeliverySchemaMetaIndexLazyCount.Store(0)
resetDeliverySchemaCatalogState()
+1
View File
@@ -32,6 +32,7 @@ type ContractFinalPayload struct {
DryRun *DryRunSpec
Result *ResultSpec
Pagination *PaginationSpec
RetryPolicy *RetryPolicySpec
Interface *InterfaceSpec
Selection *SelectionSpec
Identity *ToolIdentitySpec
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package contract
import (
"fmt"
"strings"
)
const RetryModeDeduplicationKey = "deduplication_key"
// RetryPolicySpec declares the reviewed condition that makes an otherwise
// non-idempotent operation safe to retry. KeyParameter names the CLI parameter;
// Schema assembly binds it to one explicit interface property before runtime.
// No flag-name or request-property inference is permitted.
type RetryPolicySpec struct {
Mode string `json:"mode"`
KeyParameter string `json:"key_parameter"`
SamePayloadRequired bool `json:"same_payload_required"`
}
// RetryDecision is the invocation-scoped safety result consumed by Agent and
// transport code. Static metadata remains unchanged; EffectiveIdempotency is
// derived only from the reviewed policy and the actual invocation arguments.
type RetryDecision struct {
EffectiveIdempotency string `json:"effective_idempotency"`
SafeToRetry bool `json:"safe_to_retry"`
Reason string `json:"reason"`
}
// NormalizeRetryPolicySpec validates and defensively copies a retry policy.
func NormalizeRetryPolicySpec(in *RetryPolicySpec, canonical string) (*RetryPolicySpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &RetryPolicySpec{
Mode: strings.TrimSpace(in.Mode),
KeyParameter: strings.TrimSpace(in.KeyParameter),
SamePayloadRequired: in.SamePayloadRequired,
}
if out.Mode != RetryModeDeduplicationKey {
return nil, fmt.Errorf("schema tool %s retry_policy has unsupported mode %q", canonical, out.Mode)
}
if out.KeyParameter == "" {
return nil, fmt.Errorf("schema tool %s retry_policy has no key_parameter", canonical)
}
if !out.SamePayloadRequired {
return nil, fmt.Errorf("schema tool %s retry_policy must require the same payload", canonical)
}
return out, nil
}
// ResolveRetryDecision derives invocation safety without mutating the static
// command contract. argumentKey is the CLI parameter name for Agent calls or
// the explicitly declared interface property for transport calls.
func ResolveRetryDecision(idempotency string, policy *RetryPolicySpec, arguments map[string]any, argumentKey string) RetryDecision {
switch strings.TrimSpace(idempotency) {
case "idempotent":
return RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "static_idempotent"}
case "non_idempotent":
return RetryDecision{EffectiveIdempotency: "non_idempotent", Reason: "static_non_idempotent"}
case "unknown":
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "idempotency_unknown"}
case "conditional":
normalized, err := NormalizeRetryPolicySpec(policy, "<invocation>")
if err != nil || normalized == nil {
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "invalid_retry_policy"}
}
argumentKey = strings.TrimSpace(argumentKey)
if argumentKey == "" {
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "deduplication_key_binding_missing"}
}
value, ok := arguments[argumentKey].(string)
if !ok || strings.TrimSpace(value) == "" {
return RetryDecision{EffectiveIdempotency: "non_idempotent", Reason: "deduplication_key_missing"}
}
return RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "deduplication_key_present"}
default:
return RetryDecision{EffectiveIdempotency: "unknown", Reason: "idempotency_unknown"}
}
}
@@ -0,0 +1,79 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package contract
import "testing"
func TestCrossPlatformCoverageNormalizeRetryPolicySpec(t *testing.T) {
if got, err := NormalizeRetryPolicySpec(nil, "chat.send"); err != nil || got != nil {
t.Fatalf("nil NormalizeRetryPolicySpec() = %#v, %v", got, err)
}
got, err := NormalizeRetryPolicySpec(&RetryPolicySpec{
Mode: " deduplication_key ",
KeyParameter: " uuid ",
SamePayloadRequired: true,
}, "chat.send")
if err != nil {
t.Fatalf("NormalizeRetryPolicySpec() error = %v", err)
}
if got.Mode != RetryModeDeduplicationKey || got.KeyParameter != "uuid" || !got.SamePayloadRequired {
t.Fatalf("NormalizeRetryPolicySpec() = %#v", got)
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: RetryModeDeduplicationKey, SamePayloadRequired: true}, "chat.send"); err == nil {
t.Fatal("empty key_parameter must fail closed")
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: RetryModeDeduplicationKey, KeyParameter: "uuid"}, "chat.send"); err == nil {
t.Fatal("same_payload_required=false must fail closed")
}
if _, err := NormalizeRetryPolicySpec(&RetryPolicySpec{Mode: "inferred", KeyParameter: "uuid", SamePayloadRequired: true}, "chat.send"); err == nil {
t.Fatal("unsupported retry mode must fail closed")
}
}
func TestCrossPlatformCoverageResolveRetryDecision(t *testing.T) {
policy := &RetryPolicySpec{Mode: RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true}
if got := ResolveRetryDecision("conditional", policy, map[string]any{"uuid": "request-1"}, "uuid"); !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("conditional with key = %#v", got)
}
for name, args := range map[string]map[string]any{
"missing": {},
"empty": {"uuid": " "},
"non-string": {"uuid": 123},
} {
t.Run(name, func(t *testing.T) {
got := ResolveRetryDecision("conditional", policy, args, "uuid")
if got.SafeToRetry || got.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("decision = %#v", got)
}
})
}
if got := ResolveRetryDecision("idempotent", nil, nil, ""); !got.SafeToRetry || got.EffectiveIdempotency != "idempotent" {
t.Fatalf("static idempotent = %#v", got)
}
if got := ResolveRetryDecision("non_idempotent", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "non_idempotent" {
t.Fatalf("static non-idempotent = %#v", got)
}
if got := ResolveRetryDecision("unknown", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" {
t.Fatalf("unknown = %#v", got)
}
if got := ResolveRetryDecision("conditional", policy, map[string]any{"uuid": "request-1"}, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" || got.Reason != "deduplication_key_binding_missing" {
t.Fatalf("missing key binding = %#v", got)
}
if got := ResolveRetryDecision("conditional", nil, nil, ""); got.SafeToRetry || got.Reason != "invalid_retry_policy" {
t.Fatalf("invalid conditional policy = %#v", got)
}
if got := ResolveRetryDecision("retryable", nil, nil, ""); got.SafeToRetry || got.EffectiveIdempotency != "unknown" {
t.Fatalf("unknown legacy value = %#v", got)
}
}
+34 -3
View File
@@ -44,12 +44,14 @@ type ContractDecl struct {
DryRun *contract.DryRunSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
RetryPolicy *contract.RetryPolicySpec
Interface *contract.InterfaceSpec
Selection contract.SelectionSpec
Identity contract.ToolIdentitySpec
}
// validateContractDecl enforces authoring-time homology for declared commands.
// validateContractDecl enforces authoring-time homology for declared commands
// and returns the normalized retry policy for the ContractFinal projection.
// A declared Contract is the sole final source for its fields: downstream
// catalog/Agent gates hard-require description and the selection prose for
// every effective tool — so a declaration missing any of these fields could
@@ -61,9 +63,34 @@ type ContractDecl struct {
// self-description: identity is collected from ContractFinal on the live
// leaves, so an incomplete or inconsistent declared Identity fails collection,
// binding, and policy downstream.
func validateContractDecl(spec Spec) {
func validateContractDecl(spec Spec) *contract.RetryPolicySpec {
idempotency := strings.TrimSpace(spec.Safety.Idempotency)
if idempotency == "conditional" && spec.Contract.RetryPolicy == nil {
panic(fmt.Sprintf("command %q Safety.Idempotency=conditional requires Contract.RetryPolicy", spec.Use))
}
if spec.Contract.RetryPolicy != nil && idempotency != "conditional" {
panic(fmt.Sprintf("command %q Contract.RetryPolicy requires Safety.Idempotency=conditional", spec.Use))
}
retryPolicy, err := contract.NormalizeRetryPolicySpec(spec.Contract.RetryPolicy, spec.Contract.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.RetryPolicy: %v", spec.Use, err))
}
if retryPolicy != nil {
declared := false
for _, parameter := range spec.Contract.Parameters {
if strings.TrimSpace(parameter.Name) == retryPolicy.KeyParameter && strings.TrimSpace(parameter.Property) != "" {
declared = true
break
}
}
if !declared {
panic(fmt.Sprintf(
"command %q Contract.RetryPolicy key_parameter %q requires Contract.Parameters with an explicit ParamDecl.Property",
spec.Use, retryPolicy.KeyParameter))
}
}
if spec.Contract.empty() {
return
return retryPolicy
}
missing := make([]string, 0, 8)
if strings.TrimSpace(spec.Contract.Description) == "" {
@@ -125,6 +152,7 @@ func validateContractDecl(spec Spec) {
"command %q Contract.Identity.CLIPath %q and PrimaryCLIPath %q must agree on the primary leaf path",
spec.Use, cliPath, primary))
}
return retryPolicy
}
// Empty reports whether no ContractDecl field was authored.
@@ -152,6 +180,9 @@ func (s ContractDecl) empty() bool {
if s.Pagination != nil {
return false
}
if s.RetryPolicy != nil {
return false
}
if s.Interface != nil {
iface := s.Interface
if strings.TrimSpace(iface.Mode) != "" || strings.TrimSpace(iface.Availability) != "" ||
+54 -2
View File
@@ -14,6 +14,7 @@
package corecmd
import (
"fmt"
"strings"
"testing"
@@ -21,6 +22,43 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
)
func TestCrossPlatformCoverageContractRetryPolicyFailsFast(t *testing.T) {
if (ContractDecl{RetryPolicy: &contract.RetryPolicySpec{}}).Empty() {
t.Fatal("RetryPolicy-only ContractDecl must not be empty")
}
assertPanic := func(name, want string, spec Spec) {
t.Helper()
t.Run(name, func(t *testing.T) {
defer func() {
recovered := recover()
if recovered == nil || !strings.Contains(fmt.Sprint(recovered), want) {
t.Fatalf("panic = %v, want %q", recovered, want)
}
}()
validateContractDecl(spec)
})
}
assertPanic("conditional without contract", "requires Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"},
})
minimal := ContractDecl{Description: "description"}
assertPanic("conditional without policy", "requires Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
minimal.RetryPolicy = &contract.RetryPolicySpec{Mode: contract.RetryModeDeduplicationKey, KeyParameter: "uuid", SamePayloadRequired: true}
assertPanic("policy without explicit ParamDecl property", "requires Contract.Parameters with an explicit ParamDecl.Property", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
minimal.Parameters = []contract.ParamDecl{{Name: "uuid", Property: "requestUuid"}}
assertPanic("policy without conditional", "requires Safety.Idempotency=conditional", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "unknown"}, Contract: minimal,
})
minimal.RetryPolicy.SamePayloadRequired = false
assertPanic("invalid policy", "invalid Contract.RetryPolicy", Spec{
Use: "send", Safety: contract.SafetySpec{Idempotency: "conditional"}, Contract: minimal,
})
}
func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *testing.T) {
cmd := New(Spec{
Use: "create",
@@ -35,11 +73,12 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
},
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "retryable",
Confirmation: "user_required", Idempotency: "conditional",
},
Contract: ContractDecl{
Title: "Create Title",
Description: "Create Desc",
Parameters: []contract.ParamDecl{{Name: "mode", Property: "mode"}},
Positionals: []contract.RuntimeSchemaPositional{{Name: "id", Required: true, Index: 0}},
DryRun: &contract.DryRunSpec{PreviewKind: "invocation", RemoteReads: true},
Result: &contract.ResultSpec{
@@ -47,6 +86,11 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
DataSchema: []byte(`{"type":"object"}`),
},
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"},
RetryPolicy: &contract.RetryPolicySpec{
Mode: contract.RetryModeDeduplicationKey,
KeyParameter: "mode",
SamePayloadRequired: true,
},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
@@ -78,7 +122,7 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
if final.Title != "Create Title" || final.Description != "Create Desc" {
t.Fatalf("title/desc = %q %q (payload stores declared Contract text; Catalog may prefer Cobra Long)", final.Title, final.Description)
}
if final.Safety == nil || final.Safety.Confirmation != "user_required" || final.Safety.Idempotency != "retryable" {
if final.Safety == nil || final.Safety.Confirmation != "user_required" || final.Safety.Idempotency != "conditional" {
t.Fatalf("safety = %#v", final.Safety)
}
if final.DryRun == nil || final.DryRun.PreviewKind != "invocation" || !final.DryRun.RemoteReads {
@@ -90,6 +134,14 @@ func TestCrossPlatformCoverageNewCommandEmbedsFullContractDeclAsFinalSource(t *t
if final.Pagination == nil || final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
t.Fatalf("pagination = %#v", final.Pagination)
}
if final.RetryPolicy == nil || final.RetryPolicy.KeyParameter != "mode" || !final.RetryPolicy.SamePayloadRequired {
t.Fatalf("retry_policy = %#v", final.RetryPolicy)
}
final.RetryPolicy.KeyParameter = "mutated"
again, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || again.RetryPolicy == nil || again.RetryPolicy.KeyParameter != "mode" {
t.Fatalf("stored retry_policy was not defensively copied: %#v", again.RetryPolicy)
}
if final.Interface == nil || final.Interface.Mode != "mcp" || final.Interface.Ref == nil || final.Interface.Ref.RPCName != "create_thing" {
t.Fatalf("interface = %#v", final.Interface)
}
+4
View File
@@ -85,6 +85,10 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
value := *in.Pagination
out.Pagination = &value
}
if in.RetryPolicy != nil {
value := *in.RetryPolicy
out.RetryPolicy = &value
}
if in.Interface != nil {
value := *in.Interface
if in.Interface.Ref != nil {
+4 -1
View File
@@ -1364,7 +1364,7 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
_, _ = short, long
// Reuse NewCommand's completeness rules so bind-time attaches cannot ship
// a partial declaration that would only fail in generated artifacts.
validateContractDecl(Spec{Use: cmd.Name(), Safety: safety, Contract: decl})
retryPolicy := validateContractDecl(Spec{Use: cmd.Name(), Safety: safety, Contract: decl})
validateSafetySpec(Spec{Use: cmd.Name(), Safety: safety})
payload := contract.ContractFinalPayload{
@@ -1394,6 +1394,9 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
}
payload.Pagination = pagination
}
if retryPolicy != nil {
payload.RetryPolicy = retryPolicy
}
if decl.Interface != nil {
iface := &contract.InterfaceSpec{
Mode: strings.TrimSpace(decl.Interface.Mode),
+20 -14
View File
@@ -19,19 +19,21 @@ import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type Invocation struct {
Kind string `json:"kind"`
Stage string `json:"stage"`
Implemented bool `json:"implemented"`
DryRun bool `json:"dry_run,omitempty"`
CanonicalProduct string `json:"canonical_product"`
Tool string `json:"tool"`
CanonicalPath string `json:"canonical_path"`
LegacyPath string `json:"legacy_path,omitempty"`
Params map[string]any `json:"params"`
Kind string `json:"kind"`
Stage string `json:"stage"`
Implemented bool `json:"implemented"`
DryRun bool `json:"dry_run,omitempty"`
CanonicalProduct string `json:"canonical_product"`
Tool string `json:"tool"`
CanonicalPath string `json:"canonical_path"`
LegacyPath string `json:"legacy_path,omitempty"`
Params map[string]any `json:"params"`
Retry *contract.RetryDecision `json:"retry,omitempty"`
}
type Result struct {
@@ -47,13 +49,17 @@ type EchoRunner struct{}
func (EchoRunner) Run(_ context.Context, invocation Invocation) (Result, error) {
if invocation.DryRun {
response := map[string]any{
"dry_run": true,
"request": ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
}
if invocation.Retry != nil {
response["retry"] = invocation.Retry
}
return Result{
Invocation: invocation,
Response: map[string]any{
"dry_run": true,
"request": ToolCallRequest(invocation.Tool, invocation.Params),
"note": "execution skipped by --dry-run",
},
Response: response,
}, nil
}
return Result{Invocation: invocation}, nil
+7 -3
View File
@@ -4,19 +4,21 @@ import (
"context"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
params := map[string]any{"name": "value"}
compat := NewCompatibilityInvocation("old path", "doc", "read", params)
if compat.Kind != "compat_invocation" || compat.CanonicalPath != "doc.read" || !reflect.DeepEqual(compat.Params, params) {
if compat.Kind != "compat_invocation" || compat.CanonicalPath != "doc.read" || compat.Retry != nil || !reflect.DeepEqual(compat.Params, params) {
t.Fatalf("unexpected compatibility invocation: %#v", compat)
}
if got := NewCompatibilityInvocation("old", "doc", "read", nil).Params; got == nil {
t.Fatal("nil compatibility params were not normalized")
}
help := NewHelperInvocation("old path", "chat", "send", params)
if help.Kind != "helper_invocation" || help.Stage != "helper_override" {
if help.Kind != "helper_invocation" || help.Stage != "helper_override" || help.Retry != nil {
t.Fatalf("unexpected helper invocation: %#v", help)
}
if got := NewHelperInvocation("old", "chat", "send", nil).Params; got == nil {
@@ -29,8 +31,10 @@ func TestCrossPlatformCoverageInvocationBuildersAndEchoRunner(t *testing.T) {
t.Fatalf("EchoRunner normal result = %#v, %v", result, err)
}
compat.DryRun = true
retry := contract.RetryDecision{EffectiveIdempotency: "idempotent", SafeToRetry: true, Reason: "static_idempotent"}
compat.Retry = &retry
result, err = runner.Run(context.Background(), compat)
if err != nil || result.Response["dry_run"] != true {
if err != nil || result.Response["dry_run"] != true || result.Response["retry"] != &retry {
t.Fatalf("EchoRunner dry-run result = %#v, %v", result, err)
}
}
+20 -1
View File
@@ -90,7 +90,26 @@ if ! jq -e --arg registry_count "$registry_count" '
(.effect == "read" or .effect == "write" or .effect == "destructive") and
(.risk == "low" or .risk == "medium" or .risk == "high") and
(.confirmation == "not_required" or .confirmation == "user_required") and
(.idempotency == "idempotent" or .idempotency == "non_idempotent" or .idempotency == "unknown") and
(.idempotency == "idempotent" or .idempotency == "non_idempotent" or .idempotency == "unknown" or .idempotency == "conditional") and
(if .idempotency == "conditional" then
(.retry_policy | type) == "object" and
(.retry_policy | keys) == ["key_parameter", "mode", "same_payload_required"] and
.retry_policy.mode == "deduplication_key" and
((.retry_policy.key_parameter // "") | type) == "string" and
((.retry_policy.key_parameter // "") | length) > 0 and
.retry_policy.same_payload_required == true and
(.retry_policy.key_parameter as $key |
(.parameters[$key] | type) == "object" and
.parameters[$key].type == "string" and
((.parameters[$key].property // "") | length) > 0 and
.parameters[$key].field_provenance.property.source == "native_annotation") and
.interface_mode == "mcp" and
(.interface_ref | type) == "object" and
((.interface_ref.product_id // "") | length) > 0 and
((.interface_ref.rpc_name // "") | length) > 0
else
(has("retry_policy") | not)
end) and
(has("use_when") and (.use_when | type) == "array") and
(has("avoid_when") and (.avoid_when | type) == "array") and
(has("examples") and (.examples | type) == "array") and
+157 -1
View File
@@ -55,6 +55,7 @@ type toolSchema struct {
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Idempotency string `json:"idempotency"`
RetryPolicy string `json:"retry_policy,omitempty"`
}
type positionalSchema struct {
@@ -355,6 +356,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Idempotency string `json:"idempotency"`
RetryPolicy json.RawMessage `json:"retry_policy"`
}
if err := json.Unmarshal(raw, &tool); err != nil {
return "", toolSchema{}, err
@@ -403,6 +405,9 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
if err != nil {
return "", toolSchema{}, fmt.Errorf("dry_run: %w", err)
}
// The outer json.Unmarshal above has already validated this RawMessage;
// canonicalization cannot fail for a decoded JSON value.
retryPolicy, _ := canonicalRawJSON(tool.RetryPolicy)
return id, toolSchema{
PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath),
@@ -417,6 +422,7 @@ func normalizeTool(raw json.RawMessage) (string, toolSchema, error) {
Risk: strings.TrimSpace(tool.Risk),
Confirmation: strings.TrimSpace(tool.Confirmation),
Idempotency: strings.TrimSpace(tool.Idempotency),
RetryPolicy: retryPolicy,
}, nil
}
@@ -568,6 +574,9 @@ func readContract(path string) (schemaContract, error) {
}
func checkCompatibility(baseline, current schemaContract) []string {
if err := validateReviewedIdempotencyTransitions(); err != nil {
return []string{fmt.Sprintf("invalid reviewed idempotency transition table: %v", err)}
}
var failures []string
for productID, oldProduct := range baseline.Products {
newProduct, ok := current.Products[productID]
@@ -591,6 +600,7 @@ func checkCompatibility(baseline, current schemaContract) []string {
func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string {
var failures []string
reviewedIdempotencyChange := compatibleReviewedIdempotencyTransition(toolPath, oldTool, newTool)
for _, field := range []struct {
name string
old string
@@ -602,12 +612,17 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
{name: "effect", old: oldTool.Effect, new: newTool.Effect},
{name: "risk", old: oldTool.Risk, new: newTool.Risk},
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
{name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency},
} {
if field.old != field.new {
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
}
}
if oldTool.Idempotency != newTool.Idempotency && !reviewedIdempotencyChange {
failures = append(failures, fmt.Sprintf("schema tool %q changed idempotency", toolPath))
}
if oldTool.RetryPolicy != newTool.RetryPolicy && !reviewedIdempotencyChange {
failures = append(failures, fmt.Sprintf("schema tool %q changed retry_policy", toolPath))
}
if oldTool.Constraints != newTool.Constraints &&
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
@@ -640,6 +655,147 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
return failures
}
const reviewedUUIDRetryPolicy = `{"key_parameter":"uuid","mode":"deduplication_key","same_payload_required":true}`
type idempotencyTransitionKey struct {
ToolPath string
From string
To string
}
type reviewedIdempotencyTransitionSpec struct {
RetryPolicy string
Reason string
}
// reviewedIdempotencyTransitions enumerates exact, independently reviewed
// safety migrations. Idempotency is an execution contract: a candidate cannot
// authorize its own change by merely adding a flag or editing a fixture. The
// authoritative compatibility wrapper builds this checker from the PR
// merge-base, so additions to this table take effect only after the governance
// PR that owns them has landed on main.
//
// A conditional migration also pins the complete canonical retry_policy. The
// policy deliberately does not claim a retry window: the current backend
// evidence only establishes that a supplied uuid is a deduplication key and
// that retries must reuse the same payload. Every unlisted tool, direction, or
// policy value remains incompatible.
var reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
{ToolPath: "chat/chat.combine_forward_messages", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.forward_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.reply_personal_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.send_personal_message", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.share_group_invite_url", From: "unknown", To: "conditional"}: {
RetryPolicy: reviewedUUIDRetryPolicy,
Reason: "A supplied uuid is the reviewed deduplication key; retry safety is conditional on reusing the same payload.",
},
{ToolPath: "chat/chat.add_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_emoji_reaction", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_group_member", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_message_favorite", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_robot_to_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.add_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.audit_join_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.chat_permission_grant", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.chat_permission_grant_cross_org_data", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.clear_conversation_messages", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_and_send_card", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_group_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_group_notice", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_smart_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.create_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.delete_conv_category", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.dismiss_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.edit_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.forward_topic", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.hide_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.quit_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.recall_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.recall_robot_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_custom_user_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_emoji_reaction", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_group_member", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_message_favorite", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_robot_in_group", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.remove_text_emotion", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.send_message_by_custom_robot", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.send_robot_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_custom_user_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_group_member_mute_list", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_group_mute", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_pin_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.set_top_conversation", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.transfer_group_owner", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.unset_pin_message", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_at_all_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_conv_member_roles", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_custom_group_role", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_icon", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_name", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_group_settings", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_red_env_notification_off", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_show_history_msg_option", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_streaming_card", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.update_user_group_alias", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
{ToolPath: "chat/chat.upgrade_group_to_external", From: "unknown", To: "non_idempotent"}: {Reason: "No reviewed deduplication key is available; automatic retry must stay disabled."},
}
func validateReviewedIdempotencyTransitions() error {
for key, transition := range reviewedIdempotencyTransitions {
if strings.TrimSpace(key.ToolPath) == "" || !strings.Contains(key.ToolPath, "/") {
return fmt.Errorf("invalid tool path %q", key.ToolPath)
}
if key.From != "unknown" {
return fmt.Errorf("%s has unsupported from value %q", key.ToolPath, key.From)
}
if strings.TrimSpace(transition.Reason) == "" {
return fmt.Errorf("%s %s->%s has no review reason", key.ToolPath, key.From, key.To)
}
switch key.To {
case "conditional":
canonical, err := canonicalRawJSON(json.RawMessage(transition.RetryPolicy))
if err != nil || canonical == "" || canonical != transition.RetryPolicy {
return fmt.Errorf("%s has a non-canonical retry policy", key.ToolPath)
}
case "non_idempotent":
if transition.RetryPolicy != "" {
return fmt.Errorf("%s non_idempotent transition has retry policy", key.ToolPath)
}
default:
return fmt.Errorf("%s has unsupported to value %q", key.ToolPath, key.To)
}
}
return nil
}
func compatibleReviewedIdempotencyTransition(toolPath string, oldTool, newTool toolSchema) bool {
transition, ok := reviewedIdempotencyTransitions[idempotencyTransitionKey{
ToolPath: toolPath,
From: oldTool.Idempotency,
To: newTool.Idempotency,
}]
if !ok {
return false
}
return oldTool.RetryPolicy == "" && newTool.RetryPolicy == transition.RetryPolicy
}
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
// RPCs share business semantics, permissions, error behaviour, or side effects,
+224
View File
@@ -177,6 +177,25 @@ func TestNormalizeCompleteSchemaPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageNormalizeRetryPolicyCanonicalJSON(t *testing.T) {
body := strings.Replace(
completeSchemaJSON,
`"idempotency":"unknown",`,
`"idempotency":"conditional","retry_policy":{"same_payload_required":true,"mode":"deduplication_key","key_parameter":"uuid"},`,
1,
)
path := filepath.Join(t.TempDir(), "schema.json")
writeTestFile(t, path, body)
contract, err := normalizeRawFile(path)
if err != nil {
t.Fatal(err)
}
if got := contract.Products["doc"].Tools["doc.create"].RetryPolicy; got != reviewedUUIDRetryPolicy {
t.Fatalf("retry_policy = %s, want canonical %s", got, reviewedUUIDRetryPolicy)
}
}
func TestSchemaCompatibilityIgnoresPositionalDescription(t *testing.T) {
directory := t.TempDir()
baselinePath := filepath.Join(directory, "baseline.json")
@@ -415,6 +434,208 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionTableIsExact(t *testing.T) {
if err := validateReviewedIdempotencyTransitions(); err != nil {
t.Fatal(err)
}
if got, want := len(reviewedIdempotencyTransitions), 57; got != want {
t.Fatalf("reviewed idempotency transitions = %d, want %d", got, want)
}
conditional := map[string]bool{}
nonIdempotent := 0
for key, transition := range reviewedIdempotencyTransitions {
switch key.To {
case "conditional":
conditional[key.ToolPath] = true
if transition.RetryPolicy != reviewedUUIDRetryPolicy {
t.Errorf("%s retry_policy = %s", key.ToolPath, transition.RetryPolicy)
}
case "non_idempotent":
nonIdempotent++
}
}
wantConditional := map[string]bool{
"chat/chat.combine_forward_messages": true,
"chat/chat.forward_message": true,
"chat/chat.reply_personal_message": true,
"chat/chat.send_personal_message": true,
"chat/chat.share_group_invite_url": true,
}
if !reflect.DeepEqual(conditional, wantConditional) {
t.Fatalf("conditional transitions = %#v, want %#v", conditional, wantConditional)
}
if nonIdempotent != 52 {
t.Fatalf("non_idempotent transitions = %d, want 52", nonIdempotent)
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionTableRejectsInvalidEntries(t *testing.T) {
original := reviewedIdempotencyTransitions
defer func() { reviewedIdempotencyTransitions = original }()
tests := []struct {
name string
key idempotencyTransitionKey
transition reviewedIdempotencyTransitionSpec
want string
}{
{
name: "invalid tool path",
key: idempotencyTransitionKey{From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "invalid tool path",
},
{
name: "invalid from",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "idempotent", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "unsupported from",
},
{
name: "missing reason",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{},
want: "has no review reason",
},
{
name: "invalid conditional policy",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "conditional"},
transition: reviewedIdempotencyTransitionSpec{RetryPolicy: `{`, Reason: "reviewed"},
want: "non-canonical retry policy",
},
{
name: "non idempotent policy",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "non_idempotent"},
transition: reviewedIdempotencyTransitionSpec{RetryPolicy: reviewedUUIDRetryPolicy, Reason: "reviewed"},
want: "non_idempotent transition has retry policy",
},
{
name: "invalid to",
key: idempotencyTransitionKey{ToolPath: "chat/chat.send", From: "unknown", To: "idempotent"},
transition: reviewedIdempotencyTransitionSpec{Reason: "reviewed"},
want: "unsupported to",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
test.key: test.transition,
}
if err := validateReviewedIdempotencyTransitions(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validateReviewedIdempotencyTransitions() error = %v, want %q", err, test.want)
}
})
}
reviewedIdempotencyTransitions = map[idempotencyTransitionKey]reviewedIdempotencyTransitionSpec{
{From: "unknown", To: "non_idempotent"}: {Reason: "reviewed"},
}
failures := checkCompatibility(baselineContract(), baselineContract())
if len(failures) != 1 || !strings.Contains(failures[0], "invalid reviewed idempotency transition table") {
t.Fatalf("checkCompatibility() failures = %v", failures)
}
}
func TestCrossPlatformCoverageReviewedIdempotencyTransitionsFailClosed(t *testing.T) {
oldTool := toolSchema{Idempotency: "unknown", Risk: "medium"}
conditional := oldTool
conditional.Idempotency = "conditional"
conditional.RetryPolicy = reviewedUUIDRetryPolicy
nonIdempotent := oldTool
nonIdempotent.Idempotency = "non_idempotent"
if failures := checkToolCompatibility("chat/chat.send_personal_message", oldTool, conditional); len(failures) != 0 {
t.Fatalf("exact conditional transition failed: %v", failures)
}
if failures := checkToolCompatibility("chat/chat.dismiss_group", oldTool, nonIdempotent); len(failures) != 0 {
t.Fatalf("exact non_idempotent transition failed: %v", failures)
}
baseline := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
"chat": {Tools: map[string]toolSchema{"chat.send_personal_message": oldTool}},
}}
current := cloneContract(baseline)
current.Products["chat"].Tools["chat.send_personal_message"] = conditional
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
t.Fatalf("exact transition through final compatibility seam failed: %v", failures)
}
tests := []struct {
name string
toolPath string
oldTool toolSchema
newTool toolSchema
want string
}{
{
name: "unlisted tool",
toolPath: "chat/chat.unreviewed_write",
oldTool: oldTool,
newTool: conditional,
want: "changed idempotency",
},
{
name: "old unconditional idempotent proposal stays blocked",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "idempotent", Risk: "medium"},
want: "changed idempotency",
},
{
name: "wrong policy",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: `{"key_parameter":"request_id","mode":"deduplication_key","same_payload_required":true}`, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "missing policy",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", Risk: "medium"},
want: "changed idempotency",
},
{
name: "reverse direction",
toolPath: "chat/chat.send_personal_message",
oldTool: conditional,
newTool: oldTool,
want: "changed idempotency",
},
{
name: "non idempotent cannot carry policy",
toolPath: "chat/chat.dismiss_group",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "non_idempotent", RetryPolicy: reviewedUUIDRetryPolicy, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "conditional policy drift after publication",
toolPath: "chat/chat.send_personal_message",
oldTool: conditional,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: `{"key_parameter":"uuid","mode":"deduplication_key","same_payload_required":false}`, Risk: "medium"},
want: "changed retry_policy",
},
{
name: "unrelated drift cannot hitchhike",
toolPath: "chat/chat.send_personal_message",
oldTool: oldTool,
newTool: toolSchema{Idempotency: "conditional", RetryPolicy: reviewedUUIDRetryPolicy, Risk: "high"},
want: "changed risk",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failures := strings.Join(checkToolCompatibility(test.toolPath, test.oldTool, test.newTool), "\n")
if !strings.Contains(failures, test.want) {
t.Fatalf("failures = %q, want %q", failures, test.want)
}
})
}
}
func TestMergeContracts(t *testing.T) {
historical := baselineContract()
current := cloneContract(historical)
@@ -2026,6 +2247,9 @@ func writeRawSchemaContractFile(t *testing.T, path string, contract schemaContra
if tool.DryRun != "" {
rawTool["dry_run"] = json.RawMessage(tool.DryRun)
}
if tool.RetryPolicy != "" {
rawTool["retry_policy"] = json.RawMessage(tool.RetryPolicy)
}
tools = append(tools, rawTool)
}
products = append(products, map[string]any{"id": productID, "tools": tools})
+16 -4
View File
@@ -841,15 +841,27 @@ func TestInterfaceIntegrityWorkflowContract(t *testing.T) {
}
schemaStep := schemaRemainder[:schemaEnd]
for _, want := range []string{
"make schema-compatibility",
`BASE_REF="$COMPATIBILITY_BASE_REF"`,
`STABLE_REF="$COMPATIBILITY_STABLE_REF"`,
`CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"`,
`authority_worktree="$RUNNER_TEMP/dws-schema-authority-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"`,
`git worktree add --detach "$authority_worktree" "$COMPATIBILITY_BASE_REF"`,
`"$authority_worktree/scripts/policy/check-authoritative-schema-compatibility.sh"`,
`--base-ref "$COMPATIBILITY_BASE_REF"`,
`--stable-ref "$COMPATIBILITY_STABLE_REF"`,
`--candidate-ref "$COMPATIBILITY_CANDIDATE_REF"`,
`trap cleanup_schema_authority EXIT HUP INT TERM`,
} {
if !strings.Contains(schemaStep, want) {
t.Errorf("Schema compatibility step missing authoritative contract %q", want)
}
}
for _, forbidden := range []string{
"make schema-compatibility",
"./scripts/policy/check-authoritative-schema-compatibility.sh",
"./scripts/policy/schema-compat",
} {
if strings.Contains(schemaStep, forbidden) {
t.Errorf("Schema compatibility step must not execute candidate checkout authority %q", forbidden)
}
}
}
func TestLocalInterfaceIntegrityUsesAuthoritativeSeam(t *testing.T) {