# 品源渠道协议 · 兼容性承诺

> 配套 [`PROTOCOL.md`](./PROTOCOL.md)。本文回答一个问题：
> **"我按今天的协议实现了客户端，品源以后改东西会不会把我的实现搞坏？"**
> 答案：**不会，只要你遵守 §4 那四条**；而品源这边受 §2/§3 约束。

---

## 1. `schema_version` 是什么

- **整数，单调递增**。出现在 `GET /api/v1/schema` 的顶层，以及**商品/店目录等列表信封**的顶层。
  ⚠️ **不是每个信封都有**：`/channel/moves`（库存流水对账口）走的是自己的轻量信封
  （`store`/`generated_at`/`items`/`next_since`），**不带 `schema_version`**。
  逐端点的真实键面见 [`PROTOCOL.md`](./PROTOCOL.md) §2.1。
- 它描述**该实例当前提供的协议面**，不是"品源软件的版本号"。
- 不同实例的 `schema_version` **可能不同**（自建/私有化实例升级节奏不同）。
  **不要**假设全网的实例版本一致。

### 1.1 ⚠️ 不可与别家版本号比较

真实事故：有消费方把品源的整数版本（`8`）与自己的协议号（`rec-1.0`）做**字符串比**，
`'8' < 'r'` 恒真 → 每条对话都误报"版本过低、信息可能不准"。

**规则**：跨命名空间**不要比大小**；同级版本（都是品源的 `schema_version`）按**数值**比。

---

## 2. 什么算"不破坏兼容"（品源可以随时做，**不升破坏性版本**）

这些是品源演进的主航道，实现方**必须**能容忍：

| 变更 | 例子 |
| --- | --- |
| **新增响应键** | 信封 5 键 → 7 键（v5 就是这样：`filters_applied` / `excluded`）；items[] 新增 `revision`（v17）；items[] 新增 `service` 块（v17，非服务恒为 `null`）；items[] 新增 `channel_category`（v17/R-105，没配/没命中恒为 `null`） |
| **新增字段** | 词汇表从 30 → 37 个字段 |
| **新增插件字段（Pack，2026-10-28）** | `items[] += packs / pack_fields / pack_fields_structured / pack_field_meta`；**`fields` 基面不变**；`/channel/stores` 每条 `+= packs`；`/channel/field-rules += packs / requested_packs`；新增 `/channel/packs`。插件包版本（`PackRelease.version`）**独立于** `schema_version`。不读这些键的客户端零改动；读的客户端按 `packs` 判断字段归属 |
| **服务模式与知识块（2026-10-28）** | `items[].service += mode / project / sla`（非工程 `project=null`，键面稳定）；`items[].knowledge` 新增（非知识 `null`）。`/channel/availability` 对工程/咨询回 400 + `mode` + `reason`（不是空 slots）。老服务没有 `mode` 时按 `appointment` 读 |
| **新增需求/供应商/基准/验收读口（2026-10-28，Phase 6）** | `/channel/requirements`（GET/POST）、`/channel/requirement-quotes`、`/channel/suppliers`、`/channel/benchmarks`、`/channel/acceptance/templates`、`/channel/acceptance/runs`。走 `X-Channel-Key` + 能力位（`requirement_read` 等），**不声明能力位的旧钥行为不变**。写口只有「建需求」，落 `draft`；证据不出 `file_path` |
| **`packs[].requirement_schema`（2026-10-28）** | `/channel/packs?store=` 每个包里新增 `requirement_schema`（空 `{}` 合法）。不读它的客户端零改动 |
| **新增可选请求参数** | 新增 `?tag=`（与已有 `?q=` 互补）；新增 `?updated_since=`（v17 增量，不传=全量） |
| **新增端点** | `/api/v1/channel/availability`（v17，服务可预约时段）；`/api/v1/channel/profile`（v17，渠道能力声明）；`/api/v1/channel/tools` 与 `/tools/call`（v17，MCP 形状的工具接口） |
| **新增端点** | 新增 `/channel/moves`、`/channel/events` |
| **新增字段属性** | `field_meta` 里新增 `verified_method` |
| **某个字段的 `level` 从严变松** | `recommended` → `optional` |
| **数值精度提高 / 取值域放宽** | 允许更多小数位（v10：数量字段由整数改为支持 3 位小数） |
| **新增枚举值** | `kind` 增加一个新的合法取值 |

