# 品源渠道协议（Pinyuan Channel Protocol）

> 版本：`schema_version = 15` · 状态：**稳定，可用于生产对接**
> 本文件是**规范性文档**（normative）。文中「必须 / 应当 / 可以」分别对应 MUST / SHOULD / MAY。
> 自描述端点：`GET /api/v1/schema`（**公开，无需密钥**）——机器可读的事实源。
> 兼容性承诺见 [`COMPATIBILITY.md`](./COMPATIBILITY.md)；参考实现见 [`../tools/pyclient.py`](../tools/pyclient.py)。

---

## 0. 这份协议是什么

品源是一个**产品/服务主数据数据库**。这份协议规定**任何程序如何读取（以及有限地写入）其中的数据**，
目标是让第三方智能体开发者做到：**接一家店 = 接所有店**。你实现一次客户端，就能对接所有品源实例。

一句话分工：

| 谁 | 负责什么 |
| --- | --- |
| **品源** | 把商家的产品/服务数据变成**字段完整、机器可读**的事实；本协议是它的读口 |
| **你（实现方）** | 按本协议实现客户端或服务端；**不需要**理解品源内部的数据结构 |

### 0.1 怎么宣称"兼容"

你的实现可以用本仓库的 [`tools/conformance.py`](../tools/conformance.py) 自证：

```bash
python3 tools/conformance.py --base https://<你的实例> --key <渠道密钥>
```

全部 `PASS`（`SKIP` 不计为失败）即符合本协议。**本协议允许并鼓励任何人免费实现兼容的客户端或服务端**，
许可见 [`../tools/LICENSE`](../tools/LICENSE)。

### 0.2 词汇表以 `/api/v1/schema` 为准（**重要**）

**本文件刻意不重复列出字段清单与单位枚举。** 事实源是 `/api/v1/schema` 的 `fields`：

```jsonc
// GET /api/v1/schema  →  {"schema_version": 15, "fields": [ ... ], ...}
{"key": "weight_g", "label": "净含量", "ftype": "number", "required": true,
 "grp": "规格", "level": "required", "unit": "g", "options": null}
```

| 字段属性 | 含义 |
| --- | --- |
| `key` | 该字段在 `items[].fields` 里的键名 |
| `ftype` | 值类型：`text` / `number` / `taglist` / `list` / … |
| `level` | 门槛：`required`（必填）/ `recommended`（推荐）/ `optional`（可选） |
| `grp` | 分组（给人看的分区） |
| `unit` / `options` | 单位、受控词表（`null` 表示自由文本） |

**为什么这样设计**：如果每个实现方各自抄一份字段表，抄的那份必然与品源漂移（历史上真实发生过：
消费方把 `org_uid` 写成 `org_id`，取出来永远是 `None`，整条连锁对齐静默失效）。
所以**运行时读 `/schema`**，不要硬编码。

---

## 1. 传输与鉴权

### 1.1 基本约定

| 项 | 规定 |
| --- | --- |
| 协议 | HTTPS（生产）；本地开发可 HTTP |
| 前缀 | **`/api/v1`** |
| 编码 | UTF-8 |
| 请求/响应体 | JSON（`Content-Type: application/json`） |
| 鉴权 | 请求头 **`X-Channel-Key: <渠道密钥>`** |

### 1.2 密钥可见范围（**必须先理解这一条**）

一把渠道密钥**决定它能看到哪些店**：

- 密钥绑定到**一个组织**（org）与**一个或多个店**；
- 请求里的 `?store=<店码>` **可以省略**：省略时 = 该密钥**可见的全部店**（跨店视图）；
- 指定了**不可见**的店 → `404`（不是 `403`：**不泄露"这家店存在但你看不到"**）。

### 1.2.1 跨店视图的 `discoverable` 规则（**最容易踩的一条**）

跨店可见性按**品级开关** `discoverable` 收口，口径取决于**本次请求是否跨店**：

| 请求 | 返回 |
| --- | --- |
| 带 `?store=X`（单店） | 该店**全部**商品（调用方对这店本就有权限） |
| **不带 `?store`，跨多家店** | **只出 `discoverable=true` 的商品** |

`Product.discoverable` **默认 `false`**。理由（契约原文）：

> "一家店的货源默认对全体同行可见，等于把商家的采购清单公开。要跨店被搜到，得商家自己开。"

**实现方必须知道的两件事**：

1. **跨店结果"少"通常不是故障，而是商家没开这个开关。** 排查顺序：先查该商品的 `discoverable`，
   再怀疑协议或服务端。
2. **同一批数据，单店视图与跨店视图的条数会不同** —— 这是**规定行为**，不是不一致。
   不要用"两个视图条数应该相等"来写断言。

> 因此**不要在客户端硬编码店清单** —— 用 `GET /channel/stores` 拉当前密钥可见的店目录。

### 1.3 限流

| 项 | 规定 |
| --- | --- |
| 粒度 | **每把渠道密钥**（不是每 IP） |
| 窗口 | 固定 **60 秒** |
| 额度 | 由密钥配置（`rate_per_min`）；管理台配的数字**就是**真实承诺 |
| 超出时 | `429`，响应体含 `detail` 与 **`retry_after`（秒，在 body 里）** |

```json
{"detail": "超出限流 120 次/分", "retry_after": 37}
```

⚠️ **`retry_after` 是响应体字段，不是 `Retry-After` 头。** 实现方请读 body。

**两层限流，两层都是这个形状**（★ 2026-10-03 补齐）：

| 哪一层 | 触发条件 | `retry_after` 的精度 |
| --- | --- | --- |
| 应用层（品源服务） | 每钥每窗口的额度（`rate_per_min`） | **精确**（该窗口剩余秒数） |
| **边缘层**（nginx `limit_req`） | 每钥 60r/m + 突发 10 —— 挡在应用之前 | **上界**（给的是一个窗口 = 60s；边缘层不暴露精确值） |

> ★ **为什么要把边缘层也写出来**：这条是**第二个实现在生产上撞出来的**。
> 在本机 dev 上永远撞不到边缘限流，只有真跑生产才会 —— 而当时边缘层的 429 返的是
> **nginx 默认 HTML 页**（没有 `detail`、没有 `retry_after`），客户端**拿不到退避时间**。
> 也就是说：§1.3 当时承诺的这件事**在边缘层是假的**。
> 已修：`install/pinyuan.conf` 给边缘 429 配了 JSON 处理器（`install/nginx-json-429.sh`），
> 实测边缘 429 现在返回
> `{"detail":"请求过于频繁（边缘限流）。请按 retry_after 秒后重试。","retry_after":60}`。
>
> **实现方要注意的一点**：正因为有两层，`retry_after` 的语义应理解为
> "**至少**等这么久"而不是"精确倒数"—— 按它退避即可，不要用它做对时。

**批量同步注意**：默认 100 条/页会让一次全量同步消耗大量配额。请使用 `?page_size=`（上限 500），
见 §3.4。

### 1.4 渠道档案（`/channel/profile`，**v17 新增**）

每个渠道可以声明自己能读什么、能不能写、看哪些字段：

| 字段 | 含义 |
| --- | --- |
| `capabilities` | `catalog_read` / `stock_write` / `consumer_write` / `metrics_write` / `attribution` / `availability` 等。**空 = 不限制**；一旦声明就是显式白名单，没写的能力视为未开通（对应端点回 403） |
| `field_allow` | 字段白名单；**空 = 全部**（仍受全局保密字段约束） |
| `field_deny` | 字段黑名单；**永远优先于 allow** |
| `category_map` | 店内类目 → 渠道类目映射（**v17/R-105 起生效**）：命中时在条目里新增 `channel_category`；`fields.category` **不变** |
| `rate_per_min` / `auth_method` | 渠道级限流声明（**v17/R-105 起生效**）：与钥上的值**取更严的那个**；`auth_method` 当前只支持 `channel_key` |
| `status` | `active` = 正常；`disabled` = **拒绝该渠道的其余端点（403）** —— 停用不是“回到不限制”。`/channel/profile` 自己仍可读，渠道能看见自己被停用 |

`GET /api/v1/channel/profile` 返回本钥所属渠道的档案；没有档案时 `declared=false`、
能力全为 true（存量行为不变）。**这个口本身不受能力开关/停用限制** —— 渠道得能读到自己的能力。

**全局保密字段不受档案影响**：`cost`（进价）、`lic_no`、`fs_lic_no`、`pay_account`、
`delivery_mode` 即使在 `field_allow` 里也**永不出现**（见 §2.9）。

#### 1.4.1 `checkout` 段：结算入口模板（**T-23 新增，2026-11-05**）

顾客点「确认购买」时要跳去的 H5 **由品源按渠道配**（`ChannelProfile.checkout_template`），
消费方从本口读它、拼上归因记号再跳。响应里多一段（**additive**）：

```json
"checkout": {
  "template": "https://m.example.cn/mp/app/?tenant={store}&att={token}#/pages/index/index",
  "template_valid": true,
  "placeholders": ["{token}", "{store}", "{sku}", "{qty}"],
  "token_required": true,
  "why_not": null
}
```

| 情形 | `template` | `template_valid` | `why_not` |
| --- | --- | --- | --- |
| 配了且含 `{token}` | 模板串 | `true` | `null` |
| 配了但没 `{token}` | 模板串（**原样返回**，让人看见自己配错成什么） | `false` | `"模板里没有 {token} 占位符：发出去的链接归不了因"` |
| 空 | **`null`** | `false` | `"品源没有给这个渠道配结算入口（checkout_template 为空）"` |
| 渠道未登记（`declared: false`） | **`null`** | `false` | `"渠道未登记"` |

**这段的键始终齐（五个键一个不少），包括 `declared: false` 那条分支。**
"键不存在"只能表示"品源不是这个版本"，**不许**用它表示"没配"。

**`template` 为空必须是 `null`，不是 `""`** —— `""` 会被语言层面的 falsy 判断
（`'' || env`）与"字段不存在"混成一件事，于是消费方静默回落到自己的环境变量，
而"品源明确说没有"与"品源版本太旧"的修法完全不同（前者去管理台配，后者去升级品源）。
**这正是 T-23 要治的那族病。**

**占位符只有四个，且顺序固定**：`{token}`（必需）、`{store}`、`{sku}`、`{qty}`。
消费方按 `placeholders` 逐个替换；新增第五个 = 两仓同时改，别偷偷加。
写入时**不强制**含 `{token}`（运营可以先存草稿），校验在下发时如实报告 `template_valid`。

**消费方纪律（两条，写在这里是因为它们跨仓）**：
1. `template` 非 `null` **就用它**，**不看**自己的环境变量（env 只作"品源读不到"时的兜底）；
2. `template is null` → **明说"品源没给结算入口"**，**绝不**回落 env —— 回落了就是"配错不报错"。

`/.well-known/rec-agent` 只声明**模板住在哪**（`checkout.template_endpoint` = 本口）、
叫什么占位符，**不含任何模板值**：那个口不要钥、不含租户数据，而模板里带商城域名与租户参数。

### 1.5 接入自检（`/channel/agent-selfcheck`，**T-24 新增，2026-11-05**）

接一个智能体（或任何一个消费方）**之前**先打这一句。它把"我配好了吗"一次问完 ——
因为这四件事**没有一件会抛异常**，失败形态全都是静默的：

| 判据 | 为 `false` 的意思 | 后果（**一个月后才发现**） |
| --- | --- | --- |
| `channel_registered` | 品源侧没有这个渠道码的档案（或档案已停用） | 成交归不到这个渠道，月报里这一栏永远是 0，**事后洗不回来** |
| `key_usable` | 渠道声明了写能力（回执/台账），但这把钥 `can_write=false` | 目录照读、回执全 `403`，数据是 0 |
| `checkout_template` | 没配结算入口，或配了但里面没有 `{token}`（见 §1.4.1） | 链接能打开，但归不了因 |
| `token_exchangeable` | 该店的归因渠道字典里没有这个 `code`（或已停用/过期） | 一笔成交都汇报不上去 |

```json
{
  "channel": "阿宝",
  "store": "S001",
  "checked_at": "2026-11-05T10:00:00+08:00",
  "channel_registered": true,
  "key_usable": true,
  "checkout_template": false,
  "token_exchangeable": true,
  "details": {
    "channel_registered": {"declared": true, "status": "active", "note": "渠道档案已登记且启用"},
    "key_usable": {"can_write": true, "scope": "store", "authorization": "active",
                   "rate_per_min": 60, "note": "钥可用于 stock_write / consumer_write"},
    "checkout_template": {"template_valid": false, "note": "品源没有给这个渠道配结算入口（checkout_template 为空）"},
    "token_exchangeable": {"attribution_channel": "阿宝", "usable": true,
                           "note": "只做前置条件判定，未真的签发记号"}
  },
  "why_not": {"checkout_template": "品源没有给这个渠道配结算入口（checkout_template 为空）"},
  "notes": ["本口无副作用：不签发记号、不写回执、不计用量。"]
}
```

**键面固定**：`channel` / `store` / `checked_at` + **四项顶层布尔** + `details` / `why_not` / `notes`。
`details` 四项**始终全在**（判据为 true 也有，附 `note` 说明为什么算通过）；
`why_not` **只放为 `false` 的那几项**，全绿时是 `{}`（空对象，不是 `null`）。

