From d2be742383edef57cb0405b72b11a83c8dd643c6 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sat, 29 Aug 2026 11:52:37 -0400 Subject: [PATCH] =?UTF-8?q?docs(design):=20=E8=90=BD=E5=AE=9A=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E8=BD=AC=E5=8F=91=E6=94=B9=E7=94=A8=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E7=9A=84=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回答 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) --- .../design/0017-gateway-forwarding.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 research-wiki/design/0017-gateway-forwarding.md diff --git a/research-wiki/design/0017-gateway-forwarding.md b/research-wiki/design/0017-gateway-forwarding.md new file mode 100644 index 0000000..395ba6b --- /dev/null +++ b/research-wiki/design/0017-gateway-forwarding.md @@ -0,0 +1,345 @@ +# 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.` 这类),或者放宽绑定的取值类型。后者动的是公共 +类型,而绑定被定成字符串映射是为了让它能逐字段进参数快照——`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` 就行。这是决策一那种「不列举坐标维度」的做法白拿的收益:白名单 +方案得为它多列一个名字,还得先判断该不该列。