**实现方必须遵守（否则上面每一条都会把你搞坏）**：

1. **容忍未知键**：解析 JSON 时**忽略**认不出的键，**不要**用"键集合必须完全相等"来校验。
2. **不要硬编码字段清单 / 店清单 / 类目清单**：一律运行时读 `/schema`、`/channel/stores`、
   `/channel/categories`。硬编码的那份**必然**漂移。
3. **不要硬编码枚举全集**：新增枚举值是允许的；遇到不认识的枚举值**应当**当"未知"处理并跳过，
   而不是抛错中止。
4. **不要解析游标**：游标是不透明字符串，只回传。

### 2.1 关于数字类型的额外承诺（**v10 起**）

"精度提高"这一类变更最容易伤人的地方是**JSON 里的数字类型**：
`148`（int）与 `148.0`（float）在很多语言里是同一个数，但在**严格判断类型**的实现里
（`isinstance(x, int)`、Java 的 `Integer`、Go 的 `int64` 反序列化）是两种东西，
会直接崩或静默变成 0。

所以品源在 v10 明确承诺：

> **整数值一律出 JSON 整数**（`148`，不是 `148.0`）；
> **只有真的有小数时才出小数**（`1.5`）。

这条不需要实现方做任何事 —— 它是品源这一侧的义务。数量字段的清单见
[`PROTOCOL.md`](./PROTOCOL.md) §2.5。

### 2.2 新增字段的 `level` 从宽起步

新字段**不会**一上线就是 `required`（那会让已上架商品突然"不达标"，属 §3 的破坏性变更）。
品源的做法是：先以 `recommended`/`optional` 上线，**在 `/schema` 的 `vN` 段里写明计划升格的版本**。
例：`pricing_basis`（v10 新增）在 v10 段里就写明"计划自 v12 起升为 `required`"，v12 兑现（见 §3.2）。

⚠️ **这条公告是承诺，不是备忘**：写进 `vN` 段 = 提前告知，中间相隔的版本数就是实现方的准备窗口。
已经宣布过的升格**不会**因为"没人看公告"而取消 —— 但品源**必须**在真正生效的那个版本段里
再写一次（§3.1 第 1 条），并说明影响面与迁移建议。

---

## 3. 什么算"破坏兼容"（品源必须走弃用周期）

| 破坏性变更 | 说明 |
| --- | --- |
| **删除或重命名**已有响应键（含信封七键、`items[].fields.<key>`） | 消费方逻辑会静默拿到 `None` |
| **改类型** | 如 `string` → `number`，或 `taglist` 由字符串变数组 |
| **缩小取值域 / 收紧校验** | 原来合法的请求变成 `400` |
| **改变语义**（同名不同义） | 如 `code` 的作用域由店内变全局 |
| **删除端点** | — |
| **`level` 由松变严**（`optional` → `required`） | 会让已上架商品突然"不达标" |

### 3.1 弃用周期

出现破坏性变更时，品源**必须**：

1. 在 `/api/v1/schema` 里**新增该版本的 `vN` 段**，写明：**改了什么、为什么、替代方案**；
   （`v3`–`v8` 段就是按这个格式累积下来的，可直接当先例看）
2. 旧形态**至少保留 2 个 `schema_version`**，期间**双写**（新旧键同时存在）；
3. 删除前在 `vN` 段里标注**计划移除的版本号**。

**没有 `vN` 段说明的破坏性变更 = 品源的 bug**，请按 bug 报回来。

### 3.2 已发生的变更登记（**这里必须是诚实的**）

#### v17/R-105 · 渠道档案两项声明生效 + `status=disabled` 语义收紧（**行为变更 · 渠道对接方必读**）（2026-10-20）