**为什么 `why_not` 要给"人话 + 修法"而不只是布尔**：人来接智能体时是拿着清单逐条勾的。
只报"`false`"，对方还得回来问"哪个 false、为什么" —— 一次只报一条错，要来回四轮。
所以每一条都带"缺什么 / 去哪儿补"（`AttributionChannel`、管理台的渠道档案、结算模板）。

#### 鉴权与状态码

- **借最弱的那一档 `catalog_read`**：一把只能读目录的钥也必须问得动自检。
  要求 `stock_write` 会把**只读部署**挡在门外 —— 而只读部署恰恰最需要自检。
  用途不含 `catalog_read` 的钥回 `403`（它连目录都读不了，配没配好都不重要）。
- **停用的渠道档案也放行（刻意）**，与 `/channel/profile` 同一个理由：不给这个例外，
  `channel_registered` 就只剩"没登记"一种答案，**"被停用"永远报不出来**（鉴权层就 `403` 了）——
  而"档案被停用"正是最典型的静默失败：别的端点全 `403`，只有这个口能说清为什么。
  它**不多给任何东西**：响应里没有商品/成交/会员数据，比 `/channel/profile` 出的更少。
- 状态码：缺钥/钥无效/钥停用 `401`；用途不含 `catalog_read` `403`；超限流 `429`；
  `store` **看不见或不存在统一回 `404` + `{"detail":"店铺不存在"}`**（两者分开就是个店码枚举口）。

#### `?store=` 的口径

- 给了 `store`：按它判第④项（看不见 → `404`，同上）。
- 省略：**只在该钥恰好看得见 1 家店时**替它选；看得见多家且没指定 →
  `token_exchangeable=false` + `"未指定门店，无法判定"`，`store` 回 `""`。
  **不猜一家** —— 猜错的表现是"自检说好，实际那家店根本没建渠道字典"。

#### 无副作用（可以放心打）

不签发 `AttributionToken`、不写库存/退货/台账回执、**不记用量**（记了等于"
每接一个智能体就多一笔出库"，污染渠道月报）、不动钥的 `last_used_at`。
**唯一**的写入是限流计数 —— 它**不可避免**：关掉它，这个口就成了绕过限流的入口。

> ⚠️ 这一个口**不是探活口**。探活看 `/api/v1/ping`；它回答的是"这个渠道配置对了吗"，
> 所以**接完就该打一次并存下结果**，而不是等"数据是 0"了才开始查。

---

## 2. 响应信封

### 2.1 共同核心 + **每端点各自的键面**（没有"统一七键"这回事）

> ⚠️ **本节此前写的是"所有列表类响应共用同一信封，顶层恰好这七键"——那是错的。**
> 实测（守卫 `server/core/test_spec_drift.py` 逐端点比对真实响应）三个主要列表端点的顶层键面
> 各不相同：`products` **8** 键、`stores` **6** 键、`moves` **4** 键。
> **规范只能写实测过的话**，故本节按实测重写；这处错误本身就是"文档没有守卫就会变成谎话"的实例。

除 `/schema` 外，列表类响应共享一个**共同核心**（多数端点都有），但**每个端点有自己的键面**：

| 端点 | 顶层键（实测，**精确键面**） | 与核心的差异及原因 |
| --- | --- | --- |
| `/channel/products` | `schema_version` `generated_at` `stores` `store` `filters_applied` `excluded` `items` `next_cursor` | 8 键。比核心多 `stores`＝本次覆盖的**店码数组**（跨店一次取完时用来认"这批属于哪些店"；每条 `items[]` 另自带 `store_code`）。单店与跨店请求**键面相同**，跨店时 `store` 为 `null`。**v17**：`updated_since` 增量模式下多一个 `removed`（9 键），全量模式仍是 8 键 |
| `/channel/stores` | `schema_version` `generated_at` `total` `filters_applied` `next_cursor` `stores` | 6 键。**没有 `store`** —— 它本身就是店目录，再套一层"当前店"没有意义；另多一个 `total`（语义见下） |
| `/channel/moves` | `store` `generated_at` `items` `next_since` | 4 键。**轻量对账口**：没有 `schema_version`／`filters_applied`／`excluded`，游标键也不同（`next_since`，见 §3.2）。**消费侧不要把 moves 当"通用信封"解析** |
| `/channel/categories` | `schema_version` `generated_at` `note` `categories` | 类目树；列表键是端点自带的 `categories` |
| `/channel/products-batch` | `schema_version` `generated_at` `note` `results` `stores_total` `stores_ok` `failed` | 批量口；按店分组的 `results` 代替单一列表键 |

> 上表前三行（`products`／`stores`／`moves`）的键面由附录 B 的 `*.envelope_keys` **精确核验**；
> 后两行是实测记录，**暂未加断言**（加断言的成本很低，但先记下这个已知缺口）。

**共同核心键的语义**（下面这些键**不是**每个端点都有，按上表为准）：

| 键 | 类型 | 含义 |
| --- | --- | --- |
| `schema_version` | int | 本响应的协议版本（见 [`COMPATIBILITY.md`](./COMPATIBILITY.md)）。⚠️ `/channel/moves` **不带**它 |
| `generated_at` | string | 生成时间（ISO 8601，UTC） |
| `store` | object \| null | **本次数据所属的店**：请求带 `?store=X` 时 = 那家店；请求不带 `?store` 时 = 本钥可见店**恰好只有一家**则为那家店、**可见多家则为 `null`**。⚠️ 见下面 2.1.0 —— **不要**用 `store === null` 判断"这次是不是跨店" |
| `filters_applied` | object | **实际生效**的过滤条件（回显，便于排查"为什么结果是这样"） |
| `excluded` | object | **被排除的条目与原因**（见 §2.3） |
| `<列表键>` | array | 数据条目；**键名随端点不同**（见 §2.2） |
| `<游标键>` | string \| null | 下一页游标；**键名随端点不同**（见 §3.2） |

#### 2.1.0 ⚠️ `store === null` **不是**"跨店"的判据（**第二个实现在生产上撞出来的**）

判定"本次是不是跨店"要看 **`stores[]`**（本次覆盖的店码数组），**不要**看 `store`：

| `stores[]` 长度 | `store` | 该按什么口径处理 |
| --- | --- | --- |
| `1` | **可能非 null**（就是那家店） | 相当于单店视图：这批数据只来自一家店 |
| `> 1` | `null` | 跨店视图：**每条必须读自带的 `store_code`**（见 §2.4），`code` 单独用会串 |

也就是说：**同一把钥，在"系统里只有一家店"和"有两家店"时，不带 `?store` 的同一个请求会落到两种形态。**
这与 §1.2.1 是自洽的 —— §1.2.1 说的本来就是"不带 `?store`、**跨多家店**"才只出 `discoverable=true`；
只有一家店时不存在"跨多家"，所以按单店视图处理（该店全部商品）。

> ★ **这一条是怎么发现的**：`tools/jsclient.mjs`（第二个实现）在**本机 dev**（2 家店）跑全绿，
> 拿到**生产**（当时只有 1 家店）去跑，`c3b` 当场 **FAIL**：
> "跨店时 `store` 应为 null，实为 `{'code':'st0001',…}`"。
> —— 一个只在"店数 = 1"时暴露的形态差异，本地数据集**永远测不出来**。
>
> **隐私结论（重要，别误判）**：这**不是**泄漏。只有一家店时，那把钥本来就能用 `?store=<该店>`
> 读到它的全部商品（§1.2.1 第一行），所以"不带 `?store` 也能读到"没有多给任何东西；
> 而 `discoverable` 要防的是**跨店**暴露，一家店谈不上跨。
>
> **给实现方的建议**：要判断"这批数据可能含多家店"，用 `stores.length > 1` 或逐条 `store_code`，
> **不要**用 `store === null`。

#### 2.1.1 `stores.total` 的实测语义（**别当"本次返回多少"用**）

`/channel/stores` 的 `total` = **按本钥可见范围 + 本次筛选参数匹配的店行数**。实测四条性质：

- **与分页无关**：`?page_size=1` 时 `total` 仍是全部匹配数；翻到第 2 页 `total` 不变；
- **只听筛选参数**：`?q=…`／`?scene=…` 等会收窄它（`count()` 在筛选之后、游标之前取）；
- **不减去被可用性门剔除的店**：某店到期/停用后不再出现在 `stores[]` 里，但 `total` **照旧含它**
  （实测：3 家店停用 1 家 → `stores[]` 2 条、`total` 仍 3）；
- 因此它是"**边界**"而不是"本次条数"：`total=37` 既不表示这页有 37 家，也不表示 37 家都能用。

要实现"还能翻多少页"用 `next_cursor`；要知道"实际能用几家"用 `stores[]` 的条数（那已经过门）。

#### 2.1.2 两条纪律：**对外容忍未知键，对内逐字记录当前键面**

**实现方（必须）**：解析时**容忍未知键** —— **不要**用"键集合必须完全相等"来校验
（与 [`COMPATIBILITY.md`](./COMPATIBILITY.md) §2 第 1 条一致）。品源**新增响应键是兼容变更**，
按精确键集校验的客户端会在品源一次合法升级后自己崩掉。

**本仓库（必须）**：附录 B 的 `*.envelope_keys` 断言是**精确相等**，不是子集。
理由：**"对外的兼容"与"对内的记录"是两件事** ——
对外，加键兼容、客户端不该崩；对内，文档必须逐字记录**当前**键面：
实现哪怕只是**加一个键**，也必须同时更新附录 B 的断言。

> ⚠️ **两个"没有守卫的地方"，说清楚，免得被当成已经守住了**：
> ① 本节的**表是散文，守卫读不到它** —— `*.envelope_keys` 只强制"附录 B ↔ 真实响应"一致；
>    改了键面**也必须人工改本节这张表**，否则表与附录 B 对不上而没人报错。
> ② `/api/v1/schema` 的 `vN` 段（文字在 `api/views/common.py` 里）**同样没有守卫**，也只能人工同步。
> 也就是说：**键面的机器守卫只覆盖"附录 B ↔ 实现"这一段**，另外两处靠人。
> 彻底堵住要把键面收口成一处事实源 —— **现在没做**，别假装做了。

### 2.2 列表键与游标键**不是统一的**（历史事故点）

| 端点 | 列表键 | 游标键 |
| --- | --- | --- |
| `/channel/products` | `items` | `next_cursor` |
| `/channel/stores` | **`stores`** | `next_cursor` |
| `/channel/moves` | `items` | **`next_since`** |

⚠️ **店目录的列表键是 `stores`，不是 `items`。** 实现方如果统一按 `items` 取，会把店目录读成空数组。

### 2.3 `excluded`：被排除的条目与原因

品源**不会静默丢数据**。当有过滤/截断发生时，`excluded` 给出计数与**口径说明**：

```json
{"price_missing": 12, "price_unparseable": 0,
 "dropped_by_expiry_in_page": 3,
 "scope": "price_missing/price_unparseable=全店在架口径（仅 price_max 生效时给数）；…"}
```

- 计数为 `null` = **该口径本次不适用**（不是 0）；
- `scope` 是**人可读的口径说明**，告诉你怎么解读这些数字。

实现方**应当**把 `excluded` 透出给自己的运维/日志：这些数字是"结果为什么少"的唯一线索。

### 2.4 条目形状（`items[]` / `stores[]`）

商品条目（`items[]`）的字段分三层，**都在条目内部**，不在信封顶层：

| 键 | 内容 |
| --- | --- |
| `fields` | **属性字段（字符串键面）** —— 出库合同里"属性都在这里"的那一份 |
| `fields_structured` | 与 `fields` **同键面**的类型化副本（`taglist`/`list` 已切为数组；数值型为数值） |
| `field_meta` | 字段级来源与核验：`{<key>: {source, verified, verified_method, updated_at, format_ok}}` |

> ⚠️ **骨架列也在 `fields` 里**：商品名是 **`fields.name`**、类目名是 **`fields.category`** ——
> 条目**顶层没有** `name`。
> ★ 2026-10-03 补这一句：第二个实现（`tools/jsclient.mjs`）第一版按直觉读 `it.name`，
> 打出 `undefined` —— 而 §2.4 当时只列了那三个字段块，没说骨架列在哪。
>
> ⚠️ **条目顶层还有一批规范正文未逐字列出的键**（`kind`/`stock`/`sales`/`completeness`/
> `skus`/`expiry`/`images`/`missing`/`search_text` 等）。它们的**语义**分散在本文件各处
> （§2.5 数量、§2.6 价格、§2.7 `kind`、§2.9 `cost`），**键面本身没有一处集中列出** ——
> 这是已知缺口。实现方**不要**按精确键集校验（§2.1.2），按需读即可。
>
> **v17 新增 `revision`**（记录级版本号）：全量与增量都带。它是同一条记录"改了几次"的
> 单调计数，用于增量消费方的幂等 upsert（revision 不回退）。不要拿它当全局时钟。

> **v17/R-105 新增 `channel_category`**：渠道档案 `category_map` 的映射结果
> （没配/没命中恒为 `null`）。它**只作为新增键**出现，`fields.category` 仍是店内类目名
> （消费方还要拿它去问 `/channel/field-rules`，不能被渠道映射就地改掉）。

身份标识（**跨店/跨系统引用必须用对**）：

