---
type: design
node_id: design:2026-08-26-issue18-pg-test-isolation
title: "issue #18: 隔离靠权限强制,目标靠显式声明"
date: 2026-08-26
---

# issue #18：隔离靠**权限强制**，目标靠**显式声明**

> 类型：design｜日期：2026-08-26｜状态：待 Codex 审 → 人类审
> 事实基础见 `findings/2026-08-26-issue18-shared-pg-test-isolation.md`（本文所有实测引用均出自该文）。
> 两处需人类拍板的取舍已于 2026-08-26 会话中确认：`--table` **纳入**；7 条写真表的用例**全迁**；`public.llm_calls` 里那 11 行历史孤儿行**不清理**。

## 1. issue #18 的诊断只对了一半

issue 判定"行数断言依赖共享实例的当下状态"，方向对；它推荐的首选处置（标 `slow`，交发布清单统一跑）**不解决问题**——标 `slow` 只是把假红挪出日常关卡，而这条断言还有另一半失效：

| 失效方向 | 表现 | 标 `slow` 之后 |
|---|---|---|
| 假红 | 外部进程写/删共享表 → 断言红，脚本无辜 | 挪到发布关卡，**照样红**，只是红得更少人看见 |
| **假阴** | 外部插入与脚本误删互相抵消 → 行数相等 → 静默放行 | **原样保留** |

这条断言守的是"脚本静默删了共享的真表"。假阴才是它真正的代价，而 `slow` 对假阴毫无作用。

## 2. 根因三层

| 层 | 事实 | 后果 |
|---|---|---|
| L1 | `_public_count` 是全套件唯一一处**全表口径**断言，而同文件的 `_RUN_PREFIX` 机制从设计上就假定"多个进程并行写同一张表" | 两套前提互斥，偶发红是必然而非意外 |
| L2 | 一个**安全属性**（脚本不越界）被编码成对**全局可变量**（真表行数）的观测 | 假红 + 假阴，结论既不可靠也不可否证 |
| L3 | 之所以只能这么写：`telemetry_retention.py` 的目标表由连接的 `search_path` 隐式决定（`to_regclass('llm_calls')`），**调用点无法声明"我要删哪张表"** | 测试没有别的手段表达"只许动这张表"，只好退回事后观测 |

L3 不是测试的问题，是脚本契约的问题——它同时是生产风险：`search_path` 默认首项是 `"$user"`，换个角色跑同一条命令，只要库里存在同名 schema 下的 `llm_calls`，删的就是另一张表。脚本现有的应对是把解析结果打印出来，但那行打印与 `DELETE` 在同一次运行里，中间没有人。

## 3. 设计主张

1. **安全属性由数据库权限强制，不由断言观测**——测试跑脚本用的角色对 `public.llm_calls` 无任何权限，越界不是"会被发现"，而是"做不到"。
2. **目标表由调用方声明**——`--table SCHEMA.NAME` 给出后，目标不再经 `search_path` 推断。
3. **测试与真实共享表完全脱钩**——`public.llm_calls` 从此零测试触碰，隔离手法收敛为"临时 schema"一种，并由 lint 门机械化守住。

## 4. 变更 A：`telemetry_retention.py` 新增 `--table SCHEMA.NAME`

### 4.1 语义：声明即目标，不是"声明后比对"

两种可能的实现要先分清：

| | 做法 | 结果 |
|---|---|---|
| 否决 | 仍按 `search_path` 解析，再与声明比对，不符则退出 | 目标**仍然**由环境决定，`--table` 只是一道确认；且要为"不符"发明第四个退出码语义 |
| **选定** | 给了 `--table` 就用 `to_regclass('"schema"."name"')` **精确解析**，绕开 `search_path` | 目标真正由参数决定；不存在则落入既有的"目标表不可用"语义 |

选定做法的实现落点只有一处——`_purge_postgres` 里 `to_regclass($1)` 的入参从裸 `TABLE` 换成引号限定名，分区探测、统计、分批 DELETE 全部不变（它们本就用解析结果拼 `qualified`）。

