Files
PolyLoop/research-wiki/design/0017-gateway-forwarding.md
iomgaa d2be742383 docs(design): 落定网关转发改用保留前缀的方案
回答 GovDoc-SaaS 提的 issue #6,取代 0012 决策四。那一条的结论(只转发 session_id 与
parent_call_id)和它的理由(网关只有两个槽位)都不成立:0012 定于 2026-08-10,而依赖下界
polygateway>=1.1 里 registry 上唯一装得到的 1.1.1 发布于 08-06,那一版的 chat() 上已经有四个
str | None 的槽位。被挡在外面的 cache_namespace 恰恰是上游指定的租户隔离手段,挡掉之后两个
租户提交相同文本时,第二个会读到第一个的模型输出。

要换掉的不是那份白名单的取值,是「哪些键往下传由下游取的名字和网关取的名字偶然相同来决定」
这个机制——补成五个键只是把同一个陷阱重新上好膛。改成绑定里带 gateway. 前缀的键才转发,前缀
之后整段当 chat() 的关键字参数名。由此本库不必列举网关能接受哪些坐标维度,依赖下界照旧
>=1.1,<2,上游此后加维度也不用本库发版(限于取值是字符串的维度,绑定装不下别的类型)。

issue 给的三条路都不采纳,理由分别在决策一到决策三;meta 这一维不做,写在决策五。四条防御
(结构性参数、空串或纯空白、前缀后无参数名、不带前缀的两个历史裸键)都在 call() 里抛
ValueError,表现为一次「第 0 步就以 LLM_ERROR 收尾」的运行。

过了两轮硕士生冷读。第二轮抓出一处事实错误:初稿跟着 issue 写了「下游自己写的 ModelClient
契约套件覆盖不到」,而 polyloop.testing.ModelClientContract 1.0.2 起就随包发布,继承它就能跑,
取消传播恰恰是它覆盖的五条之一。背景那一段换成了成立的那条代价——两份网关适配器各自漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:52:37 -04:00