| 项 | 内容 |
| --- | --- |
| **改了什么** | ① `category_map` 命中时新增 `items[].channel_category`（`fields.category` 不动）；② 档案 `rate_per_min` 与钥上的值**取更严者**，429 文案报实际生效额度；③ `status=disabled` 的档案现在**拒绝该渠道其余端点（403）**，此前它等同“没有档案=不限制” |
| **对消费方的影响** | ① 纯增键，按 §2 忽略未知键即可；② 若档案限额低于钥限额，实际可用额度会变小（只会更严）；③ 被停用的渠道从“不限制”变成“全部 403” —— 这是修正，不是放宽 |
| **谁能读到** | `/channel/products`（`channel_category`）；所有渠道口的 429 文案；`/channel/profile` 仍可读，能看到 `status=disabled` |
| **为什么** | 声明了却不生效的配置比没有配置更坏：运营以为限流/停用已生效，实际没有 |

#### v15 · 新增 `price_valid_until`（价格有效截止）+ 新开 ACT 目录口（**纯增式**）（2026-10-15）

一次版本里有两件事，**一件纯增、一件新开端点**，分开写清楚：

**（a）纯增式：新增 `price_valid_until`**

| 项 | 内容 |
| --- | --- |
| **改了什么** | 新增字段 `price_valid_until`（`ftype=date`、`level=optional`、`kind_scope=product,service`、`grp=价格`、永久 `role=other`） |
| **对老消费方的影响** | **没有**：`/schema` 的字段表多一个键（词汇表是公开的，按本文件规矩必须登记），其余渠道口键面不变 |
| **谁能读到它** | `/channel/catalog`（ACT 目录口）出 `price_valid_until` + `price_state`；`/channel/products` 的 `items[].fields` 里自然也带它（出库键=全字段减去 `NEVER_OUT_KEYS`） |
| **为什么加** | ACT `CID-MER-CAT` 推荐项②要的是「价格有效截止时间**或**报价更新时间」。品源此前只有后者 —— 而「这份报价**什么时候写的**」不等于「这份报价**还算不算数**」，买方做支付前确认要的是后者 |
| **为什么 optional** | 同 `cost` 那条：逼填只会得到假数，而**假的有效期**会让"这份报价还算数"变成错误的安全感 |
| **为什么 `role=other`** | 标 `price` 会进 `EXPECTED_NUMERIC_KEYS` / `keys_of("price","measure")` 那条数值路径（`?price_max=` 预筛、单价折算），在日期上做数值运算必然坏；标 `expiry` 会与 `shelf_life`（保质期）撞义 |
| **★ 口径（这条最要紧）** | **只有"已过期"会阻塞**：`price_state=expired` ⇒ 该条 `catalog_ready=false` 且 `missing_required` 含 `price`。依据是 ACT 必备项④用「**应**能支撑……支付前确认」—— 作废的标价在定义上就不满足它。而商家**没填**（`unknown`）**不阻塞**：ACT 把有效期列为推荐项（「**宜**」），且"未知就不许下单"是买方策略，不是我们的（本仓口径：只出数据不出策略） |

**（b）新开端点：`GET /api/v1/channel/catalog`（ACT `CID-MER-CAT` 兼容目录）**

| 项 | 内容 |
| --- | --- |
| **改了什么** | **新增**一个渠道读口，出 ACT 协议要的形状（必备四项 `id`/`name`/`category`/`price` + 推荐项） |
| **对老消费方的影响** | **没有**：新路径、新键面，不动任何既有端点的合同。它与 `/channel/products` 只有四个字段重叠，**刻意独立**（理由同 §8.2：同名不同义最容易出错） |
| **鉴权/分页** | 与其余渠道口**逐字一致**（`X-Channel-Key`，缺钥/错钥 `401`；看不见的店与不存在的店**同形 `404`**；游标分页） |
| **为什么不违反"协议不定义目录"** | ACT 自己明说「**本组件当前版本暂时不定义统一的目录协议**」「暂不对调用路径/请求方法/认证机制/分页方式作统一规定」—— 沿用既有渠道口正是它允许的 |
| **不出什么** | 进价（`NEVER_OUT_KEYS`）；不出推荐排序与策略（ACT 把比较与决策划在规范范围之外） |

**（c）同版本的相关改动**

- `/schema` 返回的 `schema_version` 与 `field_version` 均为 **15**；字段数 45 → **46**。
- `tools/conformance.py` 新增 **G 组（8 条）**判 ACT 目录口；带钥总条数 22 → 30。
  ★ 其中 `G5` 含一条「**已过期的标价不得报 `catalog_ready: true`**」，
  依据同上面那条必备项④的口径 —— 它**只看 ACT 侧的 `price_valid_until`**，
  不看品源自己的 `price_state`，所以对第三方实现同样成立。