三条支撑它的 PG 语义已实测（PostgreSQL 16.14，见 finding §7）：`to_regclass('"schema"."llm_calls"')` 正常解析；**schema 不存在时返回 NULL 而不抛错**；引号限定名**区分大小写**（`"PGWPROBE_S_X"."llm_calls"` → NULL）。前两条决定了"找不到"能落进既有的退出码 2 而不需要新分支，第三条决定了 §4.2 的"逐字比较"是可实现的。

### 4.2 参数与校验

| 规则 | 行为 | 理由 |
|---|---|---|
| 仅 `--backend postgres` 接受 | sqlite 给了 `--table` → 退出 **1** | 与 `--batch-size` 同款；SQLite 库文件即目标，无 schema 概念，无歧义可消 |
| 必须是**两段**限定名 | `--table llm_calls` → 退出 **1**，提示写成 `schema.表名` | 单段等于没声明，隐式性原样保留 |
| **表名段必须逐字等于 `llm_calls`** | `--table audit.events` → 退出 **1**，消息点明本脚本只清理 `llm_calls` | 见 §4.4：不加这条，`--table` 会把本脚本从"遥测表清理器"扩成"任意同形表删除工具" |
| 两段均非空;**schema 段须为普通标识符**(`[A-Za-z_][A-Za-z0-9_$]*`) | 不合法 → 退出 **1** | 复杂标识符（含引号的表名）不支持，此时退回不给 `--table` 的路径；写进 `--help`。**本行原写作"均不含 `.` 与 `\"`"，实现阶段核出"段内含 `.`"是不可达分支**——按 `.` 切分后恰好两段是前置条件，`a.b.c` 走的是"不是恰好两段"那条消息，故删去该半句 |
| **逐字比较，不做大小写折叠** | 传 `_quote()` 包裹的限定名给 `to_regclass` | catalog 里存的是真实标识符；未加引号建的表在 catalog 中是小写。折叠会与"引号标识符区分大小写"的真实语义打架 |
| 解析不到 | 退出 **2**，消息点名"显式指定的表 X 不存在"，并附一句"PG 中未加引号建的标识符在 catalog 里是小写" | 与 `search_path` 找不到的消息**分开写**：诊断方向不同。**退出码维持 2 而非 1**：`Public.llm_calls` 格式合法，找不到是环境事实而非参数非法——把它归成 1 会让"schema 真的不存在"这类该告警的情形被调度器当成不必重试的参数错误。大小写这类高频手误由消息文本消化，不由退出码 |
| 无权限 | 后续 `COUNT` 抛 `PostgresError` → 既有 except → 退出 **2** | 无需新增分支 |

退出码不新增。`1` 留给"参数写错了，重试也没用"，`2` 留给"环境不对，值得告警"——这条分界是脚本已有的对调度器契约（见 `_Parser.error` 的注释），本变更沿用。

### 4.3 目标白名单：为什么表名段不可变

`--table` 若只校验"两段、非空、无点无引号"，一次手误 `--table audit.events` 就会让脚本对一张**恰好也有 `created_at` 与 `tenant_id` 列**的业务表执行同一套 COUNT + 分批 DELETE。脚本的名字、`--help`、退出码 3 的分区提示、README 的定位全都是围绕遥测表 `llm_calls` 写的，它从未声称自己是通用清理器；让参数悄悄扩大作用域，是在一个**默认 dry-run、拿 DELETE 权限跑**的脚本上开一个静默的口子。

故 `--table` 的可变部分只有 schema 一段。**为什么不干脆改叫 `--schema`**：cron 配置里的那一行必须自解释——运维读 crontab 时看到 `--table public.llm_calls` 就知道全部目标，看到 `--schema public` 还得回去查脚本常量才知道表名。多出的那条校验不是冗余，它本身就是"本脚本的作用域到此为止"的显式声明，且错误消息可以当场把边界告诉用户。

### 4.4 未声明时的提示

`--apply` 且未给 `--table` 时，在"目标表: x.y"之后补一行：