346 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design 0017 · 网关适配器往下传哪些键
**日期** 2026-08-29 · **状态** 已接受(2026-08-29 项目负责人确认)
**回答** 实验室 Gitea 上 PolyLoop 仓库的 issue #6,标题是「转发白名单挡掉了 cache_namespace
而上游指定它做租户隔离」,提出者是下游项目 GovDoc-SaaS。
**取代** `0012-gateway-model-client.md` 决策四。那一条的结论(只传两个键)与它的理由(网关
只有两个槽位)都不再成立,理由见背景。`0012` 的其余五条决策不受影响。
**触及** `../../src/polyloop/adapters/__init__.py`
`../../tests/integration/test_gateway_model_client.py``../../CHANGELOG.md`,以及
`../../research-wiki/migrations/govdoc-saas.md`。逐处要改什么在文末的回写清单。
## 读本文需要的几个名字
**接缝** 是本库留给下游替换实现的接口点,一律是 `Protocol`。模型调用接缝
`polyloop.ports.ModelClient`)只有两个方法:发一次调用,以及上报自己的可复现参数。本文讲的
`GatewayModelClient` 是这个接缝的一个实现,作用是把本库的一次调用翻译成 PolyGateway 的一次
治理调用。
**装配分成两半。** 跨运行不变、可以并发复用的那一半是 `AgentDefinition`,四个接缝挂在它上面,
模型调用接缝是其中之一。随每次运行变的那一半是 `RunRequest`,预算、工具、上下文、绑定挂在
它上面。这条分界在决策三里是判据。
**绑定** 是下游项目自己的坐标,库不解释内容、原样透传给模型调用接缝。它在 `RunRequest` 上的
字段名是 `model_binding`,类型 `Mapping[str, str]`,到了模型调用接缝手上叫 `ModelCall.binding`
**它进参数快照时的键前缀是 `request.binding.`,不是字段名那个 `model_binding`**——同一样东西
在三处有三个写法。
**参数快照** 是一次运行开始时算出来、写进运行开始记录的一份字符串到字符串的映射,回答的是
「这次运行是什么设置」。它汇的是绑定与配方指纹这两个请求字段,加上向四个接缝各问一次
`parameters()` 得到的结果。**配方指纹**是挂在运行请求上的另一份字符串到字符串的映射,记的是
这次运行用的材料是哪一版——提示词模板的哈希、技能库的版本这类;它和绑定的分界是「坐标还是
配方版本」。**续跑**指的是一次运行崩了或被打断之后,用同一个运行标识接着往下跑;开工前会
重算一次快照并与日志里那份逐字段比对,对不上就抛 `ParameterDriftError` 中止,免得跑出一条
前后来自两套配置的轨迹。
**`parameters()`** 是接缝上那个上报可复现参数的方法。模型调用接缝这一侧,`GatewayModelClient`
报的是 scope 与源列表。**scope** 是网关里一组模型源的命名分组,也是网关的治理单位——限流、
熔断、缓存的账都按 scope 记。**源**是这个分组里的一个具体端点,一个源就是「供应商 + 地址 +
模型名」那一组,多个源可以指向同一个模型。这两样合起来回答的是**这次运行用的是哪个模型、
打到哪儿、带着哪些恒定采样参数**,这一整份叫「模型身份」。它进快照时的键前缀是
`model_client.`
网关 `chat()` 签名上的几个参数:
- **`cache_namespace`** 是缓存键的一段。缓存键由「模型指纹 + 消息摘要 + 命名空间」构成,
命名空间不同的两次调用读不到彼此的缓存。**模型指纹**是网关自己算给缓存键用的那一份,按
模型名去重;它和上面那个模型身份是两侧的两样东西,模型身份更严——改一个源名也要报出来。
**不传不等于不缓存**:网关取值的写法是「本次调用给了就用本次的,没给就用网关装配时配的
那个默认命名空间」,所以不传是「和所有人共用一格」。
- **`cache_salt`** 是同一命名空间内再分一层的字符串。给同一批消息换一个盐,本该命中的调用
就会打空、真的重新去问模型——需要同一批输入跑多轮且每轮都要真实调用的下游用得上它。
- **`tenant_id`** 与 **`meta`** 是网关 1.3.0 起新增的两个调用方自定义维度,只进它的遥测、
不进缓存键。前者是遥测表里的真实列(可挂行级安全、可进复合索引),后者是任意键值容器。
- **`overlay`** 是采样参数覆盖层(`temperature``seed``max_tokens` 这类),优先级高于配置
里那份恒定采样参数。**`structured`** 要求模型按一个给定的结构返回,网关会为此改写请求并在
返回前校验、必要时重试。
## 背景
issue #6 指出的事实逐条核过都成立。
**适配器只往 `chat()` 传两个键。** `adapters/__init__.py` 里那份模块级白名单是
`("session_id", "parent_call_id")`:绑定里键名与它们相同的那些被当作同名关键字参数传给
`chat()`,其余的键留在参数快照里、不往下传。
**给出的理由是「网关只有两个槽位放得下这类东西」,而这句话在写下的那天就已经不成立。**
`0012` 定于 2026-08-10`pyproject.toml` 声明的依赖下界是 `polygateway>=1.1,<2`,而 1.1.x 里
registry 上唯一存在的版本 1.1.1 发布于 2026-08-06——也就是说定 `0012` 的那天,装得到的最低
版本上就已经有四个 `str | None` 的槽位:`session_id``parent_call_id``cache_salt`
`cache_namespace`。(1.1.0 有过版本号但从未上传,装不到,这正是 `CLAUDE.md` §1.10 记的那个
教训。)所以这不是一条随上游演进而过期的判断,是当时就核错了。1.3.0 上又多了 `tenant_id`
`meta`1.1.1 上没有这两个。
**被挡掉的 `cache_namespace` 正是上游指定的租户隔离手段。** 网关自己把这件事写死了两处:
网关装配时启用了缓存却没配命名空间,它直接拒绝装配,报错原文是「缓存 key 靠它做租户
隔离」;`tenant_id` 的 docstring 明写它不进缓存键,租户隔离由 `cache_namespace` 负责。上游
指派了一个机制来守这条边界,而经过本库之后那个机制传不进去。
失败场景具体:两个租户提交了内容相同的一段文字,两次调用的模型指纹、消息摘要、命名空间三项
全部相同,于是第二个租户读到第一个租户那次的模型输出。这不是缓存效率变差,是跨租户读到别人
的内容。这一条是本次判断里权重最大的那个事实。
**下游能绕过去,代价是多养一个适配器。** `ModelClient` 只有两个方法,下游自己写一个实现不
难,而且不会因此掉出机器兜底:本库随包发布的公共契约套件里就有模型调用接缝那一份
`polyloop.testing.ModelClientContract`),自己写的实现继承它、覆盖 `model_client`
`failing_call` 两个 fixture 就能跑。它守的是接缝层面的五条:返回三个字段、调用标识绝不为
空串、失败以异常表达、取消要能穿过模型调用且 `CancelledError` 不许被吞、签名里不出现重试与
限流参数——取消传播恰恰是套件覆盖了的那一条。
**套件覆盖不到的是翻译那一段。** 把本库的一次调用变成这个网关的一次调用——消息怎么拼、网关
抛的异常怎么原样穿出、调用标识怎么从返回里取、模型身份怎么算——是这个适配器特有的行为,不是
接缝对所有实现的承诺。所以任何一个自己写的网关适配器,在这部分没有机器兜底。
**真正的代价是两份适配器各自漂移**,这一条是提出者自己写的:他们会长期维护第二个网关适配器,
和本库自带的那个唯一区别是多传几个参数;两份会各自往前走,而漂移的那天不会有任何东西提示。
issue 提了三条可能的做法:补白名单、把白名单做成构造参数、把这几个值做成构造参数。三条都不
采纳。
## 决策一:转发不再按名字撞,改成绑定里的保留前缀
绑定里键名以 `gateway.` 开头的,前缀之后那一整段当作 `chat()` 的关键字参数名,值原样传下去。
其余的键照旧不传、不报错。
```python
model_binding={
"book": "b7", # 项目自己的坐标,不传
"gateway.cache_namespace": "acme:v1:tenant:x7", # 传成 chat(cache_namespace="acme:v1:tenant:x7")
"gateway.tenant_id": "x7",
}
```
**前缀之后不再解析,点号也算参数名的一部分。** `gateway.meta.foo` 交给 `chat()` 的参数名是
`meta.foo`,网关签名上没有这个名字,于是抛 `TypeError`。绑定这一侧不为点号编一层嵌套,理由
在决策五。
**其余的键不报错,这是一条有意划的线。** 本库不知道下游的坐标该叫什么,所以对它不认识的键
一律保持沉默——一个叫 `book` 的坐标不是一次写错了的转发。唯一的例外是 `session_id`
`parent_call_id`:这两个名字今天真的会被转发,对它们保持沉默等于静默改变行为,所以它们要
报错,改法见决策四第四条。
**要换掉的是「哪些键往下传」由两个命名空间的名字偶然相同来决定这件事。** 绑定这一侧的名字由
下游取,`chat()` 那一侧的名字由网关取,白名单让两边撞名的那些自动接通。这个机制在两个方向都
会坏:下游随手起一个叫 `tenant_id` 的坐标就被静默传下去;上游加一个参数,本库就得改一次
常量,而漏改的表现是「这个参数传不进去」——issue #6 就是这么来的。**issue 的第一条路(把白
名单从两个键补到五个)只改了那份常量的取值,没有改这个机制**,等于把同一个陷阱重新上好膛,
下一个新参数出现时会再来一次。
前缀让转发变成下游显式声明的:不写前缀就不会往下传,写了就说明是有意的。它的直接后果是本库
不必列举**网关能接受哪些坐标维度**,代码里也不必出现 `cache_namespace` 这个名字。本库手上
仍然有一份网关参数名的清单,但那是另一份东西——决策四第一条那份「不许从绑定走的结构性参数」,
四个名字,在依赖下界那一版上就已经存在,不随上游新增维度而增长。会随上游长的是坐标维度那
份名单,而那份追不动,被拿掉的正是它。由此得到三件事:
**依赖下界不用抬,靠的正是「不列举坐标维度」这一件事。** `tenant_id` 只在 1.3.0 之后存在,
本库若把它写进白名单常量,声明的下界就得从 `>=1.1` 抬到 `>=1.3`,于是每个下游都得跟着升
网关——包括那些根本不需要这个维度的。用前缀的话本库一个坐标维度的名字都没提,下界照旧
`>=1.1,<2`:网关支持的那个下游传得进去,装着旧网关的那个会拿到 `TypeError`,而错误信息里
带着参数名。上游此后再加维度也是同一个待遇,本库不发版。这条免疫只覆盖取值是字符串的新
维度:绑定的类型是 `Mapping[str, str]`,装不下别的,网关哪天加一个取 `int``bool` 或者
嵌套映射的参数,前缀这条路传不了它。这个限制可以接受——调用方坐标这一类维度天然是字符串
标识,租户、会话、命名空间、盐写出来都是名字;真出现一个非字符串的新维度,那是一次新的设计
(放宽绑定的取值类型,或者另开一条通道),不是这个机制上的一个漏洞。
**转发的键照旧全程进参数快照。** 键名不变,`gateway.cache_namespace` 在快照里是
`request.binding.gateway.cache_namespace`。所以「这次运行用的是哪个命名空间」事后查得到,
而换一个命名空间续跑会撞 `ParameterDriftError`——那正是最该炸的一次,因为续跑读到的可能是
另一个租户的缓存。
**升级不改变现存运行的行为。** 前缀今天不被任何东西解释,带前缀的键在今天只是一个普通坐标,
所以装上新版之后往网关传的东西不变。补白名单则相反:一个绑定里本来就有 `tenant_id` 的下游,
升级当天行为就变了,而快照里那一项一个字没改,续跑守卫也不会响——一次静默的行为变更,正是
本库最怕的形态。
**残留风险照实认下:今天真有一个叫 `gateway.<某某>` 的坐标的下游,升级后它会被往下传。**
这个风险以「装上就炸」的形态出现而不是静默扩散——网关不认得那个参数名会当场抛 `TypeError`
除非那个名字恰好也是网关的参数名,而那种巧合要同时撞中两层。
**前缀取 `gateway.` 而不是别的写法**,因为它说的正是「这个键是给网关的」,而读到它的人手上
拿的就是网关适配器。更不容易撞的写法(加下划线、加更长的限定串)换来的是每次都要多打几个字
和一个记不住的拼写,而撞名的代价上一段已经认下了。
**代价:`gateway.` 从此是绑定里的保留前缀。** 一个真的想用这个前缀当自己坐标的下游没法这么
取名了。接受,因为绑定的键名本来就由下游自己定,改一个名字的成本是一行。
## 决策二:为什么不是「白名单做成构造参数」
issue 的第二条路是让调用方决定转发哪些键。它没有解决决策一说的那件事——转发仍然按名字撞,
只是撞的范围可配。而且它多出一个后果:读一份参数快照不再能回答「网关那次收到了什么」,因为
答案同时取决于绑定和构造时那份配置,而后者不在快照里。把它也塞进 `parameters()` 能补上,但
那是为一个不必要的旋钮再加一层机制。
## 决策三:为什么不是「做成 `GatewayModelClient` 的构造参数」
issue 的第三条路(提出者自己最倾向的那条)是把这几个值直接做成适配器的构造参数。它和装配的
两半相冲,而且冲的地方正是这次要传的那个值。
`GatewayModelClient` 挂在 `AgentDefinition` 上,也就是跨运行不变、可以并发复用的那一半。租户
标识与租户命名空间恰恰**每次运行都可能不同**——一个多租户服务里,一次运行属于一个租户。把它
放到构造参数上,等于要求每个租户各装配一份定义,而定义那一半存在的理由就是它不随运行变。
本库已经有一条专门的每次运行通道,就是绑定。租户坐标是坐标,走坐标那条路。
(提出者自己也写了「跨运行基本不变,`tenant_id` 除外」。那个例外不是边角,它是这次需求的主体。)
## 决策四:四条防御
前缀让下游能把任意参数名传给 `chat()`,所以适配器要挡住几类明显不该这么传的东西。挡不住的
那些原样交给网关,由它抛 `TypeError`——错误信息里有参数名,比本库自己编一条更有用。
### 一、会改变请求本身的参数名一律拒绝
`messages``stream``structured``overlay` 四个。
**判据是「这个参数有没有一个已经存在的权威」,不是「它重不重要」。** 模型身份那份快照
`parameters()` 报的 scope 与源列表,含配置里的恒定采样参数)是「这次运行的模型行为是什么」
的权威。`overlay` 改的是同一件事,从绑定走等于让同一件事有两处记录,而其中一处不是权威——
`0012` 决策二把采样参数算进模型身份,为的就是「temperature 从 0 改成 1 之后续跑,模型的行为
变了而轨迹上看不出来」,从绑定塞 `overlay` 会把那道守卫从旁边绕过去。`structured` 会让网关
改写请求并按结构校验、必要时重试,`stream``messages` 决定发出去什么,同理。
**缓存维度(`cache_namespace`、`cache_salt`)不落在这一类,因为没有第二个地方记它们。**
它们改变的是「这次调用会不会真的发出去、读到谁的那一份结果」,而这件事在本库这边只有绑定
一处记录,逐字段进快照、逐字段守。所以它们是坐标,不是结构。
这份拒绝清单也会过期,但它过期的后果比今天那份软一档:漏掉一个新的结构性参数,表现是「该拦
的没拦住」,而且要下游主动写出那个名字才会发生;今天那份漏掉一个新参数,表现是「该传的传不
了」,下游什么都不做就中招。
### 二、取值是空串或纯空白时拒绝,带前缀的键一律如此
网关那边取命名空间的写法是「本次调用给了就用本次的,没给就用默认的」,而空串在 Python 里是
假值——所以 `gateway.cache_namespace=""` 会静默落回默认命名空间,也就是悄悄关掉隔离。绑定
那一侧的校验只管键和值都得是字符串,管不到空串。
**纯空白(`" "`、`"\t"`)比空串更坏,所以一起拒。** 空白在网关那边是真值,会被原样当成一个
命名空间用——于是隔离看起来成立,实际是所有拿到这份坏配置的租户共用同一格,而这正是本文要
修的那个跨租户串读场景换了个入口。空串至少还会落回默认命名空间那条众所周知的路,空白连这条
路都不走。
**判据是这个取值带不带信息,不是这个命名空间格式对不对。** 空串与纯空白都不带,所以拒得掉;
`"acme:v1:tenant:"`(租户标识拼空了)带信息但内容是错的,本库拦不住,也不该拦——它不解释绑定
的取值,一旦开始判断「什么样的命名空间算合法」,就等于替下游定了编码规则。这条线划在这里是
有意的,它挡的是「一个看着配了、实际什么都没配的隔离」,不是「配错了的隔离」。
**这一条只管带前缀的键。** 不带前缀的键根本到不了网关,它们的取值是下游自己的坐标,空不空
由下游自己判——库对它们唯一的要求是「键和值都得是字符串」,那一条在别处已经有了。
### 三、前缀后面没有参数名时拒绝
键恰好是 `gateway.` 时,剥掉前缀之后是一个空的参数名。**这一条不补任何漏洞**:真交下去,
`chat()` 的签名是纯关键字、没有 `**kwargs`,网关照样会抛 `TypeError`。它换掉的只是错误信息
——`TypeError: chat() got an unexpected keyword argument ''` 说不出问题出在绑定的哪个键上,
而这是一个纯粹的手滑,报错该直接指着那个键说「前缀后面要跟一个参数名」。
交给网关是默认,本库只在自己能给出明显更有用的信息时才拦。
### 四、不带前缀的 `session_id` 与 `parent_call_id` 拒绝,并在错误信息里给出改法
这两个键今天会被转发,前缀落地之后不再转发。直接静默不传是又一次静默的行为变更——下游的
网关遥测会悄悄不再按会话分组,而没有任何东西会提示。报错则要求它把键名改成
`gateway.session_id`,一行的事。这份清单只有历史遗留的那两个,此后永不增长——只有这两个键
曾经被真的转发过,所以需要迁移提示的名字集合是封闭的,不会有第三个。下一个 major 可以整条
删掉。
**不设弃用期(先警告一版、下一版再报错)**,因为弃用期要求这一版继续按旧机制转发这两个键,
也就是把决策一要拆掉的那个撞名机制再留一个版本;而警告是可以被忽略的,日志里多一行
`DeprecationWarning` 在一个跑批量运行的进程里没人会看见。用一次响亮的失败换掉一段没人读的
警告,代价是撞上的人要改一行,收益是这一版之后再没有第二套转发机制活着。
### 这四条报错时会发生什么
绑定要到一次模型调用发生时才到适配器手上,所以这四条只能在 `call()` 里判,判不到构造那一刻。
`session`——本库里驱动一次运行的那个模块,和网关那个 `session_id` 只是撞名——捕获模型
调用接缝抛出的任何异常,写一条带失败说明的结果记录、记一步、以 `StopReason.LLM_ERROR` 收尾。
也就是说这四条报的 `ValueError` 不会穿出 `run()`,它表现为一次「第 0 步就以模型调用失败
结束」的运行,失败说明里带着 `ValueError` 这个类名和出问题的那个键名。
**判断发生在把请求交给网关之前**,所以出错的那次调用一次都没发出去:转发出来的那份关键字
参数是 `chat()` 的实参,Python 求值完全部实参才进函数体。
**这个形态可以接受,因为绑定一次运行内不变**:错了就是第一次调用就错,不存在跑到一半才炸,
也没有任何预算被花掉。代价是这次运行在下游的统计里落在「模型调用失败」那一格而不是「配置
写错了」,要读失败说明才分得开——这一点和 `0016` 决策二说的那件事同族。那一条定的是:动作
执行接缝抛异常时库不捕获,也不替它编一个结算结果,因为异常抛出的那一刻副作用发生没发生是
未知的,库编一个出来就是把「不知道」改写成「知道,而且是这个值」。区别是那里库有得选(可以
不接管,让异常穿出去),这里没得选——模型调用接缝的异常怎么处置早就定死了,而适配器没有
更早的位置可以判。
## 决策五:`meta` 这一维不做
`meta` 的类型是 `Mapping[str, Any]`,而绑定是 `Mapping[str, str]`,装不下。要装下得二选一:
给绑定加一层嵌套编码(`gateway.meta.<key>` 这类),或者放宽绑定的取值类型。后者动的是公共
类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`0006` 里那句「不透明对象是公共
签名上一个永久的洞」说的就是这件事。前者是为一条需求引入第二套解析规则。
提出者自己把这一维标成最低优先级,说明的用途是带一个自己的 trace 标识。那个用
`gateway.session_id` 就能带:网关那边 `session_id` 本来就是一个用来分组的字符串,而本库不往
里面填任何东西——按 `0012` 那句「项目想让网关按运行分组,把它要的那个键放进绑定里」,这个
槽位一直是留给下游的。
不做 `meta` 是一次决定,不是漏掉的一维。提出者明确要求「如果你们认为这不该由库来做,请在
issue 里说一声」,所以 issue #6 的回复里要写上这一条。
## 公共契约套件不动
转发是这个适配器的行为,不是 `ModelClient` 这个接缝对所有实现的承诺——另一个下游写的实现接的
可能根本不是这个网关,对它断言 `gateway.` 前缀没有意义。
真正的缺口在别处:一个下游被逼着自己写网关适配器时,它重写的正是套件覆盖不到的那一段翻译
逻辑,而两份适配器此后会各自漂移。这一版之后那个前提没了——需要租户隔离的下游不必再自己写
实现,留在自带适配器上就能把命名空间传下去,而自带适配器的转发行为由 `tests/integration/`
里的用例钉着。**缺口是靠「消除下游离开这条路的理由」补上的,不是靠给套件加一条断言。**
**issue #6 末尾那个附带请求已经满足了。** 提出者问的是「能不能让准入契约套件也能套在自己写
`ModelClient` 实现上」——`polyloop.testing.ModelClientContract` 就是它,1.0.2 起随包发布,
继承它、覆盖两个 fixture 即可。
## 版本:1.0.3
`session_id` 从「静默转发」变成「报错」是一次破坏性的行为变更,按语义化版本的字面它不该
落在补丁号上。号是项目负责人拍的板,没有附理由;下面三条是这个选择为什么可以承受:
- 旧行为本身是缺陷。按名字撞的转发从来没有被任何人显式选择过,它是 `0012` 那个核错的前提留下
来的;
- 没有已发布的消费者依赖它。两个下游都在重建中,本库 1.0.2 发布于 2026-08-27,两天前;
- 失败是响亮的。撞上的人拿到一条带迁移写法的 `ValueError`,不是一次静默的行为改变。
**次版本号按同样这三条一样安全**,而且不和语义化版本的字面打架,所以 1.1.0 是一个真的选项。
取舍在别的地方:这次改动没有给库添任何新能力,它修的是一条本来就写错了的转发规则,用次版本
号会把一次修缺陷宣传成一次加特性;而读到次版本号的人更容易判断「这一版我可以先不升」,可
这一版恰恰是每个多租户消费者都该升的。落在补丁号是拍板的结果,上面那三条与这段取舍都是事后补的论证。
**残留风险照实认下**:万一有本次不知道的消费者正在依赖裸键转发,它会在一次补丁升级上撞到
`ValueError`。用报错而不是静默不传,就是为了让这个风险以「装上就炸」而不是「跑了三个月才
发现遥测一直没分组」的形态出现。
## 回写清单
**不回写下面这几处,本文就是死的**——写适配器的人读的是 docstring,而它现在还写着「网关只有
两个槽位」。
- `adapters/__init__.py` 的模块常量:白名单换成前缀常量、结构性参数拒绝清单、历史裸键清单;
- `_forwarded_binding` 的实现与 docstring:那句「网关只有两个槽位放得下这类东西」是本文推翻
的那条,改成决策一的说法,并指向本文;
- `GatewayModelClient` 的类 docstring:加一段说明保留前缀怎么用,含一个例子;
- `tests/integration/test_gateway_model_client.py` 里钉住旧行为那条用例:改写成前缀语义,另加
四条防御各一条;
- `CHANGELOG.md`:单开一节写迁移写法,因为它是破坏性变更落在补丁号上;
- `migrations/govdoc-saas.md`:那边要写的是租户命名空间怎么传;
- `tools/soak/run_soak.py` 里那份空绑定上方的注释:它把「绑定为什么留空」归给了转发规则,
而真正的理由是全部键值都进参数快照、每批不同的值会报假漂移,和转发哪些键无关。
## 留给后续的
**本库仍然不往网关的任何槽位里自动填东西。** 运行标识 `run_id`(续跑就是拿着它接着往下跑
的那个)不会被自动填进 `session_id`:那个槽位是下游的,本库填进去就会和下游自己的会话概念
撞,而撞了之后先写的那个赢。要按运行分组的下游自己写 `gateway.session_id`
**`cache_salt` 顺带通了,没有为它写一行代码。** 需要同一批消息跑多轮、每轮都真的重新调用的
下游写 `gateway.cache_salt` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单
方案得为它多列一个名字,还得先判断该不该列。