| 键 | 唯一性 | 用途 |
| --- | --- | --- |
| `uid` | **跨实例全局唯一** | 跨系统引用、下单认品**一律用它** |
| `code` | 仅店内唯一 | 店内展示 |
| `store_code` | 店码（**全局唯一**） | 跨店取数时认店**读条目自带的这个**，不要靠外层 `store` 推断（跨店信封的 `store` 是 `null`） |
| `qualified_code` | `<店码>:<店内短码>` | 跨店去重/缓存键 |
| `barcode_norm` | 规范化条码（只留数字） | 认"同款商品"用它；⚠️ **不要**用 `fields.barcode`（那是展示文本，可能带「（演示）」等后缀） |

> ★ **类目也跟着这条纪律**（2026-10-03 收紧）。`Category.code`（`c01` 这种）**只在店内唯一** ——
> 两家店的 `c01` 可以指完全不同的类目（实测：一个「粮油米面」、一个「卤味熟食」）。所以：
>
> | 场景 | 可以用的类目口径 |
> | --- | --- |
> | **单店请求**（带 `?store=`） | `?category=<店内码或类目名>` 都行（两者都在这一家店内唯一） |
> | **跨店请求**（不带 `?store=`） | **只认类目名**；按码跨店会被 **400 拒掉**。无歧义引用请用 `?category_uid=<类目uid>` 或 `?category_template=<共享类目树节点uid>` |
> | **聚合口** `key=` | **只认类目名** `category:<类目名>`；类目码会被 **400 `bad_key` 拒掉** |
>
> 为什么按名字可以、按码不行：名字是"这家店管这叫粮油"，跨店按名字对齐是**有意的**；
> 码是店内流水号，**没有跨店含义**。按码跨店会把各家不相干的类目并成一个结果，
> 而那个结果**看起来完全正常**（`n_stores`/`n_items`/`dist` 都还是数）—— 所以必须当场拒。

> ★ **规格（SKU）的引用口径**（2026-10-03 写明）。出库 `items[].skus[]` 每个规格带
> `uid`（全局）与 `qualified_code`（`<店码>:<规格码>`）：
>
> | 要干什么 | 用什么 |
> | --- | --- |
> | 跨店 / 跨系统引用某一个规格 | `skus[].uid` |
> | 跨店去重 / 缓存 / 对账 | `skus[].qualified_code` |
> | 渠道写入时指定规格（如报销量） | 商品身份（`uid`，或 `store`+`code`）**加上** `sku_code` —— 两者合起来认品 |
>
> ⚠️ **规格码是"店内唯一"，不是"商品内唯一"** —— 也就是说**同一家店里不允许两个规格同码**
> （不同商品也不行）。这不是限制过严：`qualified_code` 的唯一性**完全押在这一条上**，
> 松掉它同一家店就会出两行 `st0001:5J`，而那是给消费方做去重/对账的键。所以：
> 想让两个商品用同一个规格**叫法**，共用规格**名**（`name` 没有唯一约束）；
> 想引用具体规格，用 `uid`。


### 2.5 数量：小数与"整数仍出整数"（**v10 新增，兼容性承诺**）

数量字段支持 **3 位小数**（散称品：`1.5`kg、`0.25`L）：

| 字段 | 含义 |
| --- | --- |
| `items[].stock` / `items[].skus[].stock` | 库存（可小数） |
| `items[].sales.d7` / `d30` / `d_all` | 销量（可小数） |
| `moves[].delta` | 库存流水增减（可小数，正入负出） |
| `POST /channel/stock_report` 的 `sold` | 上报量（可小数） |

**两条实现方必须知道的约定**：

1. **整数仍然出整数**。库里存的是 `Decimal(12,3)`，但 `148` **一定**序列化成 `148`（不是 `148.0`）——
   只有真有小数时才出 float。所以按 `isinstance(x, int)` 判断的老实现**不会**因为本次升级而崩。
   （这是品源的**承诺**，不是实现细节；`json.dumps` 出来的字节可逐字比对。）
2. **超过 3 位小数 → `400`**，**不做四舍五入**。品源不会静默改你的数：
   报 `1.2345` 存成 `1.235` 会让台账与实物永远对不上，而且没有任何地方会报错。

### 2.6 价格怎么读：`pricing_basis`（计价方式，**v10 新增字段 · v12 起 `required`**）

`price` 只给数字是**不够**的 —— `18` 是"每件"还是"每公斤"决定了它能不能和别的价格比。
所以每件商品有一个 `pricing_basis`（在 `fields` 里，受控词表）：

| 值 | 含义 | 这个价是 | 库存计量 |
| --- | --- | --- | --- |
| `piece` | 按件 | 元/件 | 件 |
| `weight` | 称重 | 元/kg | **kg（小数）** |
| `volume` | 按体积 | 元/L | **L（小数）** |
| `length` | 按长度 | 元/米 | 米（小数） |
| `time` | 按时 | 元/小时 | 不占库存 |
| `quote` | 议价 | 区间/面议 | 不占库存 |

⚠️ **`unit_kind`（`g`/`ml`/`count`）不是计价方式**，它是**包装里装了多少**。
两者必须分开看：500g 袋装面条（按件卖）与散称花生（按公斤卖）的 `unit_kind` **都是 `g`**。

**比价与聚合前先按 `pricing_basis` 分组**：元/kg 与元/件求平均是没有意义的数。
现成的比价键是 `items[].unit_price`（`{value, unit:'元/kg', basis, price, weight_g}`，
只在 `price` 与 `weight_g` 都能解析成数值时给出，否则 `null` —— **不猜**）。

⚠️ **v12 起它是 `required`**（此前 `recommended`）。这是**语义收紧**，实现方必须知道：

- `/channel/field-rules` 里它会从 `recommended` 移到 `required`，缺它的商品**完整度下降**；
- 用 `min_level` / `min_completeness` 过滤的实现，会看到一批商品档位下降
  （**这是预期行为，不是数据丢失** —— 那些商品本来就缺一个"价格的分母"）；
- `knowledge` 类不受影响（`kind_scope="product,service"`：知识不按件也不按公斤卖）。

品源**没有**为存量商品按 `unit_kind` 推定补值（v10 曾那样做过一轮，那时它只是 `recommended`）：
一旦升为必填，一个**猜错**的值就从"聊胜于无"变成了"**必填字段里的错值**" ——
空着会显形、会被商家补上，猜错会隐形（散称品的价被当成"每件"参与比价与聚合，永远没人发现）。
所以升级后台账里会多出一批"缺计价方式"的商品，它们**应当**按缺项处理。
完整规则见 [`COMPATIBILITY.md`](./COMPATIBILITY.md) §3.2。

### 2.7 `kind`：一件东西是**产品 / 服务 / 知识**（**v11 起口径写清 · v13 补服务字段**）

品源不是"商品库"，是**产品与服务主数据**，所以每条 `items[]` 都带一个 `kind`：

| 值 | 含义 | 典型 |
| --- | --- | --- |
| `product` | 有规格、有库存的物品 | 500g 袋装莜面 |
| `service` | 按次 / 按时 / 按量的履约 | 上门安装、按米裁剪、按小时咨询 |
| `knowledge` | 方案 / 知识条目（**不拿来卖**） | 食用指南、搭配方案 |

三条**实现方必须知道**的约定：

1. **出库口不按 `kind` 过滤，也不替你做策略。** `?kind=` 只是**可选收窄**；不传 = 全都要（含知识）。
   `/channel/products` 的可售性判定只看 `shelf=up` + 未进回收站 + 未过期 —— **不看 `kind`**。
   "知识要不要出现在你的货架上"是**你的策略**，品源只出数据。
2. **"有什么"是全量的，"缺什么"按 `kind` 算。** 哪些字段对哪种 `kind` 适用，事实源是
   `/api/v1/schema` 的 `fields[].kind_scope`（v11 起公开）与 `/api/v1/channel/field-rules?kind=`：

   - `kind_scope` 为**空字符串** = **对所有 kind 都适用**（不是"都不适用"）；
   - 否则是以逗号分隔的 kind 列表，如 `product,service`。

   最典型的一条：**`knowledge` 不要求 `price`**。标价 0 元的知识不是拿来卖的，
   把它算成"缺价格"会让知识类**永远不达标**。（历史上这里有过两份算法并打架：
   一处读 `kind_scope`、另一处硬编码"知识不要求价格"，而 `unit` 的 `kind_scope` 是空 —— 
   改数据不会让硬编码跟着变，也没有任何地方会报错。现在只有一份：`fieldroles.applicable`。）
3. **不传 `kind` = 不过滤（宽容侧）**：完整度按**适用字段的全集**算 ——
   宁可把一件东西算得"不够完整"，也**不许**把缺料的当合格的放出去。
4. **★类目专属字段按「这件东西自己的类目」算**（**v13 起收紧**，这是行为变更，实现方必读）。
   `category_scope` 非空的字段（`origin`、`shelf_life`、`nutrition_*`…）只对该类目下的条目适用：

   | `/channel/field-rules` 怎么传 | 口径 |
   | --- | --- |
   | **不带** `category=` | **并集** —— "这门类目下都要什么"（枚举 / 报表用） |
   | **带** `category=粮油米面` | 按前缀收窄（父类目配一次、子类目继承） |
   | **带** `category=`（**空串**） | **这条没有类目 → 排除所有挑类目的字段** |

   ⚠️ **v13 之前，空串与不传是等价的**（都"不过滤"）—— 于是**没有类目的服务与知识
   被判「缺 `origin`（产地）」**，消费侧按 `missing_required` 一拦，
   **服务与知识一条都推不出来**。服务要产地、知识要产地，本来就没有意义。
   实现方**应当**用条目自己的 `kind` + `category` 去问 `field-rules`，别自己拼这套合并逻辑。

### 2.8 服务类字段（**v13 新增**）

官网口径的「服务类 6 字段」，其中「计价方式」= §2.6 的 `pricing_basis`，其余 5 个自 v13 起就位：

| key | 标签 | 门槛 | 类型 | 说明 |
| --- | --- | --- | --- | --- |
| `service_content` | 服务内容 | `required` | text | 不填它，读者不知道这项服务做什么 |
| `service_scope` | 服务范围 | `required` | text | 不填它，"能不能服务到我"无法判断 |
| `duration` | 时长 | `recommended` | text | 如 `约 2 小时`（**刻意用 text**：单位不固定） |
| `delivery` | 交付方式 | `recommended` | taglist | 上门 / 到店 / 线上 / 寄送 |
| `booking` | 预约方式 | `recommended` | taglist | 电话 / 微信 / 小程序 / 到店 |

全部 `kind_scope="service"` —— **商品与知识不会被问这些**（可用 `?kind=` 自己看）。

**`kind` 由谁决定？** 这条是**商家口**的事、与协议无关：一家店能录哪几类由
**店铺设置**的「业务类型」开关决定（`GET/PATCH /api/v1/store/kinds`，v13/R-82；
默认只开 `product`）。**出库口一律不按它过滤**（第 1 条），开关只约束"能录哪几类" ——
关掉某一类**不会**让它已录的数据消失或下架。

#### 2.8.1 结构化服务块 `items[].service`（**v17 新增**）

`kind="service"` 的条目多一个 `service` 对象；其他类型恒为 `null`（键面稳定）。

| 键 | 含义 |
| --- | --- |
| `configured` | 是否已配置结构化服务参数（`false` = 只有上面的 EAV 文本字段） |
| `mode` | **2026-10-28 新增**：`appointment` / `on_site` / `project` / `consulting`；没有配置时按 `appointment` |
| `project` | 仅 `mode=project` 时为 `{milestones, deliverables, warranty_months, acceptance_note}`；其余恒为 `null` |
| `sla` | 响应/交付承诺：`{response_minutes?, arrive_days?, quote_valid_days?, notes?}`；没配置为 `{}` |
| `duration_minutes` | 单次服务时长（分钟） |
| `capacity_per_slot` | 每个时段可接待人数/单数 |
| `slot_interval_minutes` | 时段间隔；`0` = 用 `duration_minutes` |
| `lead_time_minutes` | 最早可预约提前量（分钟） |
| `cancel_before_minutes` | 免费取消的提前量（分钟） |
| `booking_enabled` | 是否开放预约 |
| `price` | `{amount, basis, currency}`；`basis` 复用 §2.6 的 `pricing_basis` 词表 |
| `schedule` | 每周可预约时段：`{"mon": [["09:00","12:00"]], ...}`，本地时间 |

**可预约余量**不放在条目里（会随时间变），请查
`GET /api/v1/channel/availability?uid=<商品uid>&store=<店码>&date=YYYY-MM-DD`。
它返回 `service` 块 + 当天 `slots[]`（`capacity/booked/remaining/status`）。

**价格同源**：商家端写服务价格时会同时写 EAV 的 `price`/`pricing_basis`，
所以 `fields.price` 与 `service.price` 永远一致；老消费方继续读 `fields.price` 即可。

**工程/咨询没有时段**：`mode=project` / `consulting` 时，
`GET /channel/availability` 返回 **400**，body 含 `mode` 与 `reason`
（“工程型/咨询型按项目/报价交付，请走需求或报价链路”）——
**不会静默回空 `slots[]`**，否则消费方会误以为“今天没余量”。

#### 2.8.2 知识块 `items[].knowledge`（**2026-10-28 新增**）

`kind="knowledge"` 的条目多一个 `knowledge` 对象；其他类型恒为 `null`（键面稳定）。