```
注意: 目标表由连接的 search_path 推断得到。要把目标钉死,请加 --table <schema>.<表名>。
```

只在 `--apply` 时打：dry-run 不可逆性为零，且它本就以"看清楚再决定"为用途，多一行提示是噪音。

## 5. 变更 B：测试角色化——把安全网换成权限边界

### 5.1 模型

**凡是启动 `telemetry_retention.py` 子进程的用例，一律用临时登录角色跑，无一例外**——包括正向的 apply/dry-run/分区让路用例。只给"最坏情况"那一条用低权限角色是自欺：正向用例才是带 `--apply` 真删数据的那些，它们若仍用 `.env` 的 superuser DSN 跑，一旦 `search_path` 或 `--table` 出问题，删的就是真表，而新设计里已经没有行数快照会发现它。

每个这样的用例临时建一个**登录角色** `tmp`，并 `CREATE SCHEMA s AUTHORIZATION tmp`，表由 `tmp` 自己建。于是：

- `tmp` 是那张表的**属主**——与脚本文档要求的"用维护角色跑"形态一致，测的不是一个失真的现场
- `tmp` 对 `public.llm_calls` 一无所有：实测 ACL 为 `{app=arwdDxt/app, chs3_test=ar/app}`，无 PUBLIC 授权

**必须换角色的原因**：`.env` 里的 `app` 实测 `rolsuper = true`，superuser 无视一切权限检查，用它跑则这条防线不存在。无 `CREATEROLE` 权限的环境 `skip`（项目既有惯例，见 `least_privilege_dsn`）。

防线已实测：临时角色裸连（`search_path = "$user", public`）对真表执行 `COUNT` 与 `DELETE`，两者均 `InsufficientPrivilegeError: permission denied for table llm_calls`。

**约束：角色名与 schema 名必须错开。** 实测 `CREATE SCHEMA X AUTHORIZATION X` 时，`"$user"` 会命中自有 schema 并**遮蔽 public**——今天 `least_privilege_dsn` 正是同名形态。同名虽多一层巧合式防护，却让 §5.3 的最坏情况用例根本走不到 public，等于测了个假现场。故 `pg_sandbox` 一律用 `pgw_s_<uuid>` / `pgw_r_<uuid>` 两套名字。

### 5.2 最坏情况从"事后观测"变成"确定性红灯"

| 情形 | 旧 | 新 |
|---|---|---|
| `search_path` 失效，脚本落到 `public` | 事后数行数，可能被并发抵消 | 数据库拒绝 → 退出 2 → 测试红，**且一行都删不掉** |
| 外部进程并发读写 `public` | 直接假红 | 与测试无关（不再读 `public`） |

`_public_count` / `before_public` / 那条 `assert` 整体删除。

### 5.3 新增一条"最坏情况"用例，替代被删掉的安全网

用属主角色的 DSN **不挂 search_path** 跑脚本（于是解析走 `"$user", public`，角色同名 schema 不存在 → 落到 `public.llm_calls`），不给 `--table`：

- 断言退出码 **2**、stderr 非空且点名 `llm_calls`、临时表内容一行未变
- **不断言 PG 的英文错误原文**（服务端 `lc_messages` 不由测试掌握），也**不出现 `public.llm_calls` 字面量**（见 §7 的 lint 门）
- 库里没有 `public.llm_calls` 的环境上，脚本报"找不到表"同样退出 2 —— 两条路都绿，用例不因环境而摇摆

这条用例把"最坏情况"钉成确定性的红/绿，且完全不观测共享状态。

## 6. 变更 C：7 条用例迁出 `public`

| 用例 | 迁移后验的东西 |
|---|---|
| `TestSchema::test_schema_has_frozen_columns_in_order` | **变强**：现在验的是本机那张被历史 `_BACKFILL` 补过列的老表，迁到 fresh schema 后验的是**库当前 DDL 建出来的表** |
| `TestObservabilityColumns::test_values_round_trip` | 不变（只要求表存在） |
| `TestSchema::test_call_id_idempotent` / `test_concurrent_writes_all_land` | 不变（与表在哪无关） |
| `TestDegradation::test_row_failure_does_not_poison_later_rows` / `test_aclose_idempotent` | 不变 |
| `TestPoolFootprint::test_pool_does_not_preconnect_and_stays_within_pool_max` | 不变（验的是连接数），但**必须保留唯一 `application_name`**，见下 |