#### v14 · `/channel/aggregate` 由 `501` 改为**已实现**（**行为变更 · 实现方必读**）（2026-09-24 / R-89）

| 项 | 内容 |
| --- | --- |
| **改了什么** | 该端点从「已预留、未实现（带有效钥 `501`）」变成**真实现**：带有效钥 `200`，响应含 `n_stores` / `sample_ok` / `dist` / `by_basis` / `readiness` |
| **谁会受影响** | 任何**按 `501` 判断"这个能力还没做"**的实现方 —— 现在必须改成按 `sample_ok` 判断"有没有数据" |
| **口径要点** | **样本不足 → `200` + `dist: null`**（不是 `501`、不是错误）；指标不认识 / `key` 写错 → `400`；`k_min` **只能往上调**（低于默认按默认执行） |
| **保护没变** | k-匿名（独立店数 < `k_min` 一个数都不出）· 只出统计不出个体（**无 min/max**）· 只统计 opt-in 的店 · 范围只许收窄 |
| **为什么登记** | `501` → `200` 是**实打实的行为变更**：同一个请求从"失败"变成"成功但可能没数据"，实现方不看这条会误判 |


#### v14 · 新增 `cost`（进价）—— 纯增式，且**对消费方不可见**（2026-09-24）

| 项 | 内容 |
| --- | --- |
| **改了什么** | 新增字段 `cost`（进价，`decimal`、`level=optional`、`kind_scope=product`） |
| **对消费方的影响** | **没有**：它**不进 `/channel/*` 出库**，也不参与聚合 —— 任何消费方都读不到它 |
| **那为什么登记** | 因为 `/schema` 的字段表**多了一个键**（词汇表是公开的）。按本文件的规矩：改了什么就登记什么，即使"看不见" |
| **为什么加** | 没有进价，定价建议只能做相对调价、算不出毛利，给不出「再降就亏了」 |
| **为什么 optional** | 逼填只会得到假数；假进价比没有进价更坏（会把"再降就亏了"变成错误的安全感） |
| **红线** | `cost` **永不参与聚合、永不出库**；实现方式是唯一一份例外清单 + 结构守卫（不是靠界面） |


#### v13 · 新增 5 个服务字段 + 类目专属字段的判定口径收紧（2026-09-24）

一次版本里有两件事，**性质不同**，分开写清楚：

**（a）纯增式：5 个服务字段**

| 项 | 内容 |
| --- | --- |
| **改了什么** | 新增 `service_content` / `service_scope`（**required**）、`duration` / `delivery` / `booking`（recommended），全部 `kind_scope="service"` |
| **为什么** | R-70 给商家端加了「产品/服务/知识」开关，但**服务这条线当时只有开关、没有字段**（`kind_scope` 含 `service` 的只有 `price` 与 `pricing_basis`）—— 服务只是"被豁免了商品字段的商品"。口径来源=官网「服务类 6 字段」，用户 2026-09-24 拍板为**正式口径** |
| **兼容性** | **兼容**：老消费方忽略新键即可；只有 `?kind=service` 的 `field-rules` 会多出这几项 |
| **影响面** | 已有 `service` 条目**完整度下降**（两个新 required 没填）——**那是真的缺，不是回归** |

**（b）行为变更：`category` 传空串不再等于"不过滤"**（**这一条会改变判定结果，实现方必读**）

| 项 | 内容 |
| --- | --- |
| **改了什么** | `/channel/field-rules` 与完整度判定：`category=`（空串）现在表示「**这条没有类目 → 排除所有挑类目的字段**」；此前它与"不传"等价（都不过滤） |
| **为什么** | 实测（2026-09-24）：`kind=service`/`kind=knowledge` 的条目被判「缺 `origin`（产地）」→ 消费侧按 `missing_required` 拦 → **服务与知识一条都推不出来**。同一个 bug 也让"未归类商品"被要求"厨房用品的规格"等**假缺口** |
| **兼容性** | **行为变更**（判定结果会变），但**不是**破坏性键面变更：键面/类型/端点全没动。**判定结果只会变"更准"**：不再报那些本来就不适用的字段 |
| **实现方怎么做** | 用条目自己的 `kind` + `category` 去问 `field-rules`（**带上** `category=`，空串也要带）。别自己拼合并逻辑 |
| **不受影响** | 有类目的商品：`粮油米面` 仍然要 `origin`（原语义一字未动，有测试钉着） |


