---
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/
```
