@@ -0,0 +1,297 @@
---
type: plan
node_id: plan:2026-08-26-issue18-pg-test-isolation
title: "issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标"
date: 2026-08-26
---
# issue #18 实现计划
> 类型:plan|日期:2026-08-26|分支 `fix/issue-18-pg-test-isolation`
> 实现设计 `designs/2026-08-26-issue18-pg-test-isolation-design.md`(已过人类门)。设计的节号在下文直接引用;本计划只负责"动哪些文件、按什么顺序、怎么拿到证据"。
> **本计划不涉及参考实现迁移,保真校验不适用。**
> [!CAUTION]
> **执行期唯一的不可逆风险,写在最前面。** 设计 §5.3 的"最坏情况"用例故意让脚本以裸 `search_path` 跑到共享表上。它**只有在沙箱角色就位之后才可以跑**——若在角色化之前用 `.env` 的 `app`(实测 superuser)跑它,`--older-than-days 7 --apply` 会真的删掉共享表里的过期行(实测那 11 行 2026-07-22 的数据全部早于任何截止线)。
> 这条风险决定了下面的任务顺序:**沙箱工厂(Task 1)→ retention 全面角色化(Task 2)→ 才写这条用例**。它没有常规意义上的"先红"路径,见 Task 2 的说明。
## 目标
让 `tests/integration` 不再依赖也不再污染共享表 `llm_calls` ,并把"清理脚本删错表"从事后可观测改成物理上做不到,随后发布 1.3.2。
## 方案概述
先建 `tests/integration/conftest.py` 的一次性沙箱工厂(独立 schema + 可选独占登录角色),把 retention 测试全面切到对真表无任何权限的角色上并删除行数快照;再给 `telemetry_retention.py` 加 `--table SCHEMA.llm_calls` (目标由参数精确解析、绕开 `search_path` ,表名段锁死);随后把 `test_postgres_telemetry.py` 的 7 条用例迁出真表、拆分 `_RUN_PREFIX` 的两个职责;最后加一道 lint 门防字面量回归,发布 1.3.2。
## 涉及技术
Python 3.12 / pytest + pytest-asyncio(auto) / asyncpg / PostgreSQL 16 权限与 `search_path` 语义 / argparse。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `tests/integration/conftest.py` | **新建 ** | `PgSandbox` 与 `pg_sandbox` 工厂;admin DSN 私有化 |
| `tests/integration/test_pg_sandbox.py` | **新建 ** | 工厂自身的行为测试(含 setup 中途失败不留残留) |
| `tests/integration/test_retention_tool_pg.py` | 修改 | 全部用例角色化;删行数快照;补 `--table` 与最坏情况用例 |
| `tools/telemetry_retention.py` | 修改 | 新增 `--table` ;PG 分支目标解析改为"显式限定名优先" |
| `tests/unit/test_retention_tool.py` | 修改 | `--table` 的参数分类用例(不连库) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 7 条用例迁出真表;`_RUN_PREFIX` 双职责拆分;其余 fixture 收敛到工厂 |
| `Makefile` | 修改 | `lint` / `check` 各加一道字面量门 |
| `README.md` / `CHANGELOG.md` / `pyproject.toml` / `src/polygateway/__init__.py` | 修改 | `--table` 用法与 1.3.2 定版 |
---
## 跨任务共享接口(Task 1 产出,Task 2/4/5 消费)
`tests/integration/conftest.py` 对外只有一个 fixture 与一个返回类型:
``` python
@dataclass ( frozen = True )
class PgSandbox :
""" 一次性 PG 沙箱: 独立 schema + 可选独占登录角色。 """
schema : str
role : str | None
dsn : str # 已挂 options=-csearch_path=<schema>
bare_dsn : str | None # 同角色但不挂 search_path;role is None 时为 None
```
``` python
async def pg_sandbox (
* ,
ddl : str | None = None ,
extra : Sequence [ str ] = ( ) ,
role : Literal [ " none " , " owner " , " grantee " ] = " none " ,
grants : Sequence [ str ] = ( " SELECT " , " INSERT " ) ,
) - > PgSandbox : . . .
```
### 三种 `role` 的语义
覆盖现有全部六个 fixture 的需求,**不得再加第四种**:
| `role` | schema 属主 | `ddl` /`extra` 由谁执行 | 返回 DSN 的身份 | 对应今天的 fixture |
|---|---|---|---|---|
| `"none"` | admin | admin | admin | `fresh_schema` / `legacy_schema` / `pre_tenant_schema` / `partitioned_schema` |
| `"owner"` | 临时角色 | **临时角色自己 ** (故表属主 = 该角色) | 临时角色 | 无(本次新增,retention 全部用例用) |
| `"grantee"` | admin | **admin ** (故表属主 = admin,与最小权限现场一致) | 临时角色(只被 `GRANT USAGE ON SCHEMA` + 表级 `grants` , **绝不 GRANT CREATE**) | `least_privilege_dsn` / `least_privilege_pre_tenant_dsn` |
### `ddl` / `extra` 的执行契约
1. **调用方传的 DDL 一律不带 schema 限定 ** ( `CREATE TABLE llm_calls (...)` ,不是 `CREATE TABLE {schema}.llm_calls` )。工厂在执行前对该连接 `SET search_path = <schema>` ,由 search_path 定位。这条统一了两种今天并存的写法——`PG_DDL` 本就是裸表名,而 `_LEGACY_DDL` / `_PRE_TENANT_DDL` 今天带 `{schema}` 占位,**Task 5 要把这两个常量的 `{schema}.` 前缀去掉**。
2. `extra` 在**同一连接、同一 search_path** 下按给定顺序逐条执行,不包事务(分区子表这类 DDL 各自提交即可)。
3. `ddl is None` 时只建空 schema,不执行任何建表语句。
### 临时角色的 DSN 构造
- 密码:模块级常量(测试专用,非机密),沿用今天 `_PROBE_PASSWORD` 的做法。
- `bare_dsn` :把 admin DSN 里的 `//user:pass@` 段整体替换为 `//<role>:<密码>@` ( `re.sub(r"//[^@/]+@", ...)` , `count=1` ),**不追加任何 `options` 参数**——它的用途就是让 `search_path` 回落到 `"$user", public` 。
- `dsn` :在 `bare_dsn` 基础上追加 `options=-csearch_path%3D<schema>` ,分隔符按 DSN 里是否已有 `?` 选 `?` 或 `&` 。
- `role="none"` 时 `dsn` 用 admin 身份加同样的 options, `bare_dsn` 为 `None` ——admin 的裸 DSN 不对用例开放(设计 §7.1 约束 3)。
### 三条硬约束(设计 §5.1、§7.1,逐条都是验收点)
1. schema 名 `pgw_s_<12 位 hex>` 、角色名 `pgw_r_<12 位 hex>` ,**两者前缀有意不同**——同名会让 `"$user"` 遮蔽真表,最坏情况用例就测不到真现场。
2. 资源逐步登记:每建成一个对象就把它的清理动作入栈,`except BaseException` 时**逆序**执行并 re-raise; `yield` 之后的 teardown 走同一条清理路径。单个沙箱的清理顺序固定为 `DROP SCHEMA IF EXISTS <s> CASCADE` → `DROP OWNED BY <r>` → `DROP ROLE IF EXISTS <r>` ( `DROP OWNED BY` 必须在 `DROP ROLE` 之前,否则角色仍持有对象无法删除)。一次用例内建多个沙箱时,沙箱之间也按 LIFO 清理。
3. `role != "none"` 时先查 `rolcreaterole OR rolsuper` , **在建任何对象之前** `pytest.skip` ( `production_template` 的教训:`pytest.skip` 抛的是 `BaseException` ,若在清理块内触发会去 DROP 从未建过的对象,把 skip 盖掉)。
---
## Task 1:沙箱工厂
- [ ] **文件 ** : `tests/integration/conftest.py` (新建)、`tests/integration/test_pg_sandbox.py` (新建)
**行为 ** :实现上文《跨任务共享接口》全部内容。DSN 读取沿用今天两个文件里的做法(`dotenv_values(".env")` 合并 `os.environ` ,剥掉 `+driver` ,缺则 `skip` ,库名不以 `/polygateway` 结尾则 `pytest.fail` )——这段逻辑今天重复两份,本任务收敛为一份私有函数。
**测试要求(先红后绿的路径明确) ** :先写 `test_pg_sandbox.py` 再写 `conftest.py` ——此时 `pg_sandbox` fixture 不存在,pytest 报 `fixture 'pg_sandbox' not found` ,六条用例全红,这就是本任务的先失败证据。随后实现工厂使其转绿。
| 用例 | 断言 |
|---|---|
| `role="none"` 建表 | 表落在 `sandbox.schema` 下;`sandbox.bare_dsn is None` |
| `role="owner"` 建表 | 表属主 = `sandbox.role` ; `sandbox.role != sandbox.schema` 且两者前缀不同 |
| `role="owner"` 的 `bare_dsn` | `SHOW search_path` 为 `"$user", public` ;用它解析 `llm_calls` 得到的**不是**沙箱里那张表 |
| `role="grantee"` | 该角色 `CREATE TABLE` 被拒(`asyncpg.exceptions.InsufficientPrivilegeError` ),`INSERT` 正常 |
| **setup 中途失败 ** | 传一段必然报错的 `ddl` (如 `CREATE TABLE llm_calls (bad_type NOT_A_TYPE)` ),捕获异常后查 `pg_namespace` / `pg_roles` :本次 uuid 对应的 schema 与角色**都不存在** |
| teardown 后无残留 | 在用例内部记下 `sandbox.schema` / `sandbox.role` ,用一个**更外层**的 fixture(在 `pg_sandbox` 之后销毁)回查两者均已消失 |
**验证 ** :
```
conda run -n PolyGateway pytest tests/integration/test_pg_sandbox.py -v
```
预期全绿;随后手工查实例:`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'` 与 `pg_roles` 同款查询均为空。
---
## Task 2: retention 测试角色化,删除行数快照
- [ ] **文件 ** : `tests/integration/test_retention_tool_pg.py` (修改)
**必须在 Task 3 之前完成 ** ——见文首 CAUTION。
**行为 ** :
1. 删除 `_public_count` 、`before_public` 与那条行数断言;删除本地的 `_make_schema` / `_drop_schema` / `_search_path_dsn` / `dsn` fixture,全部改用 `pg_sandbox` 。
2. **凡启动脚本的用例一律 `role="owner"` ** (设计 §5.1,无一例外,含 dry-run 与分区让路两条)。
3. 现有三条用例的其余断言逐条保留:`将删除行数: 5` 、`'acme': 3` 、批次 1/3 存在而批次 4 不存在、`已删除 5 行` 、剩余 `fresh-1` /`fresh-2` 、分区表退出 3 且含 `DROP PARTITION` /`DETACH` 、缺 asyncpg 退出 2。
4. 新增设计 §5.3 的**最坏情况**用例:用 `sandbox.bare_dsn` 、不给 `--table` 、`--older-than-days 7 --apply` 。断言退出 **2 ** 、stderr 非空且含 `llm_calls` 、沙箱表一行不少。**不断言 PG 的英文错误原文**(`lc_messages` 不由测试掌握),**测试代码里不得出现 `public.llm_calls` 字面量**。
**测试证据(这条用例没有常规先红路径,如实记录) ** :让它变红的唯一方式是把角色换回 admin superuser——那会真删共享表的行,绝不执行。它的证伪由 `findings/2026-08-26-issue18-shared-pg-test-isolation.md` §7 的探针 6/7 提供:同款临时角色对真表的 `COUNT` 与 `DELETE` 均返回 `InsufficientPrivilegeError` 。**提交说明里必须写明这一点**,不得含糊成"已验证"。
其余改动的先红路径正常:删掉 `_public_count` 之前,先把三条既有用例切到沙箱并跑通(此时它们仍带旧断言),再删断言——若沙箱切换有问题,旧断言会先报出来。
**验证 ** :
```
conda run -n PolyGateway pytest tests/integration/test_retention_tool_pg.py -v
```
预期全绿;连跑三次结果一致。
---
## Task 3: `--table` 参数与精确解析
- [ ] **文件 ** : `tools/telemetry_retention.py` (修改)、`tests/unit/test_retention_tool.py` (修改)、`tests/integration/test_retention_tool_pg.py` (追加用例)
**顺序 ** :**先写测试再改脚本**——四条集成用例与五条单测在脚本未改时全部先红(`--table` 未定义,argparse 直接以退出码 1 拒绝,而用例期望的是别的码/别的 stdout),实现后转绿。这就是本任务的先失败证据;Task 2 已先行完成,故这些用例从第一次运行起就跑在沙箱角色之下。
**脚本行为 ** (设计 §4):
| 项 | 要求 |
|---|---|
| 参数 | `--table SCHEMA.NAME` ,仅 `--backend postgres` 接受 |
| 校验(全部退出 **1 ** ) | sqlite 给了它;不是恰好两段;任一段为空;任一段含 `.` 或 `"` ; **表名段不等于 `llm_calls` ** |
| 解析 | 给了 `--table` 时用 `to_regclass($1)` 传 `"<schema>"."llm_calls"` ( `_quote` 包裹),绕开 `search_path` ;未给时维持今天的裸 `TABLE` 解析 |
| 解析不到 | 退出 **2 ** ,消息点名显式指定的表,并附一句"PG 中未加引号建的标识符在 catalog 里是小写" |
| 无权限 | 后续 `COUNT` 抛 `PostgresError` ,走既有 except → 退出 **2 ** (不新增分支) |
| 分区表 | 仍退出 **3 ** ,逻辑不动 |
| 提示行 | `--apply` 且**未**给 `--table` 时,在"目标表: x.y"之后打印一行,指出目标由 `search_path` 推断、可用 `--table` 钉死;dry-run 不打 |
`--help` 的 epilog 补两句:本脚本只清理 `llm_calls` ;含点或引号的复杂标识符不支持,此时退回不给 `--table` 的路径。
**单测 ** ( `tests/unit/test_retention_tool.py` ,不连库):`TestUsageErrors` 加五条,对应上表五种退出 1 的情形,逐条断言 stderr 含 `--table` ; `TestHelp` 加一条断言 epilog 点明表名固定为 `llm_calls` 。
**集成用例 ** ( `test_retention_tool_pg.py` ,全部 `role="owner"` ):
| 用例 | 构造 | 预期 |
|---|---|---|
| 显式指定成功 | `--table <sandbox.schema>.llm_calls` + `--apply` | 退出 0,删除结果与不给 `--table` 时逐条一致 |
| 指向不存在的 schema | `--table pgw_s_nosuchxxxxxxxx.llm_calls` | 退出 **2 ** ,stderr 点名该表;沙箱表一行不少 |
| 指向无权的表 | 建两个 `role="owner"` 沙箱,用 A 的 DSN 指 B 的表 | 退出 **2 ** ; A、B 两张表都不变 |
| 指向分区表 | 分区沙箱 + `--table` | 仍退出 **3 ** ,含 `DROP PARTITION` / `DETACH` 字样 |
| 提示行(设计验收 #2 ) | 沙箱 DSN + `--apply` , **不给** `--table` | stdout 含推断提示。设计原写"单测断言 stdout",但该行只在 PG 分支打印、不连库触发不到,故落在集成层;设计 §11 判据 2 已同步更正 |
**验证 ** :
```
conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v
```
---
## Task 4: 7 条用例迁出真表,`_RUN_PREFIX` 拆职责
- [ ] **文件 ** : `tests/integration/test_postgres_telemetry.py` (修改)
**行为 ** :
1. 七条用例改用 `pg_sandbox(role="none")` : `TestObservabilityColumns::test_values_round_trip` 、`TestSchema` 三条、`TestDegradation::test_row_failure_does_not_poison_later_rows` 与 `test_aclose_idempotent` 、`TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` 。
2. `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询补 `table_schema = $1` (设计 §6.2;仓库注释已记载该隐患)。
3. `TestPoolFootprint` **保留唯一 `application_name` ** ,就地生成 uuid(设计 §6.1)——这是实例级资源,schema 隔离对它无效。
4. 删除 `_RUN_PREFIX` 的行隔离用途:`_cid()` 的 63 处调用机械替换为字面量(`_cid("c1")` → `"c1"` );5 处 `LIKE` 逐条处置——`dsn` fixture teardown 的 `DELETE` 整条删除,`test_concurrent_writes_all_land` 的计数改 `COUNT(*)` ,其余三处(legacy / least_privilege / manual-lp)改为不带前缀的精确条件。
5. 删除已无引用的本地 `dsn` fixture 与其 teardown。
**测试证据 ** :判据 6c 有明确先红路径——先在库里手工留一个残留同名表(`CREATE SCHEMA pgw_s_leftover; CREATE TABLE pgw_s_leftover.llm_calls (call_id TEXT)` ),此时 `test_schema_has_frozen_columns_in_order` 因少了 `table_schema` 过滤而红;补上过滤后转绿;用完删掉该残留 schema。其余六条属迁移,证据形式是迁移前后断言逐条对照(设计 §11 判据 6),差异只允许出现在"表在哪"与"查询是否带 schema 过滤"两处——**这是回归门不是先红门,提交说明里如实这么写**。
**验证 ** :
```
conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v
```
判据 6b 另做:两个 shell 同时跑 `TestPoolFootprint` 那一条,两边都绿。
---
## Task 5:其余 fixture 收敛到工厂
- [ ] **文件 ** : `tests/integration/test_postgres_telemetry.py` (修改)
**行为 ** :
1. `legacy_schema` 、`pre_tenant_schema` 、`fresh_schema` 、`partitioned_schema` 改为 `role="none"` ; `least_privilege_dsn` 、`least_privilege_pre_tenant_dsn` 改为 `role="grantee"` 。
2. 按接口契约,`_LEGACY_DDL` 与 `_PRE_TENANT_DDL` 两个常量去掉 `{schema}.` 前缀与 `.format(schema=...)` 调用,改为裸表名由工厂的 search_path 定位。
3. `production_template` **不收敛 ** :它要建三个角色、跑 README 解析出的整套模板 SQL、按月建分区,权限语义与失败期清理都是它自己的(设计 §7.1 末段与 Codex 意见 3)。工厂强行接管会把这些语义压扁。本任务只把它内部的 `_cid()` 调用一并处理掉。
**测试证据 ** :这些 fixture 的既有用例断言**一行不改**——它们是这次收敛的验收器,改了就失去验收意义。这是回归门。
**验证 ** :同 Task 4 的命令,预期全绿;在无 CREATEROLE 的账号下 `least_privilege` 系列仍能正确 skip。
---
## Task 6: lint 门与字面量清理
- [ ] **文件 ** : `Makefile` (修改)、`tests/integration/*.py` (注释措辞)
**行为 ** : `lint` 与 `check` 各加一步——`tests/` 下命中字面量 `public.llm_calls` 即 `exit 1` 并打印命中行。注释与 docstring **同样不豁免 ** ,现有"共享的 public.llm_calls"改写为"共享表 `llm_calls` "。
Makefile 里这道门的注释必须写明它的定位(设计 §7.2):**烟雾报警器,不是隔离证明**——它拦不住 `f"{schema}.{table}"` 拼接与参数化查询,真正的隔离来自工厂不交出 admin DSN、脚本以无权角色运行。
**测试证据 ** :故意加一行含该字面量的注释 → `make lint` 失败并打印该行;移除后 → 通过。
**验证 ** :
```
make lint && make check
```
---
## Task 7:独立验证(合并前硬门)
- [ ] 派**全新上下文**的 verifier subagent( `verification-before-completion` ),交给它设计 §11 的判据表逐条核对,重点:
- 判据 3(最坏情况删不掉任何行)是否真由权限拒绝达成,而非碰巧——它没有先红证据,须由 verifier 独立复核 findings §7 的探针与用例断言是否真的对应同一条防线
- 判据 4:整套 `tests/integration` 连跑三次,**其间由 verifier 手工改动真表行数**(插入若干行再删掉),全程应无任何用例受影响
- 判据 6:7 条用例迁移前后断言逐条对照
- `--table` 的五种退出 1 与三种退出 2/3 是否都有用例覆盖
- `tests/` 与实例上是否留下任何 `pgw_%` 残留
---
## Task 8:文档与版本号
- [ ] **文件 ** : `README.md` 、`CHANGELOG.md` 、`pyproject.toml` 、`src/polygateway/__init__.py`
- README: `--table` 用法落在两处——"存量兜底"表格行与 SQLite 侧段落之后的脚本说明段;写明表名固定为 `llm_calls` 。安装约束是 `>=1.3.0,<2` 范围式,**本版无需改**(已核)。
- CHANGELOG:按设计 §10 如实写明 `tools/` 与 `tests/` 都不在 pip 包内,**1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同**,本版内容是运维脚本的契约扩展与测试确定性,不得包装成库能力更新。
- 版本号两处一致改 `1.3.2` 。
- `make wiki-check WIKI=<路径>` 跑过(公共行为变更须同步用户文档站,`docs-convention.md` §2)。
---
## Task 9:发布 1.3.2
- [ ] 按 CLAUDE.md §4.4.1 九步执行,一步不跳:合并 main(`--no-ff` )→ 在 main 上重跑 `make lint` 与全套件 → **显式跑 `pytest -m slow` ** → 打 tag 并 push → `rm -rf dist && python -m build && twine check` → 上传 registry( token 走 `TWINE_PASSWORD` ,不进命令行)→ `pip download` 验证并解包确认 → 建 Release + 挂仓库 → 以下游视角打开包页面与 Releases 页核对。
- [ ] 关闭 issue #18 ,正文指向本计划与设计。
---
## 审查留痕(Codex, 2026-08-26)
报 5 项,**全部采纳**:
| # | 意见 | 处置 |
|---|---|---|
| 1 | `ddl` /`extra` 的执行身份、search_path、顺序、schema 占位、失败清理顺序都没写成契约 | 新增《`ddl` /`extra` 的执行契约》一节;并据此在 Task 5 追加"去掉两个 DDL 常量的 `{schema}` 占位"这一步 |
| 2 | 临时角色的密码来源与 DSN 构造规则缺失 | 新增《临时角色的 DSN 构造》一节 |
| 3 | **Task 1 先实现 `--table`、Task 3 才写集成用例,先红路径不可能成立 ** | 采纳,任务重排:沙箱工厂 → retention 角色化 → `--table` (测试先写)。重排同时让 `--table` 的集成用例从第一次运行起就在沙箱角色之下,与文首 CAUTION 一致 |
| 4 | 工厂测试缺"先写失败测试"的明确步骤 | Task 1 写明:先写 `test_pg_sandbox.py` ,此时 `fixture 'pg_sandbox' not found` 全红 |
| 5 | 设计验收 #2 说"单测断言 stdout",计划却放在集成层 | 核实后确认是**设计写错了**——该提示行只在 PG 分支打印,不连库的单测触发不到。已就地更正设计 §11 判据 2,并在 Task 3 注明 |
另外据 Codex 对 Task 4/5 的观察,两处证据形式(回归门而非先红门)已在任务里如实标注,不含糊成"已验证"。
## Wiki 注册
``` bash
.claude/tools/research_wiki.py add_entity research-wiki/ --type plan \
--id 2026-08-26-issue18-pg-test-isolation --title "issue #18 实现计划"
.claude/tools/research_wiki.py add_edge research-wiki/ \
--from "plan:2026-08-26-issue18-pg-test-isolation" \
--to "design:2026-08-26-issue18-pg-test-isolation" --type implements
.claude/tools/research_wiki.py rebuild_index research-wiki/
```