# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)

- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。

---

## 文件结构

| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings` 增 `telemetry_auto_migrate` |
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
| `.env.example`、`README.md`、`CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |

**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。

---

## 关键接口(跨任务消费,此处定稿)

`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):

```python
COLUMNS: tuple[str, ...]              # 24 个 INSERT 字段(call_id 起、meta 止)
SQLITE_DDL: str                       # CREATE TABLE IF NOT EXISTS(全量列)
PG_DDL: str
SQLITE_BACKFILL: tuple[tuple[str, str], ...]  # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
PG_BACKFILL: tuple[tuple[str, str], ...]      # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
```

**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS` 含 `created_at`,可作对照)。

**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。

两个语句构造函数:

```python
def insert_sql(backend: str, columns: Sequence[str]) -> str:
    """按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。

    子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
    不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
    """

def telemetry_schema_sql(backend: str) -> str:
    """返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
```

recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):

```python
class SQLiteRecorder:
    def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...

class PostgresRecorder:
    def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
```

`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:

```python
telemetry_auto_migrate: bool
```

---

## Task 1: 建 `telemetry/schema.py` 单一事实源

- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
- **行为**: 把 `sqlite.py` 的 `_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS` 与 `postgres.py` 的 `_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()` 与 `telemetry_schema_sql()`。
- **验收**:
  - 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
  - `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
  - `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`。
  - `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`。
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
- **提交**: `refactor: make the telemetry schema a single source of truth`

## Task 2: PG 写入去掉冲突目标

- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`。
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12 按 `created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`

## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)

- [ ] **文件**: `src/polygateway/telemetry/sqlite.py`、`src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`。
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
- **行为**:
  - 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`。
  - 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns` 与 `self._insert = insert_sql(backend, effective)`;`record_llm_call` 按 `self._columns` 取值。
  - `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
  - `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
  - 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
  - `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
  - PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
  - 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
- **验收**: 见测试。
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
  - manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id`、`meta` 与 `ALTER TABLE`。
  - auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
  - manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
  - **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436` 的 `captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
  - 缺 `call_id` 的畸形表 → warning 升级措辞,不抛异常。
  - 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`

## Task 4: 配置派生与装配

- [ ] **文件**: `src/polygateway/config.py`、`.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
- **行为**:
  - `config.py` 增 `_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
  - `GatewaySettings` 增 `telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`。
  - `.env.example` 在 `PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: derive the schema mode from the telemetry backend`

## Task 5: 公共导出

- [ ] **文件**: `src/polygateway/__init__.py`、`tests/unit/test_package.py`。
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
- **提交**: `feat: expose the telemetry schema SQL to downstreams`

## Task 6: 真实 Postgres 集成验收

- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`。
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
- **验收**:
  - manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
  - **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
- **提交**: `test: prove manual mode leaves a stale table untouched`

## Task 7: 文档与承诺

- [ ] **文件**: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
- **行为**:
  - README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
  - CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
  - ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` ∪ `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
- **提交**: `docs: document the schema mode and the expand-contract promise`

---

## 完成判据

1. 七个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