### 6.1 `_RUN_PREFIX` 有两个职责，只能删掉其中一个

| 职责 | 落点 | 处置 |
|---|---|---|
| call_id **行隔离** | `_cid()` 的 63 处调用、5 处 `LIKE '<前缀>%'` 过滤、`dsn` fixture teardown 的 `DELETE` | 删除——schema 隔离已完全取代它 |
| **`application_name` 唯一** | `test_pool_does_not_preconnect_and_stays_within_pool_max` 用它标记本池连接，再查 `pg_stat_activity` 数连接数 | **保留**（就地生成 uuid）——连接是**实例级**共享资源，schema 隔离对它无效；改成固定名字会把并行进程的连接数进来，等于把偶发红从表层搬到连接层 |

删除行隔离用途时调用点做**机械替换**（`_cid("c1")` → `"c1"`），不改任何断言语义；5 处 `LIKE` 过滤逐条在计划里列出并单独验证。

### 6.2 顺带封掉一个仓库自己已记载的隐患

`test_schema_has_frozen_columns_in_order` 今天查的是 `information_schema.columns WHERE table_name='llm_calls'`，**不带 schema 过滤**——库里任何一个残留的临时 schema 里的同名表都会污染结果。这不是推测：`production_template` 的 `except BaseException` 分支注释里已经写明了这个坑（"会被残留物在下一次运行里以列数不符的形态误伤"），当时的处置是让另一处 fixture 清理得更干净。迁移时补上 `table_schema = $1`，把它从"靠别人不留残留"改成"自己只看自己"。

**用函数级而非 module 级 sandbox**：建/删一个 schema 是毫秒级，7 条用例的开销可忽略；module 级共享会把"用例之间互不影响"这条重新变成需要论证的事。

## 7. 变更 D：`conftest.py` 收敛 + lint 门

### 7.1 一个沙箱工厂取代七处样板

`tests/integration/conftest.py` 新增：

| fixture | 职责 |
|---|---|
| `pg_admin_dsn`（session） | 读 `.env`、缺失 `skip`、库名守卫（只许 `polygateway`）。**命名下划线语义上属内部**，用例不该直接用 |
| `pg_sandbox`（function，工厂） | `await pg_sandbox(ddl=..., extra=(), owner_role=False)` → 返回 frozen dataclass（`schema` / `dsn` / `role`）；teardown 按 LIFO 统一 `DROP SCHEMA CASCADE` + `DROP OWNED BY` + `DROP ROLE` |

三条硬约束（缺一条工厂就会自己变成污染源）：

1. **资源逐步登记，`except BaseException` 清理**：建角色成功、建 schema 失败时不会走到 `yield`，普通 teardown 不执行，角色就永久留在实例上（角色是**全局**对象，不随库消失）。`production_template` 已有同款先例，工厂必须继承它而不是简化掉。
2. **uuid 后缀取 12 位十六进制**：8 位在并行会话下碰撞概率虽低却非零，而碰撞的后果是 `CREATE ROLE` 失败或误清理别人的残留。加长的成本为零。
3. **admin DSN 不做成 fixture**：改为模块私有函数，只被工厂内部调用。做成 fixture 就等于把一个能 `DELETE FROM public.llm_calls` 的连接摆在所有用例面前，"用例不该直接用"只是纪律不是机制。

今天这套样板在两个文件里重复**七处**（`legacy_schema`、`pre_tenant_schema`、`fresh_schema`、`partitioned_schema`、`least_privilege_dsn`、`least_privilege_pre_tenant_dsn`、`production_template`，加 retention 侧两处）。收敛后清理逻辑只有一份——今天任何一处 teardown 写漏，残留都落在共享库里。

