@@ -0,0 +1,131 @@
# GatewaySettings 跨字段不变量守卫的生效范围
- **日期**: 2026-07-29;**状态**: 待人类审批(改变库对下游的构造承诺,强制人类门)
- **范围拍板**(用户 2026-07-29): 功能对齐社区 PR#1 ,但按本库规范重写;顺带销掉 PR#1 遗留的两个缺陷
- **上游依据**: ARCHITECTURE §7.3 契约补强 G6(装配期守卫,"违反直接报错拒绝装配")、§9 配置聚合、CLAUDE.md §4.5(装配只有两条路)、`types.py` 同族 frozen dataclass 的既有校验笔迹
## 1. 缺陷取证(全部本地实测,worktree @ f76a89b 与 main 对照)
`GatewaySettings` 有三条**跨字段**不变量——单个字段合法、组合起来才非法,因此 `types.py` 各子配置的 `__post_init__` 管不到,只能在聚合层管:
| 不变量 | 现居位置 | 违反后的运行时后果 |
|---|---|---|
| 源 `timeout_s` ≤ `lease_ttl_s` | `_guard_lease` ,仅 `from_env` 调用 | 租约先于请求过期,名额被放给他人 → 实际并发超配额,击穿网关 |
| `backpressure.stall_window_s` ≥ 最大源 `ttft_timeout_s` | `_guard_stall` ,仅 `from_env` 调用 | 正常慢首包被误判卡死掐断 |
| `breaker.probe_ttl_s` ≥ 最慢源 `timeout_s` + 5 | `_load_breaker` 内联,仅 `from_env` 路径 | 半开探针在途即被接管(M2 设计 §3 原文) |
三条守卫都只挂在 `from_env` 上,而 CLAUDE.md §4.5 规定装配有**两条**官方路。走 `from_settings()` 能装出违反上述任一条的配置且不报错——类可以合法地存在于它自己 docstring 声称不可能的状态。
实测(在 PR#1 分支上,即已修前两条之后):
| 构造方式 | 结果 |
|---|---|
| `replace(base, breaker=replace(base.breaker, probe_ttl_s=1.0))` (最慢 timeout 120s) | **未拦截 ** ,装配成功 |
| `replace(base, sources=())` | `ValueError: max() arg is an empty sequence` —— 内置异常泄漏,既不点字段也不说原因 |
第一条说明 PR#1 的搬迁不完整:它的全部论证同等适用于 `probe_ttl_s` ,却只搬了两条。第二条是 PR#1 **新引入**的失败模式——`max()` 此前只在 `_load_sources` 保证非空之后才执行,守卫上移到构造期后失去了这个前提。
另有一条隐性不变量此前从未表达:**`sources` 不得为空**。`from_env` 路径由 `_load_sources` 显式拦截,直接构造路径无人把关,零源的 client 装出来后选源必然失败。
## 2. 备选方案对比
| 方案 | 做法 | 权衡 |
|---|---|---|
| **A. `__post_init__` 集中校验(推荐) ** | 三条跨字段守卫 + 空源检查全部收进 `GatewaySettings.__post_init__` ,拆为 `_validate_sources/_validate_lease/_validate_stall/_validate_probe` 私有方法 | 与 `types.py` 同族五个 frozen dataclass 的既有笔迹完全一致;一处覆盖全部构造路径(六个工厂 + 直接构造 + `dataclasses.replace` );代价是收紧了构造承诺(见 §4) |
| B. 各工厂入口显式调用 `settings.validate()` | 三个 client × 两个工厂,六处各加一行 | 不改构造承诺,零 breaking;但六处要永久保持同步,新增第四个 client 时必漏——正是"每个调用方各维护一份副本"的毛病挪进库里。且 `dataclasses.replace` 仍能绕过。**否决** |
| C. 公共 `settings.validate()` ,由调用方自愿调 | 提供校验入口,不强制 | 把类不变量降级成"建议";违反 P5 防御性(外部输入校验后使用)与 ARCHITECTURE §7.3"违反直接报错拒绝装配"。**否决** |
方案 A 与 `SourceConfig.__post_init__` 同构。选它的核心理由不是"少写五行",是**不变量的归属**:这三条约束是 `GatewaySettings` 这个类的定义的一部分,不是 `from_env` 这个函数的输入检查。放在函数里,类就失去了自我描述能力。
### 2.1 子决策:守卫的代码形态
`config.py` 现有 `_guard_lease(settings)` / `_guard_stall(settings)` 两个模块级函数,把自身实例传回给模块级函数是绕路。`types.py` 的既有做法是私有方法(`SourceConfig` 拆三个 `_validate_*` )。**改为私有方法**,与同族一致;模块级 `_guard_*` 一并删除(无其他调用点)。
### 2.2 子决策:`probe_ttl_s` 的派生逻辑留在哪
`_load_breaker` 对该字段做了两件事:未配置时**派生**(`max(2*slowest, cooldown_s, probe_floor)` ,派生规则本身保证守卫恒成立)、显式配置时**校验**。派生需要读 env,必须留在 `_load_breaker` ;校验上移到 `__post_init__` 后,`_load_breaker` 内联的那份校验删除(避免同一约束两处维护)。派生分支上移后仍恒过,无行为变化。
### 2.3 子决策:错误消息里是否列 env 键名
**不列。 ** 三条理由:(1) `types.py` 全部校验消息只点字段名,是既有笔迹;(2) 守卫现在服务两类调用方,env 键对手工拼 settings 的那类是不可执行的建议;(3) 键名的单一事实源是 `.env.example` 与 wiki `参考-配置键` ,消息里复制一份即双处维护。消息格式沿用既有句式:`字段名(值)须 …;调大 X 或调小 Y` 。
> 与 PR#1 的差异:PR#1 选择"点字段名 + 括号附 env 键",单行超 100 字符且把 `{SCOPE}__{PROVIDER}__{N}__TIMEOUT_S` 模板塞进运行时消息。本方案只留字段名。
## 3. 行为审计(逐条标注)
不是从 `reference/` 迁移,是既有模块的行为收紧,故审计对象为现有 `from_env` 路径的全部可观测行为:
| 现有行为 | 处置 |
|---|---|
| `from_env` 装配非法 lease/stall 组合 → `ValueError` | **保留 ** (改由 `__post_init__` 抛,时机提前到 `cls(...)` 那一行,对调用方不可见) |
| `from_env` 配了过小 `PROBE_TTL_S` → `ValueError` | **保留 ** (同上,消息中不再含 env 键名 —— 有意变更,§2.3) |
| `from_env` 未配 `PROBE_TTL_S` → 派生值 | **保留 ** ,派生规则一字不改 |
| `from_env` 未配任何源 → `ValueError: scope X 未配置任何源` | **保留 ** ,`_load_sources` 的检查不动(它能给出键名模板,信息量高于构造期检查) |
| 直接构造/`replace` 出非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` (本设计的目的) |
| 直接构造空 `sources` → 静默成功 | **有意替换**为构造期 `ValueError` ,消息点明"至少一个源" |
| 三条守卫的异常类型 `ValueError` | **保留 ** 。装配期错误不入 `errors.py` 四分类(四分类描述的是一次调用的失败),与 `_load_sources` /`types.py` 既有装配错误一致 |
| `GatewaySettings` 字段名与类型 | **不动 ** 。迁移兼容约束(CLAUDE.md §4.3 例外条款)只增不删不改名,本次零字段变更 |
**有意放弃 ** :不提供 `strict=False` 之类的逃生开关。装出必然故障的配置没有正当用例。
## 4. 对下游的承诺变化(人类门要审的就是这条)
| 调用方式 | 影响 |
|---|---|
| `GatewayClient.from_env()` / `OcrClient.from_env()` / `EmbeddingClient.from_env()` | **零影响 ** ,该路径本就跑这些守卫 |
| `*.from_settings(settings)` ,settings 来自 `from_env` | **零影响 ** |
| 手工构造 `GatewaySettings(...)` 或 `dataclasses.replace(...)` ,组合合法 | **零影响 ** |
| 手工构造/`replace` ,组合非法 | **行为变更 ** :构造期抛 `ValueError` ,不再留到运行时表现为超配额/误判卡死/探针被接管 |
已知受影响的下游:CHSAnalyzer 重建中的 YAML → 直接构造 → `from_settings()` 路径(PR#1 提交者正是在此撞上的)。该路径若配置合法则不受影响,若非法则从"静默故障"变为"启动即报错"——方向是收益。
版本:**1.0.1**(patch,用户 2026-07-29 拍板)。设计初稿曾建议 minor(构造期新抛 `ValueError` 是可观测的收紧),用户判定受影响面仅限"手工拼出非法配置"这一本就故障的路径,按修复发 patch。CHANGELOG 必须把行为收紧单列小节,不能只混在"修复"里——patch 号不会给下游预警,changelog 是唯一的告知渠道。
发版时按 `docs-convention` §2 末行过发布清单;wiki `参考-配置键` 页现有表述("须 ≤ `PGW_LEASE_TTL_S` ""须 ≥ 最大源 TTFT")与新行为一致,**无需改动内容**。
## 5. 非功能维度
| 维度 | 回答 |
|---|---|
| 并发与取消 | **不适用但需写明 ** :`__post_init__` 是同步纯计算(只读自身字段做比较),无 I/O、无 await、无锁,不存在取消穿透点。不引入任何全局状态,纯 asyncio 中立铁律不受影响 |
| 降级方向 | 装配期校验属**准入侧**,按库铁律"报错而非放行"。无后端依赖,无降级分支 |
| 幂等与重复 | `__post_init__` 不修改任何字段(frozen 也不允许),重复构造同一配置得同一结果;校验本身无副作用 |
| 持久化与原子性 | 不适用,配置对象不落盘 |
| 性能 | 每次构造增加三次 `max()` 遍历 sources(典型 1-4 个源)。`GatewaySettings` 只在装配期构造,不在请求路径上,可忽略 |
## 6. 测试策略
`tests/unit/test_config.py` 新增一个测试类,覆盖矩阵为 **4 条不变量 × 2 条构造路径 ** :
| 用例 | 断言 |
|---|---|
| 三条守卫各自:`dataclasses.replace` 构造出违反组合 | 抛 `ValueError` ,消息含对应字段名 |
| 三条守卫各自:边界值恰好相等(`timeout_s == lease_ttl_s` 等) | **构造成功 ** ——守卫收紧的是错的那些,不是所有直接构造 |
| `sources=()` | 抛 `ValueError` ,消息点明"至少一个源",**且不是 `max() arg is an empty sequence` ** |
| `GatewayClient.from_settings(非法 settings)` | 抛 `ValueError` (真实动机路径,PR#1 只做了手工复现未落成测试) |
| `OcrSettings` / `EmbeddingSettings` 直接构造包着非法 gateway | 抛 `ValueError` (证明三条 client 线一并覆盖) |
| 既有 447 passed / 34 skipped | 全绿,零回归 |
TDD 顺序:先写测试跑出预期失败(预计 3 条守卫 + 空源 + from_settings 端到端 共失败 6 条以上),再实现,再全绿。测试不新增 mock,全部用既有 `_env()` helper 构造真实 settings 再派生。
## 7. 与 PR#1 的关系
功能对齐,不是推翻。PR#1 的问题诊断完全正确,本设计沿用其核心结论(守卫属于类不变量,应在构造期生效),差异集中在:
| 维度 | PR#1 | 本设计 |
|---|---|---|
| 覆盖的不变量 | 2 条 | 4 条(补 `probe_ttl_s` 、空 sources) |
| 代码形态 | 保留模块级 `_guard_*(settings)` | 改为 `_validate_*` 私有方法,同 `SourceConfig` |
| docstring | 引用 `CLAUDE.md §4.5` (下游读者看不到该文件)、带论证口吻 | 只引 ARCHITECTURE §7.3 与自身概念,解释"为什么"不复述辩论 |
| 错误消息 | 字段名 + 附 env 键模板 | 只点字段名(§2.3) |
| 测试 | 4 条,全走 `replace` ,其中 1 条同义反复 | 覆盖 4 不变量 × 2 路径 + 边界值 + 三条 client 线 |
| 导入位置 | 两处函数内 `import dataclasses` | 文件顶部 |
合并后应关闭 PR#1 并在其中说明:诊断被采纳,实现按库内规范重写并扩展了覆盖范围。
## 8. 待人类拍板
1. **主决策 ** :接受方案 A(构造期强制,承诺收紧)?
2. **范围 ** :`probe_ttl_s` 与空 sources 一并纳入(推荐),还是严格只对齐 PR#1 的两条?
3. **消息文案 ** :去掉 env 键名(推荐,§2.3),还是保留 PR#1 的附注形式?
4. **PR#1 处置 ** :重写合并后关闭并说明,还是先请提交者按 review 修改?