#### v12 · `pricing_basis` 由 `recommended` 升为 `required`（2026-09-23）

| 项 | 内容 |
| --- | --- |
| **改了什么** | 一个**已有字段**的门槛：`/channel/field-rules` 里它从 `recommended` 移到 `required` |
| **为什么** | `price=18` 没有分母就不可比（元/kg 与元/件求平均是没有意义的数）——**跨商家聚合的前置条件**，不升，聚合的第一批数字就是脏的 |
| **公告** | v10 段（`/schema` 的 `changes.v10`）早就写明"计划自 v12 起升为 `required`" = 提前 2 个版本告知 |
| **影响面** | 缺 `pricing_basis` 的商品**完整度下降**，在 `min_level`/`min_completeness` 过滤下可能掉档 |
| **不是**数据丢失 | 商品、价格、库存**一个字节都没变**；变的是"这件商品算不算完整" |
| **不受影响** | `kind="knowledge"`（`kind_scope="product,service"`：知识不按件也不按公斤卖） |
| **迁移建议** | 实现方**应当**读 `/channel/field-rules` 而不是记住这份清单 —— 读到 `required` 就按缺项处理 |

⚠️ **一个"反着做"的决定，写在这里以免被当成疏漏**：品源**没有**为存量商品按 `unit_kind`
推定补值。v10 当时补过一轮（那时它只是 `recommended`，粗略的推定好过什么都没有）；
但一旦升为**必填**，一个**猜错**的值就不再是聊胜于无，而是"**必填字段里的错值**"：

    · 空着 → 完整度掉档、商家端编辑时被要求补 → **错误显形，会被修**；
    · 猜成 `piece` → 全库看起来达标，而散称品的价被当成"每件"参与比价与聚合
      → **错误隐形，永远不会被修**。

这与 `/channel/field-rules` 的"宁缺勿滥"是同一条纪律。代价是升级后会有一批商品掉档 ——
**那是真的缺，不是回归。**

---

## 4. 实现方自我保护清单（**这四条让你永不被搞坏**）

1. **容忍未知键**（§2 第 1 条）。
2. **运行时读元数据**：词汇表读 `/schema`，门槛读 `/channel/field-rules`，店读 `/channel/stores`。
3. **按 `schema_version` 声明最低要求**：启动时检查 `schema_version >= N`，不满足就**明确报错**
   （而不是继续跑然后算出错数据）。
4. **`4xx` 与 `5xx` 分开处理**：`4xx`（除 `429`）不重试；`5xx` 退避重试；**绝不要**把 `5xx`
   降级成"没有数据"（那会把服务端故障伪装成"这家店没商品"）。

自查：跑 [`tools/conformance.py`](../tools/conformance.py)。

---

## 5. 兼容性声明的边界（诚实说明）

- **本承诺覆盖**：`/api/v1/channel/*` 与 `/api/v1/schema`。
  管理端接口（`/api/v1/admin/*`）与商家端接口**不在**本承诺内，可随时变更。
- **不承诺**：字段的**业务口径**永不变化（例如某行业模板把某字段从推荐改为必填）。
  这类变化会通过 `field-rules` **显式**体现，实现方**应当**读它而不是记住它。
- **不承诺**：`generated_at` 的时区表述、`detail` 文案的**逐字**稳定（文案是给人看的，
  判断请依据**状态码**，不要用字符串匹配文案）。

---

## 6. 参考实现与许可

- 消费侧参考实现：[`tools/pyclient.py`](../tools/pyclient.py)（纯标准库，Python 3.6+）。
- 一致性套件：[`tools/conformance.py`](../tools/conformance.py)。
- **许可**：[`tools/LICENSE`](../tools/LICENSE) —— **允许任何人免费实现兼容的客户端或服务端**，
  包括商业用途；无需授权、无需署名（但欢迎告知，便于互相验证）。