### 7.2 机械化执法

`make lint` / `make check` 各加一步：

```
tests/ 下不得出现字面量 public.llm_calls —— 命中即 exit 1
```

§5.3 的用例已按"不出现该字面量"设计，故门无需豁免名单——**注释与 docstring 同样不例外**，现有多处"共享的 public.llm_calls"措辞改写为"共享表 `llm_calls`"。豁免名单一旦开口，门就退化成建议。

**这道门是烟雾报警器，不是隔离证明。** 它拦不住 `f"{schema}.{table}"` 拼接、`to_regclass($1)` 参数化、或不带限定名的 `DELETE FROM llm_calls` 配上 admin 的默认 `search_path`。真正的隔离来自两处：工厂 API 不把 admin DSN 交出去（§7.1 约束 3），以及脚本以无权角色运行（§5.1）。文档里必须这样写，否则下一个人会拿这道门当"tests 零触碰 public"的证明。

## 8. 明确不做

| 不做 | 理由 |
|---|---|
| 标 `slow` | §1：对假阴无效；改完之后这条用例的成败不再取决于外部服务状态，它**应该**留在日常关卡里 |
| 建临时数据库（而非 schema） | PG 的 schema 对 DML/DDL 已是完备隔离；建库只换来"孤儿库更难清、需 CREATEDB、断连才能 DROP"三项成本 |
| 清理 `public.llm_calls` 里那 11 行孤儿行 | 人类决策：那是与迁移项目共用的表，本次不动 |
| 给 SQLite 分支加 `--table` | 库文件即目标，无歧义（§4.2） |
| 动 Redis 集成测试 | 实测已是每用例 uuid 命名空间/scope，无全表口径断言，不属同类 |
| 把 `--table` 做成必填 | 会打断下游既有 cron，属破坏性契约变更 |

## 9. 残余风险（本设计**不**覆盖，需明写而非默认解决）

| 风险 | 为什么不在本设计覆盖范围 | 缓解 |
|---|---|---|
| fixture / teardown 里用 admin 连接手滑写真表 | admin 连接必须存在（建 schema/角色本身就需要它），权限边界对它无效 | 工厂不把 admin DSN 交给用例；§7.2 的门能拦住字面量形态 |
| 进程被 `SIGKILL` 时 pytest finalizer 不执行，残留 schema/角色 | 任何进程内机制都做不到 | 命名固定前缀 `pgw_s_` / `pgw_r_`，残留可一条 SQL 查出（`SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pgw%'`）；**不做自动 TTL 清理**——并行会话下"清理别人的残留"会误删正在跑的 schema，比残留本身更危险 |
| 共享实例上其他项目往真表写/删 | 不归本库管 | 改完之后本仓库测试对它完全不敏感，这正是本设计的目的 |
| `production_template` 仍以管理身份执行不带限定名的 `DELETE` / `DROP TABLE` | 它有意不收敛进工厂（§7.1 末段），三角色与分区语义是它自己的 | 独立验证实测：它的连接 `search_path` **只有**自己那个 schema（`public` 不在路径里），故 `to_regclass('llm_calls')` 返回 `None`——search_path 一旦失手，报的是"关系不存在"而不是静默打到共享表 |
| `pg_catalog_probe` 持管理连接 | 工厂自测需要查 catalog 核对残留，这个能力删不掉 | 探针只接受 `SELECT` 开头的语句（有用例钉住）;它不交出 DSN，故越界能力止于只读查询 |

## 10. 版本号与发布

**1.3.2**（patch）。需在 CHANGELOG 里如实写明：`tools/` 与 `tests/` **都不在 pip 包内**（README 已声明脚本随仓库分发），故 1.3.2 的 wheel 与 1.3.1 在库代码上逐字节相同，本版的对外内容是**运维脚本的契约扩展**与测试确定性，不是库能力更新。不得包装成库更新。

发布按 CLAUDE.md §4.4.1 九步全走，其中与本变更直接相关的：README 需补 `--table` 用法与安装版本约束核对；`make wiki-check` 需在合并前跑过；合并后在 main 上补跑 `pytest -m slow`。