| 键 | 含义 |
| --- | --- |
| `configured` | 是否已建知识档案（`false` = 只有 EAV 文本字段） |
| `type` | `product_fact` / `standard` / `faq` / `case` / `acceptance_guide` |
| `summary` / `body_html` | 摘要与正文（正文已过 HTML 白名单） |
| `source` / `source_url` / `version` / `authority` | 出处、链接、版本、发布机构 |
| `effective_from` / `effective_to` | 生效/失效日期（`YYYY-MM-DD` 或 null） |
| `attachments` / `related_product_uids` | 附件地址数组、关联商品 uid 数组 |
| `effective` / `state` | `state` = `ok` / `not_yet` / `expired` / `unknown`；`effective` 只有 `ok` 为 true |
| `question` / `answer` | FAQ 的问题/答案（来自插件字段 `knw_faq_question`/`knw_faq_answer`） |

**过期知识不删**：`state=expired` 的条目照出，但导购**不应引用**；
引用时至少要带上 `source/version/effective_from`，让读者能判断它是否还作数。

### 2.9 `cost`（进价）—— **有字段，但读不到**（**v14 新增**）

`cost` 是**商家自己的底牌**（比销量还敏感：销量是节奏，进价是底牌），所以：

| 口 | 行为 |
| --- | --- |
| `/schema` | **有这个字段的定义**（键面/类型/门槛照常公开 —— 词汇表本来就公开；`fields` 全表就在 `/schema` 里） |
| `/schema/fields` | 同上，但是**商家端口**（表单用它渲染，**需要商家 token**，无钥返回 401）—— 消费方取字段表**用 `/schema` 就够** |
| **`/channel/*` 出库** | **一个字节都不出**（不在任何 `items[].fields` 里） |
| 聚合 | **永远不参与**（`effective_scope()` 直接滤掉，不靠"界面上没勾"） |

⚠️ **实现方读不到它不是漏，是设计。** 出库的键原本是"全字段自动流出去"，
所以 v14 配套引入了一份**唯一**的例外清单（`fieldroles.NEVER_OUT_KEYS`），
出库口与聚合口都读它那一份 —— 加字段的人若忘了登记，**迁移会直接拒绝执行**。

⚠️ **`kind` 不是分类。** `kind` 只有上表三个值、由品源固定；`category` 才是每家店自己的类目树。
两者不可互相推导，也不要拿 `kind` 当筛选类目的字段用。

---

## 3. 分页

### 3.1 游标语义

- 游标是**不透明字符串**：客户端**必须**原样回传，**不得**解析或构造。
- 走到 `next_cursor` / `next_since` 为 `null` 或缺失 = 没有下一页。
- **`next_cursor` 存在时必须继续翻页，即使本页 0 条** —— "本页为空 = 结束"是错的。
- 非法游标 → **`400`**（不得是 5xx）。

### 3.2 逐端点

| 端点 | 请求参数 | 响应下一页键 |
| --- | --- | --- |
| `/channel/products` | `cursor=` | `next_cursor` |
| `/channel/stores` | `cursor=` | `next_cursor` |
| `/channel/moves` | `since=` | `next_since` |

### 3.3 客户端必须实现"走完游标"

**只读首页 = 静默少数据**，这是本协议历史上最贵的一类事故（店目录默认 200/页，
三个消费方都只读首页，跨店推荐直接少掉一大半店铺 —— **不报错，只是少**）。

**推荐做法**：用参考实现的 `walk_products()` / `walk_stores()`（内部循环带游标），
并设**防不收敛兜底**（连续多页仍返回游标即报错中止，绝不死循环）。

### 3.4 每页条数

| 项 | 值 |
| --- | --- |
| `page_size` 默认 | 100（`/channel/stores` 为 200）—— 两条都由守卫按**行为**核验（真造 >默认值 的数据量返回条数），断言见附录 B `page.default` / `page.default_stores` |
| `page_size` 上限 | **500**（传更大一律**截断到 500**，不报错） |
| 非法值 | 非整数 → `400` |

### 3.5 增量同步（`updated_since`，**v17 新增**）

游标翻页解决"全量一次拉不完"，**增量**解决"改过的低 id 不会被再次拉到"。

| 项 | 口径 |
| --- | --- |
| 首请求 | `GET /channel/products?store=X&updated_since=<ISO8601>` |
| 后续页 | **只传**上一页的 `next_cursor`（`d1:` 前缀），不要再带 `updated_since` |
| 同时传 | `updated_since` + `cursor` → **400** |
| 非法时间 | → **400** |
| 排序/游标 | `(updated_at, id)` 双键；`updated_at` 相同时用 id 保证不重不漏 |
| `filters_applied.mode` | `full` 或 `delta` |
| 全量信封 | 顶层仍是 8 键（不变） |
| 增量信封 | 顶层 **9 键**：多一个 `removed` |
| `removed[]` | `{uid, code, store_code, reason}`；reason ∈ `trashed` / `shelf_down` / `expired` / `not_discoverable` |

**硬删除不会出现在 `removed`**（记录已经不存在了）。消费方必须**定期做一次全量对账**，
用于发现硬删除和纠正长期漂移。

**增量与过滤条件同时使用时**：过滤只作用于变更集，`removed` 只报基础可见性变化
（下架/回收站/过期/跨店不可发现）；"因为不再匹配某个过滤条件而移出"由消费方本地处理，
或走全量对账。这条是当前版本的已知边界，不要假设 `removed` 覆盖所有过滤变化。

`/channel/products-batch` 会把 `updated_since` 透传给每家店，但**游标是每店独立的**：
批量口的 `results[店码].next_cursor` 要配单店口使用。

---

## 4. 错误模型

### 4.1 形状

错误响应是 JSON，**给人看的文案在 `detail`**（部分端点用 `note`）：

```json
{"detail": "cursor 无效"}
```

`429` 额外带 `retry_after`（秒）。

### 4.2 状态码

| 码 | 含义 | 典型 `detail` |
| --- | --- | --- |
| `400` | 参数非法 | `cursor 无效` / `page_size 需为整数` / `price_max 需为数值` / `min_level 需为 …之一` |
| `401` | 未鉴权 | `缺 X-Channel-Key 请求头` / `渠道密钥无效或已停用` |
| `404` | 店不存在、不可用，或**对该密钥不可见** | `店铺不存在` / `店铺不可用（到期或停用）` / `这把钥看不到任何可用的店` |
| `429` | 超出限流 | `超出限流 N 次/分` + `retry_after` |
| `5xx` | 服务端内部错误 | 不应发生；**见到即当 bug 报给品源** |

### 4.3 实现方要求

- **`4xx` = 你的请求有问题**（不要重试，除了 `429`）；**`5xx` = 品源的问题**（可退避重试）。
- 遇到 `429`：按 `retry_after` 退避。**不要猛敲**（打不破窗口，只会浪费配额）。
- 遇到 `5xx`：**不要静默降级成"没有数据"** —— 那会把服务端故障伪装成"这家店没商品"。

---

## 5. 写口：库存上报（幂等）

`POST /api/v1/channel/stock_report`

```jsonc
// 认品二选一
{"uid": "<商品 uid>", "sold": 2, "sku_code": "…", "request_id": "<≤64 字符>"}
{"store": "<店码>", "code": "<店内商品码>", "sold": 2, "request_id": "<≤64 字符>"}
```

| 字段 | 规定 |
| --- | --- |
| `uid` | **推荐**（跨店不串品）；或 `store` + `code` |
| `sold` | 上报销量（正数），按批次到期日 **FIFO** 消耗 |
| `sku_code` | 多规格商品**必须**给；单规格给了会 `400` |
| `request_id` | **幂等键**，≤64 字符 |

### 5.1 幂等语义（**必须按此实现**）

| 情况 | 行为 |
| --- | --- |
| 带 `request_id`，首次 | 正常扣减 |
| 带 `request_id`，**同 (密钥, 店, request_id) 重复上报** | **回放首次结果** + `"duplicate": true`，**不重复扣** |
| 并发同 `request_id` | 明确报"正在处理中，请稍后重试"（**不得静默重复扣**） |
| 失败（4xx/5xx） | **不占用** `request_id`（可安全重试） |
| **不带** `request_id` | 非幂等：**重复报 = 重复扣**（渠道侧自行去重） |

**实现方要求**：把 `request_id` 设为你自己订单号的**稳定派生值**（例如订单号本身或
`订单号:行号`）。**不要**用随机数——那等于没有幂等。

---

## 5.1.1 绝对值上报与**库存版本**（T-18，2026-11-05）

除了"扣多少"（增量 `sold`），还可以报"现在有多少"（绝对值 `stock_absolute`）。
两种**一次只能报一种**，因为它们的并发语义不同：

| 模式 | 载荷 | 并发原语（服务端 WHERE） | 防的是什么错 |
| --- | --- | --- | --- |
| 增量 | `sold` | `stock >= sold` | **超卖**（不够也扣了就变负数） |
| 绝对值 | `stock_absolute` + `base_version` | `stock_version = base_version` | **陈旧覆盖**（迟到/重放的旧绝对值抹掉期间销售） |

```jsonc
// 增量（原语义，一字未变）
{"uid": "…", "sku_code": "500g", "sold": 2, "request_id": "M2026…:500g:1"}
// 绝对值（多规格必须给 sku_code，逐个规格报）
{"uid": "…", "sku_code": "500g", "stock_absolute": 3, "base_version": 7,
 "request_id": "resync-…"}
```

**互斥规则（服务端写死，不许"或"）**

| 载荷 | 结果 |
| --- | --- |
| 只有 `sold>0` | ✅ 增量 |
| 只有 `stock_absolute>=0` + `base_version` | ✅ 绝对值（`0` 合法 = 卖光了） |
| `sold` 与 `stock_absolute` 都给 | ❌ `400`（互斥） |
| 两个都不给 | ❌ `400`（缺一个） |
| `stock_absolute` 没有 `base_version` | ❌ `400`（否则旧绝对值会覆盖新库存） |
| 只给 `sold` 却带 `base_version` | ❌ `400`（`base_version` 只用于绝对值） |

**`stock_version` 是什么**：单调递增的整数，**只被库存列的写入推进**（改价/改名/上架
不动它）。它回答"我这条绝对值是基于哪一版库存算出来的"。`0` = 建行那一版。
"多推"的后果只是一次 `409`（可见、可重试），"少推"的后果是**陈旧绝对值被接受**（静默写错）。

**怎么拿到当前版本**：`GET /api/v1/channel/stock_state?store=<店码>&uids=<uuid>,<uuid>[&sku_code=…]`
（用途钥 `stock_write`；`uids` 最多 200 个，逗号分隔）

```json
{"items": [{"uid": "…", "store": "st0001", "code": "sp0001",
            "stock": 12, "stock_version": 7,
            "skus": [{"sku_code": "500g", "stock": 5, "stock_version": 3}]}],
 "count": 1}
```

这一口**不写任何业务表**（不建回执、不动库存），但**计用量**（它把库存数字给出去了）。

**两个 `409` 用 `code` 机器可分**（`detail` 是给人看的，判程序请判 `code`）：

| HTTP | `code` | 含义 | 客户端该做什么 |
| --- | --- | --- | --- |
| `409` | `stock_version_conflict` | 你基于的版本已过期 | 用响应里的 `stock_version` **重报一条绝对值**（响应里还有 `stock` / `sku_stock` / `sku_stock_version`，多规格时后面两个是**该规格**的值） |
| `409` | `receipt_in_progress` | 同 `request_id` 的**另一条正在处理** | **只退避重试，不要换形状**（换形状会让一次并发变成两条绝对值） |

> 版本冲突时服务端**不占用** `request_id`（回执整体回滚），所以同一个 `request_id`
> 可以直接重发 —— 这是"409 自愈"能成立的前提。

**重放 = `200` + 首次结果 + `"duplicate": true`**，**不是** `409`。
理由：重复上报恰恰证明上次已经成功；返 `409` 会让一条已完成的行永远卡在重试队列里、
最后被标成死信，让运维去修一个不存在的错。

**成功响应的新键（additive）**

| 键 | 说明 |
| --- | --- |
| `mode` | `"increment"` / `"absolute"` —— 回执**自证**走的是哪条路，对账时分得清语义 |
| `sku_code` | **N1，2026-11-05 新增**：这次报的是**哪个规格**，无规格时为 `""`（**键始终在**）。没有它，超退检查只能按 `uid` 累加报量，而退货是按 `sku_code` 过滤的 —— 一张单买过两个规格时两个规格**共用一个分母**，跨规格超退会被漏掉（见 §5.2） |
| `stock_version` / `sku_stock_version` | **写回之后**的版本（无规格时后者为 `null`） |
| `other_channel_writers_24h` | 该商品最近 24h 内**其他渠道**的库存回执条数；`>0` 说明"绝对值安全"的前提不成立（绝对值是覆盖式的，会抹掉对方的扣减） |

**三种商品的绝对值语义**

| 商品形态 | `stock_absolute` 的含义 | 注意 |
| --- | --- | --- |
| 无规格 | 商品库存 | 条件更新一条 |
| 有规格 + `sku_code` | **该规格**的库存 | 服务端锁商品行后重算 `商品库存 = Σ各规格`，两列各自 +1 版本 |
| 有规格 + 无 `sku_code` | —— | `400`（不猜，请逐个规格报） |
| 批次商品（有批次台账） | —— | `400`：批次商品的库存恒等于批次余量之和，绝对值写进去会毁掉这条不变量 |

绝对值**不是一笔销售**：它记 `StockMove(reason="adjust")`，**不进**销量日聚合
（否则"改了个数"会被算成"卖了几件"）。

---

## 5.2 退货回补（2026-10-29）