## 11. 验收标准

| # | 判据 | 验证方式 |
|---|---|---|
| 1a | `--table` 的**参数分类**：sqlite 互斥、非两段、空段、含点/引号、表名段非 `llm_calls` —— 各自退出 1 | 单测（`tests/unit/test_retention_tool.py`，无需 PG） |
| 1b | `--table` 的**真实解析行为**：显式指向 sandbox 表成功删除；指向不存在的 schema → 2；指向无权表 → 2；指向分区表 → 仍 3 | **集成用例（必须真连 PG）**——单测只能验参数分类与拼出的目标字符串，验不了 `to_regclass` 的真实语义 |
| 2 | 未给 `--table` 且 `--apply` 时打印推断提示 | **集成用例**断言 stdout —— 该提示行只在 PG 分支打印，不连库的单测触发不到它（本行原写作"单测断言 stdout"，计划阶段核出该判据不可执行，就地更正） |
| 3 | 最坏情况（search_path 落到 public）**删不掉任何行**且退出 2 | §5.3 新用例 |
| 4 | 整套 `tests/integration` 连跑三次全绿，其间 `public.llm_calls` 行数由外部任意变动 | 连跑 + 期间手工改动共享表行数 |
| 5 | `tests/` 下 `public.llm_calls` 零命中 | `make lint` |
| 6 | 迁移未削弱任何用例：7 条用例的断言逐条对照迁移前后 | 计划阶段逐条列表，verifier 复核 |
| 6b | `test_pool_does_not_preconnect...` 仍持有唯一 `application_name` | 代码复核 + 两进程并发跑该用例 |
| 6c | `test_schema_has_frozen_columns_in_order` 带 `table_schema` 过滤 | 故意在库里留一个残留同名表，用例仍绿 |
| 6d | 沙箱工厂 setup 中途失败不留角色/schema | 注入一个会失败的 DDL，跑完查 `pg_namespace` / `pg_roles` 无 `pgw_%` 残留 |
| 7 | 全套件 + `-m slow` 全绿 | 合并前 |

## 12. 审查留痕（Codex，2026-08-26）

报 6 项实质问题，**全部采纳**，其中两项为阻断级：

| # | 意见 | 处置 |
|---|---|---|
| 1 | **阻断**：`--table` 未限定表名段，会把脚本扩成"任意同形表删除工具"（`--table audit.events` 且该表恰有 `created_at`/`tenant_id` 时真删数据） | 采纳，见 §4.2 新增规则与 §4.3 |
| 2 | **阻断**：只给"最坏情况"用例换低权限角色，正向 apply 用例仍用 superuser 跑，则新安全网对最危险的那条路径不生效 | 采纳，§5.1 改为"凡启动脚本的用例一律用临时角色，无一例外" |
| 3 | `_RUN_PREFIX` 有第二个职责（`application_name` 唯一），机械删除会让连接池用例失去并发隔离 | 采纳，§6.1；本会话的独立清点也得出同一结论 |
| 4 | `test_schema_has_frozen_columns_in_order` 的 `information_schema` 查询不带 schema 过滤 | 采纳，§6.2；核实属实，且仓库注释已记载该坑 |
| 5 | 沙箱工厂 setup 中途失败不清理、uuid 后缀偏短、admin DSN 做成 fixture 等于把越界能力摆在所有用例面前 | 采纳，§7.1 三条硬约束 |
| 6 | lint 门只防字面量，不能当"零触碰"的证明；`--table` 的验收不能只靠 unit | 采纳，§7.2 定位改写 + §11 拆出 1a/1b |

**一处处置与建议不同**：Codex 认为 `Public.llm_calls` 这类大小写手误落到退出 2 属"告警误分类"，建议归 1。本设计维持 2，理由写在 §4.2——该参数格式合法，能否解析到是环境事实；归 1 会让"schema 真的不存在"这类该重试告警的情形被调度器当成不必重试的参数错误。手误由错误消息文本消化。