`POST /api/v1/channel/stock_return`

```jsonc
{
  "uid": "<商品 uid>",            // 或 store + code
  "sku_code": "…",                // 多规格必填
  "qty": 2,                       // 退货/退款数量，>0
  "amount": "38.00",              // 本次退款金额（≥0）
  "return_type": "return",        // return=退货回补；refund_only=仅退款
  "order_no": "M2026…",           // 原订单号（可选，用于超退核对）
  "refund_no": "R2026…",          // 退款单号（必填，幂等键）
  "occurred_at": "2026-10-29T21:30:00+08:00",
  "request_id": "refund:<refund_no>:<item_id>"   // ≤64，可选但建议
}
```

| 字段 | 规定 |
| --- | --- |
| `return_type` | `return` 才把库存加回；`refund_only` 只冲净销量，库存不动 |
| 幂等 | 落在 `(渠道, 店, 退款单, 商品, 规格)` 的唯一键上，**不依赖 30 天回执表** |
| 重复上报 | 回 `200` + `duplicate: true`（数量/金额不一致再加 `mismatch: true`）；**不返 4xx** |
| 超退 | 照收，回 `anomaly: true`，供对账。**只标不拦**（2026-11-05 写清）：全仓没有任何消费方据此拒收，它的作用只是"人工复核队列里多一条"，不是"多退的钱被拦住了" |
| 商品已回收站 | 事实照收、`stock_skipped: true`、库存不加 |

**超退判据的口径：`(uid, sku_code)` 同源**（N1，2026-11-05）

报量按「该单该**规格**」累加（回执体的 `sku_code`，见 §5.1.1），已退量也按
`(order_no, uid, sku_code)` 过滤 —— 两边同一个分母。之前报量只按 `uid` 累加，
于是买过两个规格的单里，A 规格退 2 件（A 只卖过 1 件）会因为 B 规格的销量被算进来
而**不标超退**。

**`anomaly: false` 不等于"查过了、没超退"**。下列三种情况下这一项查不了，也都是 `false`。
其中 ② ③ 会打一条 `[超退检查未覆盖]` 的 warning（服务端日志），**没查**与**查了没有**
不许共用一个 `false`：

| 情形 | 为什么查不了 |
| --- | --- |
| ① 该单的回执已被 `purge_stale` 清掉（回执保留 30 天） | 拿不到原始报量，不猜 |
| ② 该单含**绝对值回执**（`sold` 为 `null`） | "卖了几件"根本不存在（绝对值是覆盖式修正，记的是"现在有多少"） |
| ③ 该单含 **2026-11-05 之前**的存量回执（回执体里没有 `sku_code`），而这次退的是某个规格 | 那笔报量属于哪个规格**判不了**：算进去会漏掉跨规格超退，不算会误报 |

③ 是**兼容窗口**：存量回执 30 天内会被 `purge_stale` 自然清掉，过了这个窗口就不再有。
无规格商品（`sku_code` 空）不受影响 —— 两边都是商品级，照旧累加。

**净销量** = `stock_report` 累计 − `stock_return` 累计（两种 `return_type` 都冲销量）；
品源**不出净金额**——金额以消费方订单台账为准。

---

## 5.3 消费方订单台账（2026-10-30）

`POST /api/v1/channel/consumer/orders`（用途钥 `consumer_write`）

```jsonc
{
  "request_id": "consumer:M2026…:<payload指纹>",
  "store": "st0001",
  "orders": [{
    "order_no": "M2026…",
    "paid_at": "2026-10-28T10:00:00+08:00",
    "gross": "199.80", "discount": "0.00", "net": "199.80",
    "items": [{"uid": "…", "sku_code": "", "qty": "1",
               "unit_price": "99.90", "amount": "99.90"}],
    "refunds": [{"refund_no": "R…", "amount": "99.90",
                 "return_type": "return",
                 "completed_at": "2026-10-29T09:00:00+08:00",
                 "items": [{"uid": "…", "sku_code": "", "qty": "1",
                            "amount": "99.90"}]}]
  }]
}
```

| 字段 | 规定 |
| --- | --- |
| 用途 | 必须用 `consumer_write` 钥；`stock_write`/读钥一律 403 |
| 隐私 | **白名单**：未知键（phone/openid/member/address…）直接 400 |
| 幂等 | 同 `request_id` 同 payload → 200 duplicate；不同 payload → 409 |
| 不可变 | 订单商品行与成交金额入库后不可改；退款只增不减 |
| 退款 | 按 `completed_at` 归期；订单状态由退款合计推导 |
| 批次 | 单次 ≤200 单 |

台账用于**结算**与按消费方看板；品源只存聚合/业务键，不存会员身份。

## 5.4 结算单（消费方只读）

`GET /api/v1/channel/settlements?period=YYYY-MM`（`consumer_write` 钥）

返回该消费方（按 `channel` 收口，单店钥只看白名单店）的月度结算单：
`gross / refund / net / rate / commission / status / line_hash`。
结算单由品源按月生成，**消费方只读**；确认/申诉在商家侧，复核/结算在平台侧。

口径：净额 = 成交 − 退款；退款按 `completed_at` 归期；某月净额可为负；
`line_hash` 是行身份集合的 sha256，可用来核对双方明细是否一致。

## 5.5 经营指标上报

`POST /api/v1/channel/metrics`（`metrics_write` 钥）

```jsonc
{
  "request_id": "metrics:st0001:2026-10-29:0:<sha12>",
  "store": "st0001",
  "granularity": "day", "day": "2026-10-29", "hour": 0,
  "metrics": [
    {"code": "gmv_paid", "value": "1234.50"},
    {"code": "channel_gmv", "value": "800.00",
     "dims": {"channel": "小程序"}, "sample": 12}
  ]
}
```

| 规则 | 说明 |
|---|---|
| 指标 | 只收 `owner=mall` 的**基量**；比率不收（读时算） |
| 维度 | 只认 `MetricDef.dims` 白名单；带维度必须给 `sample`，`<5` 拒收 |
| 幂等 | 同 `request_id` 同 payload 回放；不同 payload 409 |
| 粒度 | `day`（hour=0）或 `hour`（0..23）；单次 ≤200 指标 |

---

## 6. 端点清单

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/schema` | **公开**自描述端点（不需密钥）。字段词汇表、逐版本变更、端点清单都在这里 |
| `GET` | `/api/v1/channel/products` | 商品检索/同步（分页；支持 `q`/`category`/`tag`/`price_max`/`min_level`/`min_completeness` 等过滤；`updated_since=` 增量） |
| `GET` | `/api/v1/channel/availability` | **服务可预约时段（v17）**：`uid=` + `store=` + `date=YYYY-MM-DD` → `service` 块 + 当天 `slots[]` |
| `GET` | `/api/v1/channel/profile` | **渠道档案（v17）**：本钥所属渠道的能力开关、字段白名单/黑名单、类目映射；**T-23 起增 `checkout` 段**（结算入口模板，见 §1.4.1） |
| `GET` | `/api/v1/channel/agent-selfcheck` | **接入自检（T-24）**：一次问完四件事（渠道登记 / 钥可用 / 结算模板 / 记号换得出来），每项带人话原因。**无副作用、不记用量**，见 §1.5 |
| `GET` | `/api/v1/channel/tools` | **工具清单（v17/R-94）**：MCP 形状的工具表（名字/说明/入参 schema/是否可用），见 §12.5 |
| `POST` | `/api/v1/channel/tools/call` | **工具调用（v17/R-94）**：`{name, arguments}` → `{tool, result}`，见 §12.5 |
| `GET` | `/api/v1/channel/stores` | 当前密钥可见的店目录（含店级 GEO 与发货档案） |
| `GET` | `/api/v1/channel/categories` | 类目树 |
| `GET` | `/api/v1/channel/field-rules` | **按类目的必填/推荐/可选三档门槛** + 完整度判据 |
| `GET` | `/api/v1/channel/moves` | 库存流水对账（`store=` 必填；`since=` 游标） |
| `POST` | `/api/v1/channel/events` | 变更/触点事件上报（幂等，见 §5 同类幂等口径）。**本行此前误写成 `GET`**，实测 `GET` → `405` |
| `GET` | `/api/v1/channel/products-batch` | 批量取商品 |
| `POST` | `/api/v1/channel/stock_report` | 报销量扣库存（幂等，见 §5）；**也收绝对值 + 版本**（见 §5.1.1） |
| `GET` | `/api/v1/channel/stock_state` | **库存与库存版本（T-18）**：绝对值上报前先取 `base_version`，见 §5.1.1 |
| `POST` | `/api/v1/channel/stock_return` | 退货/仅退款（幂等，见 §5.2） |
| `POST` | `/api/v1/channel/consumer/orders` | 消费方订单/退款台账（幂等，见 §5.3） |
| `POST` | `/api/v1/channel/metrics` | 经营指标聚合上报（幂等，见 §5.5） |
| `GET` | `/api/v1/channel/settlements` | 消费方只读自己的月度结算单（`consumer_write` 钥，按 channel 收口） |
| `GET` | `/api/v1/channel/aggregate` | **跨商家聚合查询（v14/R-89 起已实现）**，见 §8。带有效钥返回 `200`；**样本不足时 `dist` 为 `null`**（不是错误）。**鉴权与其余渠道口逐字一致**：不带钥/钥无效一律 `401` —— 这条能力再敏感也不许是敞口 |
| `GET` | `/api/v1/channel/catalog` | **ACT `CID-MER-CAT` 兼容的商品/服务目录**（2026-10-15 新增），见 §10。出的是**协议要的形状**，不是本库 45 字段的全量。缺必备项的行**照出**但带 `catalog_ready: false` |

**本表每一行都有附录 B 的断言守着**（路径能解析、方法/状态码与表中一致）——
改路由、换方法、把 `501` 改成别的，都会同时要求改本表，否则守卫红。

### 6.1 `field-rules` 为什么必须用服务端这份

完整度判据（"这个商品够不够格上架/推给智能体"）**以 `/channel/field-rules` 为准**。
消费方自己维护一套必然与品源漂移 —— 这类漂移的后果是**门槛静默失效**（该拦的没拦、
该放的没放），且没人会报错。**禁止**在客户端硬编码必填字段清单。

#### 6.1.1 响应形状（**★ 2026-10-03 补：这一节此前只有"必须用"，没有形状**）

> **为什么补**：一个**独立的第二个实现**（`tools/jsclient.mjs`，按纪律只读本文件 +
> `/api/v1/schema` 写的 Node 客户端）跑自证时打出一条 `FAIL`：它按直觉读
> `resp.required`，而真值是 `resp.base.required` / `resp.effective.required`。
> ——**一份命令你别硬编码的规范，却没说该读哪一层**，实现方只能去反推服务端。
> 这是规范自己的缺口，不是客户端写错。

```jsonc
{
  "schema_version": 15,          // 与 /schema 同一个版本号
  "field_version": 15,           // 字段目录版本（与 schema_version 同源，留着是为了将来能分开）
  "base":      { "required": [...], "recommended": [...], "optional": [...] },
  "by_category": { "粮油": { "required": [...], "recommended": [...], "optional": [...] }, ... },
  "effective": { "category": "", "kind": "product",
                 "required": [...], "recommended": [...], "optional": [...] },
  "match_rule": "类目路径前缀匹配；父类目配一次，子类目继承。空 scope 归 base。",
  "levels": { "required": "不达标（缺了算不完整；拦不拦由消费侧定）",
              "recommended": "缺了降档", "optional": "有更好" }
}
```

| 层 | 什么时候读它 |
| --- | --- |
| **`effective`** | **要判定某一件东西时读这一层** —— 它已经把本次请求的 `category` + `kind` 收窄算好了（含 `category`/`kind` 回显，便于自检）。消费方**只用这一层**就够 |
| `base` | 不带类目/类型时的**并集**口径（枚举、报表用）："这门类目下都要什么" |
| `by_category` | 各类目路径前缀下的门槛快照（可选读；`effective` 已经替你算过） |
| `levels` | 三档的**中文语义**（人读；机器判据只需要键名） |
| `match_rule` | 匹配规则的人可读说明（前缀匹配、继承、空 scope 归 base） |

**元素形状 —— ⚠️ 两层**不一样**（实测，不是笔误）：**

| 层 | 三档数组的元素 | 例 |
| --- | --- | --- |
| `base` / `by_category` | **对象**：`{key, label, ftype, unit, unit_kind, pack, options}`（与 `/api/v1/schema.fields[]` 同源） | `{"key":"name","label":"商品名称","ftype":"text", ...}` |
| **`effective`** | **裸字段键字符串** | `"description"` |

> ★ **这一段为什么长这样**：这条不对称是**第二个实现**跑出来的 —— 它先按"两层同形状"读，
> 当场 `TypeError: Cannot use 'in' operator to search for 'key' in description`。
> 查下去发现 **`effective` 的裸键是现有消费方正在依赖的形状**：品源导购就是
> `set_pinyuan_rules(env["effective"])`，直接把它当 `{required:[键…], recommended:[键…]}` 用。
> 所以**不能**为了"看着整齐"把 `effective` 改成对象 —— 那会静默打断唯一的消费方。
> 现在把它**写清楚 + 用断言钉住**（`spec-assert field_rules.keys` 的核验同时断言
> `base` 元素是对象、`effective` 元素是字符串）。谁要统一形状，必须同时改文档、
> 改断言、并通知消费方。

**正确的读法是两条路，别混：**

```jsonc
// ① 只想知道"这件东西该有哪些字段" → 直接读 effective，元素就是键
{"required": ["description", "price", ...], "recommended": ["aliases", ...]}

// ② 还要这些键的元数据（标签/类型/单位/枚举候选）→ 拿 effective 的键去 base 里查
//    base[level].find(x => x.key === key)   ← 元数据在这里
```

⚠️ **`required` 是"缺了算不完整"，不是"品源替你拦"**：拦不拦、拦到什么程度
是**消费侧的策略**（见 §2.9 与 `excluded` 的口径）。

---

## 7. 版本与兼容性（摘要）

- `schema_version` 是**整数**，**单调递增**；出现在 `/schema` 与**多数**列表信封的顶层。
  ⚠️ **不是每个信封都有**：实测 `/channel/moves` 只有 `store`/`generated_at`/`items`/`next_since` 四键，
  **不带 `schema_version`**（它是轻量对账口，见 §2.1）。实现方不要假设"拿到信封就能读到版本"，
  需要版本请用 `/api/v1/schema`。
- 品源的演进纪律是**只增不改**：新增键、新增字段、新增端点**不**升破坏性版本；
  删除键、改类型、收紧取值范围**才**是破坏性变更，需走弃用周期。
- 完整规则、弃用周期、以及"实现方该怎么判断自己会不会被破坏"见
  [`COMPATIBILITY.md`](./COMPATIBILITY.md)。

⚠️ **`schema_version` 与别家的版本号不可比较。** 历史事故：有消费方把品源的整数版本
（`8`）与自己的协议号（`rec-1.0`）做**字符串比**，`'8' < 'r'` 恒真 → 每条对话都误报
"版本过低、信息可能不准"。**跨命名空间不要比大小**；同级版本按数值逐段比。

---

## 8. 聚合查询（**已实现**；v13/R-74 定形状，v14/R-89 填实现）

十年视角要长出的能力：**跨商家聚合**（同类商品的价格分布、规格分布等），
让数据产生单店产生不了的洞察。协议**先把形状定下来**，实现留到有商家与数据之后。

```
GET /api/v1/channel/aggregate?metric=price_dist&key=barcode:6901234567890
                              &bucket=month&k_min=10
```

约定（**现在只定形状**）：

| 参数 | 含义 |
| --- | --- |
| `metric` | 指标：`price_dist` / `spec_dist` / … |
| `key` | 聚合键：`barcode:<规范化条码>` 或 `category:<类目名>`。⚠️ **类目码不行** —— 它只在店内唯一，跨店聚合按码会把不同品类并成一个结果（会被 `400 bad_key` 拒掉，并在 `notes` 里说明） |
| `bucket` | 时间分桶（可选） |
| `k_min` | **最小样本量门槛** |

响应（约定形状）：

```json
{"metric": "price_dist", "key": "barcode:6901234567890",
 "n_stores": 37, "sample_ok": true, "k_min": 10, "dist": {"p25": 12.5, "p50": 18.0, "p75": 22.0}}
```

### 8.1 三条硬约束（**实现时必须遵守，不是可选**）

1. **`k_min`（最小样本量）**：参与聚合的**独立店数**低于门槛时**必须**返回
   `sample_ok: false` 且**不出任何数值**。理由：某条码只有 2 家店时，
   "平均值"**等于把两家的报价贴出来**。建议 `k_min ≥ 10`。
   这不是隐私合规要求（品源库里**没有客户资料**），而是**商业敏感**保护
   （价格/成本/库存/销量）。
2. **只出统计，不出个体**：响应**不得**含任何可定位到单个店的原始值或可反推的极值
   （`min`/`max` 在样本量小时同样会泄露）。
3. **商家 opt-in，且可撤回**：统计参与**不得默认开启**。商家必须显式同意，
   并能查看"我贡献了什么"、随时撤回。默认开启会把品源「数据归商家」的卖点变成负债。

### 8.2 为什么聚合**不挂在** `/products` 上

常见的另一种设计是把聚合做成列表口的一个参数（如 `GET /channel/products?aggregate=price_avg`）。
品源**刻意不这么做**，三条理由：

1. **鉴权与审计不同**：聚合要 `k_min` 门槛，且需要单独审计"谁查了哪些聚合"。
   混进 `/products` 会让**有商品读权的钥顺带拿到聚合能力**。
2. **参数语义不同**：列表口的 `category=` 是"筛选自己可见的商品"；
   聚合的 `key=` 是"以什么为聚合键"，且**必须**带 `k_min`。同名不同义是接口设计里最容易出错的。
3. **兼容包袱不同**：`/products` 已有多个键面的兼容承诺，每次动聚合都要动它的合同。

### 8.3 opt-in 是**强制前置**（不是提示）

聚合只统计 **`Store.aggregate_optin = true`** 的店（默认 `false`）。约束：

- **默认关**：不表态 = 不参与；
- **显式且可指认**：商家端由专门的接口开启（`PATCH /api/v1/store/aggregate`，
  必须显式给 `optin`），并记下**谁、何时**同意的；
- **范围只许收窄**：参与字段默认 `price`/`spec`；**`sales`/`stock`/`supplier`/`cost` 永不参与**
  （即使在库里手动塞进范围也会被实现滤掉）；
- **可撤回**：撤回**不追溯**已发布报告，**下一期起**不再纳入（口径见 `DATA-USE.md`）。

> **当前状态**：端点**已启用**。带有效钥请求返回 `200`（不带钥与其余渠道口一致返回 `401`）。
> 生产上**已 opt-in 的店数是 0** → 现在查任何 key 都会得到 `sample_ok: false` + `dist: null`，
> 并在 `readiness` 里说明「还差几家」。**这不是故障，是还没有商家同意参与。**

### 8.4 实现口径（v14/R-89，**写在这里因为实现方要按同一套算**）

| 口径 | 做法 | 为什么 |
| --- | --- | --- |
| **先店后跨店** | 一家店在一个组里**只出一个点**（它自己商品的中位值），跨店再统计 | 否则一家有 500 件商品的店会把分布拽成它自己的形状，`n_stores` 也成了摆设 |
| **不出 `min`/`max`** | 只给 `p25`/`p50`/`p75` | 极值**指向具体那一家**（最便宜的就是某一家店）；分位对单点不敏感 |
| **按 `pricing_basis` 分组** | 元/kg 与 元/件**分开算**，`by_basis` 各给各的 | 两者混在一个分布里算出的中位价**没有意义，而且看起来完全正常**（隐形错） |
| **逐组抑制** | 某组独立店数 < `k_min` → **整组不出** | 既不「给数值再警告」（警告最容易被忽略），也不并进「其它」（并桶更容易反推） |
| **`n_stores` 是「返回那个 `dist` 的样本量」** | 顶层 `n_stores` 取**参与店数最多的那一组**的独立店数，**不是各 `pricing_basis` 组店数之和**；不达标时它 = 最多那组的店数（此时 `dist` 为 `null`） | 元/kg 10 家 + 元/件 8 家时，「和」是 18 而 `dist` 只来自 10 家 —— 报 18 会让消费方**过度自信**，而 `n_stores` 正是判断"这个分布可不可信"的依据（2026-09-29 修） |
| **`k_min` 只能往上调** | 请求里的 `k_min` 低于默认（10）→ **按 10 执行**并回 `k_min_raised: true` | 否则调用方把它调成 1，前面所有保护都白做 |
| **`200` vs `400`** | 样本不足 → `200` + `dist: null`；指标不认识 / `key` 写错 → `400` | 「没数据」是**正常答案**，不是错误；而「这个指标没实现」必须让调用方知道，不能塞个 `error` 字段混在 `200` 里 |

已实现的 `metric`：**`price_dist`**（价格分布）· **`spec_dist`**（规格分布：各规格有几家店用，**只有一家店有的规格不列出**）。其余 `metric` 一律 `400 unsupported_metric` —— **不假装算了个别的**。
> **就绪度（`readiness`）不在这个口上**，在**商家侧合同开关** `GET /api/v1/store/aggregate` 的响应里：
> `{"optin_stores": <已 opt-in 的独立店数>, "k_min": 10, "would_publish": <bool>, "note": …}`
> —— 否则读的人无法判断"是没做，还是做了也没数据"。
> ⚠️ **2026-09-30 审查修正**：本段原来写着「**`501` 响应里带 `readiness`**」——
> 那在 R-63~R-88 的预留位时期是对的，但 R-89 起聚合口返回 **`200`**，
> 且 `readiness` 早已移到了商家口。留着旧说法会让第三方按 `501` 去写实现。
> 一致性套件对这条**现在会真跑**（此前记 `SKIP`）。生产上 `optin_stores=0` → 期望
> `sample_ok: false` + `dist: null`；**「样本不足」是正确输出，不是失败**。

---

## 9. 归因外环：**记号出得去、回执回得来**（**v14 新增**）

### 9.1 这是什么、解决什么

扣点（3–5%）一直开不了，因为**成交这件事既没有记号、也没有可信来源**。
这一节定义的是一条**不碰支付、不碰履约**的最小闭环：

```
① POST /channel/attribution/token     ← 你要下单链接时，先来换一枚**记号**
② 把记号放进你给顾客的下单链接 / 小程序路径
③ 顾客在你自己的商城下单、付款（这一步**与品源无关**）
④ POST /channel/attribution/callback  ← **你的服务端**把成交回执报回来
⑤ 商家在「归因回执」页看到每个渠道的成交与逐笔单据（见 §9.6）
```

| 端点 | 方法 | 作用 |
| --- | --- | --- |
| `/channel/attribution/token` | POST | 签发记号（带"这次推荐了哪些商品"） |
| `/channel/attribution/callback` | POST | **服务端**回执成交，落成**单据快照** |

### 9.2 三层结构（照 CRMEB 那套中层已经跑通的做法）

**渠道字典 → 事件流水 → 业务单据快照**。

第三层**必须是快照**：单据行里把渠道的 `code`/`name`/推荐商品**抄一份**，
**永不回查渠道字典**。理由：事后渠道改名、关系变化**都不许动历史归因** ——
否则上个月已经结过的账会跟着变。所以 `code` 入库后**不可修改**
（要换标识就新建一个渠道，历史单据留在旧 code 上）。

### 9.3 两条硬纪律（对接方必须按此实现）

1. ★ **回执必须来自你的服务端，不能来自浏览器。**
   浏览器能改自己发的东西 —— 由它报成交，扣点就收不住。
   所以回执口只接**渠道钥**（`X-Channel-Key`），而页面上的触点钥报的
   一律只算**意向**。
2. ★ **同一订单只能被一个渠道认领。**
   如果这张单已经被另一个记号认领过，回执会拿到 **`409`**，并且：
   · 先到的那条快照**不被覆盖**；
   · 我们这边留下冲突记录（分账纠纷的证据）；
   · **由人去判** —— 我们不替你判，也不"后到通吃"。

### 9.4 幂等与重发

回执是网络请求，**重发是必然的**（超时重试、对方系统重推）。
按 `(店, order_no)` 幂等：重复回执**不会**写第二行（否则重复计佣）。
重复回执**只允许改状态**（`paid` → `refunded`/`cancelled`，退款是合法业务），
**渠道快照字段一个字都不动**。

### 9.5 我们**不**存顾客资料（ADR-4）—— 与 CRMEB 那一套的关键差别

CRMEB 的事件主体是 **`uid`（用户）**：它把渠道绑到人身上，下单时把人读出来。
**品源不能这么做**：事件主体是**不透明记号**。
所以我们能回答"**这批推荐带来了几笔成交**"，
而**不能也不允许**回答"谁买的" —— 那件事在我们这边**不存在**。

> 守卫：`core/test_attribution.py` 里有一条**结构判据** ——
> 这三张表里不许出现任何像顾客标识的列（`customer_*`/`phone`/`uid`/`address`…），
> 并带反向哨兵（确认判据真扫到了列、且抓得到已知坏名字）。

### 9.6 商家侧可见：聚合 + 逐笔对账（**v17/R-94 收尾**）

回执落库后，商家端（登录令牌，不是渠道钥）有两个读口：

| 端点 | 作用 |
| --- | --- |
| `GET /api/v1/attribution/summary` | 按渠道聚合：总笔数、已付笔数、已付金额、退款/取消数 |
| `GET /api/v1/attribution/orders` | **逐笔单据快照**；可筛 `channel=`（渠道 code 快照）/ `status=`，分页 `limit=`（1–200，默认 50）/ `offset=` |

两个口与 §9.1 的回执**同源同口径**：

- 只含 `source=server` 的成交 —— 浏览器自报不会进这里；
- `paid_amount` 只累加 `status=paid`，退款/取消单列（扣点按它算，虚高会多收钱）；
- 渠道名是**单据快照**，渠道事后改名不会让历史对账换一个名字；
- 只出成交事实，**不出顾客**（表里本来就没有）。

这两个口属于**商家端接口**，不在 §5 的渠道兼容承诺内（可以随界面演进）；
但上面三条数据口径是归因这条线的合同，改它们等于改账。

---

## 10. ACT 目录口：`/channel/catalog`（**2026-10-15 新增**）

### 10.1 这是什么、为什么单独一个口

> **ACT**（`Agentic Commerce Trust Protocol`，智能体商业信任协议）的 **`CID-MER-CAT`**
> 组件规定：**商户向智能体开放结构化商品/服务目录时的最低信息要求**。
> 出处：<https://www.act-protocol.com/documentation/commerce>

它不是"又一个商品口"。它出的是**协议要的形状**，与本库 45 字段的形状只有四个字段重叠 ——
所以**独立成一个端点**，理由与聚合口不挂在 `/products` 上完全一致（见 §8.2：
**同名不同义是接口设计里最容易出错的**）。

### 10.2 必备四项（**逐字取自 ACT，不是我们改写的叫法**）

| # | ACT 原文要求 | 本口字段 |
|---|---|---|
| ① | 商品或服务标识，应为**全局唯一或在商户域内可唯一解析**的标识 | `id`（全局 `uid`）＋ `merchant_code`（`<店码>:<商品码>`，店内唯一），**两个都给** |
| ② | 商品名称或服务名称 | `name` |
| ③ | 商品类目或服务类别，应能支持**与用户意图约束进行匹配** | `category{code,name,uid}` |
| ④ | **明确的标价及货币单位**，应能支撑金额比较、规则前置检验和支付前确认 | `price{amount,currency}` |

**推荐四项里我们提供四项**（v15 起）：`availability`（库存/可售）· `quoted_at`（**报价更新时间**）·
**`price_valid_until`（价格有效截止，v15 新增）** · `merchant`（商户标识）。
**未提供的前两项在响应的 `act.not_provided` 里逐条写明原因** ——
没有的字段**不许悄悄消失**（那会让买方以为"这商品真没价格有效期"，
而事实是"我们还没有这个字段"）。★ 反过来也守：**已经提供了的键，不许再留在
`act.not_provided` 里** —— 自述与事实矛盾时，判据（`tools/conformance.py` G6）当场红。

### 10.3 ★ 三条**必须照做**的读法

**① `catalog_ready` 才是能否用于支付前确认的开关。**

ACT 原文：「当目录结果无法满足最低信息要求时，买方智能体**不宜**直接将该结果用于购物车确认
或支付前衔接」。所以本口**不静默丢数据**：缺必备项的行**照出**，但带
`catalog_ready: false` + `missing_required: ["category","price"]` 之类的清单。
**由你决定怎么处理，我们不替你丢。**

**② `quoted_at` 为 `null` 时，必须当「未知 / 按陈旧处理」，不许当「新鲜」。**

它取 `ProductAttr(price).updated_at`，**可空且刻意不回填**。理由是本库自己的纪律
（`server/core/models.py` 原话）：

> 「可空且不回填……这里刻意不像 0015 回填 `source` 那样拿 `product.updated_at` 近似——
> 分类值猜错影响有限，**时间戳猜错会让智能体把陈年价格当成新价，比没有更糟**。
> 消费侧必须把 null 当「未知/按陈旧处理」，不许当「新鲜」。」

所以**本口不会拿商品级时间戳顶替**，响应里的 `act.quoted_at_rule` 也把这条写出来了。

**③ `price_state` 三态 —— 只有 `expired` 会阻塞（v15）。**

`quoted_at` 回答"这份报价**什么时候写的**"，`price_valid_until` 回答"这份报价**还算不算数**"
—— 买方做支付前确认要的是后者。`price_state` 取值：

| 值 | 含义 | 是否阻塞 `catalog_ready` |
|---|---|---|
| `ok` | 填了，且未到期（**含今天**） | 否 |
| `expired` | 填了，且**已过期** | ★ **是** —— 且 `missing_required` 会含 `price` |
| `unknown` | 商家**没填** | 否 |
| `invalid` | 填了但解析不了（历史脏值） | 否（**不猜**，值出 `null`） |

★ **这条区分是从 ACT 原文推出来的，不是我们定的策略**：

- 必备项④用「**应**能支撑……支付前确认」⇒ 一个**已过期**的标价在定义上就不满足它
  ⇒ 阻塞。所以 `missing_required` 里出现的是**必备项** `price`（一个作废的标价
  不是"可用的标价"），而不是新加一个非必备项 —— `missing_required` 的契约是"只列必备项"。
- 推荐项②用「**宜**进一步包含」⇒ 商家**没填**不构成必备项不满足 ⇒ **不阻塞**。
  但输出 `unknown`，**是不是「未知就不许下单」由买方 Agent 自己定**
  （本仓口径：只出数据不出策略）。

`price_valid_until` 本身取 `FieldDef` 的 `price_valid_until`（`ftype=date`，`level=optional`），
商家端用原生日期控件填；校验器 `core/fieldcheck.DATE_RE` 只收 `YYYY-MM-DD`。

### 10.4 鉴权、分页与限流

ACT 明说「**本组件当前版本暂时不定义统一的目录协议**」「**暂不对目录接口的调用路径、
请求方法、认证机制和分页方式作统一规定**」—— 所以本口**沿用**：

- **鉴权**：`X-Channel-Key`，与其余渠道口逐字一致（缺钥/错钥 `401`；看不见的店与不存在的店
  **回同一个 `404`**，否则这条口就是店码枚举器）；到期/停用店同样 `404`。
- **分页**：游标（`?cursor=`），语义同 §3.1；`?limit=` 默认 100、上限 500。
- **范围**：`?store=<店码>` 可选；不带 = 该钥可见的全部店，且**跨店请求只出 `discoverable` 的货**
  （口径同 `/channel/products`）。

### 10.5 不出的东西

- **进价 `cost` 一个字节都不带**（`fieldroles.NEVER_OUT_KEYS`）。ACT 要的是**标价**，不是成本。
- 不出推荐排序、不出策略 —— 比较与最终决策属买方智能体本地处理（ACT 原文划在规范范围之外）。

---

## 11. 能力发现：`/.well-known/rec-agent`（**2026-10-15 新增**）

### 11.1 它解决什么

在上面 §1–§10 之前还有**一步**：对方**还不知道我们是谁**。
`/api/v1/schema`（§2）与 `docs/PROTOCOL.md` 都要求对方**先知道去哪读** ——
而钥是**签出来的**，签给谁取决于"我们已经认识谁"。这是一个鸡生蛋问题。

`/.well-known/rec-agent` 就是那一步的答案：**不要钥、不含租户数据、一句话说清
"我是谁、支持哪些能力、用哪个请求头鉴权、是不是 ACT 兼容"**。
位置用 RFC 8615 约定的 `/.well-known/`，是各家 agent 生态的通行做法。

> ⚠️ **它在根路径、不在 `/api/v1` 下。** 这不是笔误：发现端点若要求对方先知道
> 我们的命名空间，就不叫发现了。相应地**部署时 nginx 必须显式放行**（见 §11.4）。

### 11.2 响应形状（键面稳定）

| 键 | 说明 |
|---|---|
| `protocol` / `agent_version` / `min_compatible_version` | 协议名与版本。**与 `schema_version` 是两件事**：前者是这一层的版本，后者是字段合同的版本 |
| `role` | **`supply`** —— 本端点描述的是**供给方**（出库卖货） |
| `provider` | 服务名 |
| `schema_version` | 与 `/api/v1/schema` **同一个值**（同一份 payload，不是抄的） |
| `spec` | 去哪读规范：`self_describe` / `protocol_doc` / `compatibility` / `data_use` / `conformance_suite` / 两个客户端实现 |
| `capabilities` | **当前真的具备的能力清单**（见 §11.3） |
| `transports` | 传输方式，现为 `["rest"]` |
| `auth` | `header: X-Channel-Key`；并说明"键面按这把钥看得见的范围收口" |
| `act` | `component` / `source` / `endpoint` / `compatible` —— **只指向、不复制**（见 §11.3） |

### 11.3 ★ 两条**不许违反**的设计约束

**① `capabilities` 必须从 URL 表现算，不许手写清单。**

实现上每一项能力对应一个路由名，运行时只声明 `reverse(<路由名>)` **解得出来**的那几条。
于是路由被改名/删掉时：**自述诚实降级（少一条），而测试当场红**
（`core/test_discovery.py` 断言表里每一条都解得出来）。

为什么不能手写：手写清单必然与路由漂移，而漂移的表现是**"自述说有、实际没有"** ——
对方会照着它写对接代码然后跑到一半 404。**那比没有自述更坏**：
没有自述它会先来问人，自述撒谎它会直接开工。

**② `act` 段只指向、不复制。**

必备四项 / 推荐项 / **未提供项逐条原因** 这些清单**住在 §10 那个端点的响应里**
（`/channel/catalog` 的 `act` 段）。发现端点里再抄一份就是**第三份真相** ——
改了一处忘另一处，就成了"一处说提供、另一处说没有"。
所以这里只给 `endpoint`（**同样是算出来的**，不是手打字符串）与一句"去那里读完整自述"。

### 11.4 ⚠️ 部署：nginx 必须显式放行（**否则本地好、线上 404**）

本仓在 `/c/`、`/s/` 上踩过一次同一个坑（见 `server/pinyuan/urls.py` 里的注释）：
nginx 的 `location /` 是 `try_files $uri $uri/ =404`（静态直出），
而这个路径下**没有真文件** —— 不显式转给 gunicorn 就线上 404，**而本地测试永远绿**。

```nginx
location = /.well-known/rec-agent {
    proxy_pass http://127.0.0.1:8970;
    ...
}
```

- 用 `=` **精确匹配**：只放行这一个路径，不整段开放 `.well-known/`
  （那里将来可能放 ACME challenge 之类的东西）。
- **不挂 `limit_req`**：它是发现口，买方在拿到钥之前就要读它；限流会把"第一面"变成 429。
- 上线脚本：`install/nginx-wellknown.sh`（幂等；先备份，`nginx -t` 不过**自动回滚**）。
- 判据：`core/test_wellknown.py::生产必须配nginxTests` 断言配置里真有那一行**且真的 `proxy_pass`**。

### 11.5 与导购侧那个**同名端点**的区别（别混）

| | 品源（根路径） | 品源导购（挂在 `/guide/` 下） |
|---|---|---|
| 路径 | `/.well-known/rec-agent` | `/guide/.well-known/rec-agent` |
| `role` | **`supply`**（供给方：出库卖货） | **`demand`**（消费方：导购 / 坐席 / 人机协同） |
| 描述的能力 | 出库面的能力 | 导购智能体的能力 + 人机协同（GB/T 47746—2026） |

生产拓扑里导购在 `/guide/`，所以**根路径归品源**、两者不冲突。
一个"在这个域名上找**供给方**"的买方智能体，要的正是根上的这一个。

---

## 12. MCP 桥：把出库面接进任何 MCP 客户端（**2026-10-15 新增**）

### 12.1 是什么、在哪

`tools/pinyuan_mcp.py` —— **手写的 MCP（Model Context Protocol）stdio 服务端**，
把本规范 §5–§10 的渠道面暴露成 MCP 工具：

| MCP 工具 | 对应的本规范条目 |
|---|---|
| `schema_describe` | §2（`/api/v1/schema`，**不带密钥**） |
| `stores_list` | §5 店目录 |
| `field_rules` | §6.1 按类目的三档门槛 |
| `catalog_act` | **§10**（ACT `CID-MER-CAT` 兼容目录） |
| `catalog_search` | §5 商品口（本库 45 字段全量形状） |
| `aggregate_query` | §8 跨店聚合（只出统计不出个体） |

它**建在 §1 的参考客户端 `tools/pyclient.py` 上**，不重写 HTTP 层 ——
拼 URL / 解信封 / 认键只有那一份实现（那份文件头部记着"各写各的"踩过的三类真实事故）。

### 12.2 ★ 为什么手写，而不用官方 `mcp` SDK

MCP 的 stdio 传输就是**换行分隔的 JSON-RPC 2.0**（`initialize` / `tools/list` /
`tools/call` 三个方法即可跑通），手写 ≈200 行。收益有三条：

1. **一个依赖都不加。** 官方 SDK 是 ASGI/Starlette 的（`streamable_http_app`）——
   装进品源也搬不进它的 gunicorn **WSGI** 进程（那要换 worker class，动的是**整站在线率**）。
   为一座本地客户端桥付这个代价不划算。
2. **与本仓既有口径一致**：`conformance.py` / `pyclient.py` 一直是"纯标准库、零依赖"。
3. **第三方不必装 `mcp`** 就能用这份桥（它已列入公开面；清单是仓内的 `publish-list.txt`）。

**但"手写"必须付两笔代价，都写进了判据：**

> ⚠️ 下面两条指向的是**仓内脚本**（`check-mcp-sdk.py` / `test-pinyuan-mcp.py`），
> 它们**不在公开面里**（不在 `publish-list.txt`），所以这里写的是裸文件名、
> **不是可点的发布路径**。理由分别是：对拍脚本需要 `mcp` SDK（品源刻意不装），
> 单测与公开面无关（公开的是 `pinyuan_mcp.py` 本体）。

- **形状是否合协议** —— 手写实现最容易"本地看着对、客户端不认"。
  外部证据：`check-mcp-sdk.py`，**用真 `mcp` SDK 当客户端**驱动它
  （`initialize → list_tools → call_tool`）。**我的实现过不了它就是我的实现不对。**
- **stdout 只许出现协议消息** —— 往协议流打一行人话，客户端那条 JSON-RPC 当场解析失败，
  而报错位置离真因很远（表现为"服务器不可用"）。
  判据：`test-pinyuan-mcp.py` 有专门一组守这件事（含"异常时诊断只许走 stderr"）。

### 12.3 ★ v1 **只读，不暴露任何写口**（刻意的决定，不是没做完）

`stock_report`（报销量扣库存）**不在工具表里**。理由**不是工作量**，是**风险类别不同**：
MCP 工具由**模型**决定要不要调 —— 一个可被模型触发的"改商家库存"动作，
与"读商品目录"不是一回事。要写就显式走 §5 的 REST 口或 `pyclient`，**那一步留给人。**

判据在 `test-pinyuan-mcp.py::只读_一个写口都不许有`（仓内脚本，不发布）：谁想加写口，
会先红在那里，然后必须**显式改掉那条判据**（= 一次看得见的决定）。

### 12.4 跑法

```bash
export PY_BASE=https://<你的实例>
export PY_CHANNEL_KEY=<渠道密钥>
python3 tools/pinyuan_mcp.py                 # stdio，客户端替你起进程
python3 tools/pinyuan_mcp.py --list-tools    # 不开服务，只打印工具表（排障用）
```

MCP 客户端配置：

```json
{"mcpServers": {"pinyuan": {
    "command": "python3",
    "args": ["/绝对路径/tools/pinyuan_mcp.py"],
    "env": {"PY_BASE": "https://...", "PY_CHANNEL_KEY": "pyk_..."}}}}
```

⚠️ **配置缺失时的行为是刻意的**：`initialize` 照常回（否则客户端连工具列表都看不到），
但每次 `tools/call` 都回**点明缺哪个环境变量**的错误 —— 两种坏法都避开了：
启动即退出（客户端只说"服务器启动失败"）、静默回空（人会去查品源的错）。

### 12.5 服务端原生工具接口 `/channel/tools`（**v17/R-94 新增**）

§12 的 `pinyuan_mcp.py` 是**客户端侧**的桥（本地进程把 REST 转成 MCP）。
本节是**服务端侧**的同形接口：任何智能体/渠道不必装桥，直接走 REST 也能拿到
"工具清单 + 工具调用"这两件事。形状刻意与 MCP 对齐，将来做适配器只是换传输：

| 动作 | 本接口 | 对应的 MCP 方法 |
|---|---|---|
| 拿清单 | `GET /api/v1/channel/tools` | `tools/list` |
| 调工具 | `POST /api/v1/channel/tools/call`，体 `{name, arguments}` | `tools/call` |

**鉴权与其余渠道口逐字一致**（`X-Channel-Key`，缺钥/错钥 `401`）。
每个工具声明自己的 `capability`，调用时按它过渠道档案（未开通 `403`）：
`search_services` / `get_service` 用 `catalog_read`，`get_availability` 用 `availability`。

**本版只有读工具**：

| 工具 | 入参 | 返回 |
|---|---|---|
| `search_services` | `q` / `store` / `category` / `date` / `limit`（1–50，默认 20）/ `offset` | `{items, total, limit, offset}`；`date` 只命中**当天还有余量**的服务 |
| `get_service` | `uid`（必填）、`store` | 单条服务项（`price` + `service` 块） |
| `get_availability` | `uid` + `date`（必填）、`store` | 与 §2.8.1 的 availability 口同一形状 |

跨店调用沿用 §1.2.1 的 `discoverable` 口径；看不见的店与不存在的店同形 `404`。

**`book` / `verify` 只声明、不实现**：清单里 `available: false` 并给出 `reason`，
调用在**验钥通过之后**返回 `501`（不是 `404`、也不是 `403`）。

**交易边界已定（2026-10-20）：品源止于「目录 + 归因」。**
品源不承接订单、履约、核销与结算 —— 渠道用自己的交易链路下单/核销，
成交后按 §9 的归因回执把结果带回来。所以这两个工具**不会**变成订单接口；
假装能下单会让渠道误以为品源兜底履约，那是比"没有这个工具"更坏的错。

⚠️ 这个 `501` **不受渠道档案的能力开关影响**：`booking` / `verify` 不是
`ChannelProfile.CAPABILITIES` 成员，"不做"是全局事实，不是"这个渠道没开通"。

---

## 13. 插件包（Pack，**2026-10-28 新增**）

### 13.1 这是什么

品源从单行业目录升级为「平台基础 schema + 行业/服务/知识插件」。**插件字段不进
`items[].fields`** —— 那个键面继续保持平台基础字段的稳定形状；插件字段单独出，
这样老消费方零改动，新消费方按需读。

插件版本（`PackRelease.version`）**独立于** `schema_version`：升级一个行业包
不需要动平台版本号，也不影响没绑这个包的店。

### 13.2 端点

```http
GET /api/v1/channel/packs?store=<店码>
X-Channel-Key: pyk_...
```

带 `?store=` 时出这家店绑定的包：

```json
{
  "schema_version": 15,
  "store": {"code": "st0001", "name": "晋味优品-测"},
  "packs": [
    {
      "code": "agriculture",
      "name": "农业基建",
      "pack_type": "industry",
      "version": "0.1.1",
      "fields": [{"key": "agri_temp_zone", "label": "温区", "ftype": "enum",
                   "kind_scope": "product,service", "level": "required", ...}],
      "categories": [...],
      "acceptance_templates": [...],
      "risk_tags": [...],
      "capabilities": [...],
      "benchmark_configs": [...]
    }
  ]
}
```

不带 `?store=` 时只出平台已安装包的 `code/name/pack_type/version/description`，
**不出字段**（字段是店级的，脱离店谈会误导）。

### 13.3 商品信封里的插件字段（纯增键）

```json
{
  "fields": {"name": "…", "price": "…", "...": "平台基础字段，键面不变"},
  "packs": ["agriculture"],
  "pack_fields": {"agri_temp_zone": "冷冻", "agri_volume_m3": "100"},
  "pack_fields_structured": {"agri_volume_m3": 100},
  "pack_field_meta": {"agri_temp_zone": {"source": "merchant", "verified": false,
                                          "verified_at": null, "updated_at": "..."}}
}
```

- 只出 `visibility=public` 的插件字段；`internal`（进价、证书编号、内部备注）
  一个字节都不带。
- 商品没有 `ProductPack` 时回落到店铺 `StorePack`；两者都没有 = `packs: []`。
- `fields_structured` / `field_meta` 仍只对应基础字段；插件字段看 `pack_*` 三键。

### 13.4 `field-rules` 的 `pack` 段

`GET /api/v1/channel/field-rules?store=<店码>&kind=<类型>&category=<类目>`（`pack=` 同理）。
⚠️ `effective` **只在传了 `category` 或 `kind` 时出现**；不带这两个参数时响应与老版本
逐字节一致（只是多了 `packs`/`requested_packs` 两个空段）：

```json
{
  "schema_version": 15, "field_version": 15,
  "base": {"required": [...], "recommended": [...], "optional": [...]},
  "by_category": {...},
  "packs": {
    "agriculture": {
      "version": "0.1.1",
      "base": {...}, "by_category": {...}
    }
  },
  "requested_packs": ["agriculture"],
  "effective": {"required": [...], "recommended": [...], "optional": [...]}
}
```

- 不传 `pack`/`store`：`packs={}`、`requested_packs=[]`，`base`/`effective` 与从前逐字节一致。
- 传了：`effective` 是**基础 + 包字段**合并后的门槛；`base` 仍是基础字段。

### 13.5 纪律

1. **插件字段 key 全局唯一**；不许撞基础字段或其他包（`pack_check` 拦）。
2. **同一版本 manifest 不许改内容**；改内容必须升版本（哈希校验）。
3. **停用不删数据**：停用只停止新绑定/新出库；字段值保留，重新启用即恢复。
4. 不要硬编码插件字段清单：运行时读 `/channel/packs` 或 `/channel/field-rules?store=`。

---

## 14. 需求/供应商/基准/验收（Phase 6 渠道口，**2026-10-28 新增**）

给买方 Agent（品源导购等）读五域、写需求。鉴权仍是 `X-Channel-Key`，
能力位单独声明：`requirement_read` / `requirement_write` / `supplier_read` /
`benchmark_read` / `acceptance_read`。**没声明这些能力位的旧钥不受影响**（旧口照旧）。

| 方法 | 路径 | 能力位 | 说明 |
|---|---|---|---|
| GET | `/channel/requirements?store=&status=&limit=` | `requirement_read` | 可见店的需求（无联系方式） |
| POST | `/channel/requirements` | `requirement_write` | 代买方建需求，落 `draft`；`spec_json` 按插件 `requirement_schema` 校验，缺必填 400 + `errors` |
| GET | `/channel/requirement-quotes?requirement=<uid>` | `requirement_read` | 该需求的报价（金额/交期/有效期/逐行；供应商只出店码与店名） |
| GET | `/channel/suppliers?pack=&province=&city=` | `supplier_read` | 公开供应商画像；**无证书编号、无内部备注、无黑名单** |
| GET | `/channel/benchmarks?pack=&category=&province=&city=&spec_key=&limit=` | `benchmark_read` | 匿名区间 p25/p50/p75 + `sample_stores`，样本不足的桶不存在 |
| GET | `/channel/acceptance/templates?pack=&category=` | `acceptance_read` | 模板 + 检查项（方法/阈值/证据类型） |
| GET | `/channel/acceptance/runs?requirement=<uid>` | `acceptance_read` | 执行状态与逐项结果；证据只出 `type/value/note`，**不出 `file_path`** |

**写口只有建需求一个**：改状态、报价、验收签认都在商家端/管理端由人完成；
渠道不提供这些写口（Phase 6 刻意留的口子，不是没做完）。

记账：读口记 `result_count`，写口记 `requirements_write`。

---

## 附录 A：最小可用客户端（伪代码）

```
1. GET /api/v1/schema                # 读词汇表与 schema_version（公开）
2. GET /api/v1/channel/stores        # 走完 next_cursor 取全部可见店
3. GET /api/v1/channel/products?store=<店码>&min_level=required
   循环 cursor=next_cursor 直到 null     # 属性在 item["fields"]
4. 写回：POST /channel/stock_report    # 带稳定的 request_id
```

**不要做**：硬编码字段清单、硬编码店清单、只读首页、解析游标、
用 `fields.barcode` 当认款键、用外层 `store` 当条目的店、把 `5xx` 当"没数据"。

---

## 附录 B：本文件的可核验断言

下面这些注释里的声明由测试守卫核验，**改动本文件或改动实现都必须让它们保持一致**
（守卫在 `server/core/test_spec_drift.py`）。每条断言的**值**都必须能被真实响应/真实路由证伪 ——
守卫里不允许存在"看起来在查、其实恒真"的断言。

关于 `*.envelope_keys`：它是**精确相等**（不是子集），语义与代价见 §2.1.2 ——
对外新增键是兼容变更，但**对内**必须同时更新 §2.1 的表、本附录、以及 `/api/v1/schema` 的 `vN` 段。

<!-- spec-assert schema.version = 15 -->
<!-- spec-assert kind.values = product,service,knowledge -->
<!-- spec-assert kind.scope_field = kind_scope -->
<!-- spec-assert endpoint.public = /api/v1/schema -->
<!-- spec-assert endpoint.products = /api/v1/channel/products -->
<!-- spec-assert endpoint.stores = /api/v1/channel/stores -->
<!-- spec-assert endpoint.categories = /api/v1/channel/categories -->
<!-- spec-assert endpoint.field_rules = /api/v1/channel/field-rules -->
<!-- spec-assert field_rules.keys = schema_version,field_version,base,by_category,effective,match_rule,levels,packs,requested_packs -->
<!-- spec-assert endpoint.moves = /api/v1/channel/moves -->
<!-- spec-assert endpoint.events = /api/v1/channel/events -->
<!-- spec-assert endpoint.products_batch = /api/v1/channel/products-batch -->
<!-- spec-assert endpoint.stock_report = /api/v1/channel/stock_report -->
<!-- spec-assert endpoint.stock_return = /api/v1/channel/stock_return -->
<!-- spec-assert endpoint.consumer_orders = /api/v1/channel/consumer/orders -->
<!-- spec-assert endpoint.metrics = /api/v1/channel/metrics -->
<!-- spec-assert endpoint.aggregate = /api/v1/channel/aggregate -->
<!-- spec-assert endpoint.catalog = /api/v1/channel/catalog -->
<!-- spec-assert endpoint.attribution_token = /api/v1/channel/attribution/token -->
<!-- spec-assert endpoint.attribution_callback = /api/v1/channel/attribution/callback -->
<!-- spec-assert endpoint.tools = /api/v1/channel/tools -->
<!-- spec-assert endpoint.tools_call = /api/v1/channel/tools/call -->
<!-- spec-assert page.max = 500 -->
<!-- spec-assert page.default = 100 -->
<!-- spec-assert page.default_stores = 200 -->
<!-- spec-assert auth.header = X-Channel-Key -->
<!-- spec-assert products.list_key = items -->
<!-- spec-assert products.cursor_key = next_cursor -->
<!-- spec-assert products.envelope_keys = schema_version,generated_at,store,stores,filters_applied,excluded,items,next_cursor -->
<!-- spec-assert stores.list_key = stores -->
<!-- spec-assert stores.cursor_key = next_cursor -->
<!-- spec-assert stores.envelope_keys = schema_version,generated_at,total,filters_applied,next_cursor,stores -->
<!-- spec-assert moves.cursor_key = next_since -->
<!-- spec-assert moves.envelope_keys = store,generated_at,items,next_since -->
<!-- spec-assert stock_report.idempotency_field = request_id -->
<!-- spec-assert error.detail_key = detail -->
<!-- spec-assert rate_limit.body_key = retry_after -->
