diff --git a/CHANGELOG.md b/CHANGELOG.md index f950aa7..1fb781d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ 不自动启用的首要理由是 **default-deny**: 启用 RLS 而无匹配 policy = 零行可写,且**静默不报错**。三个下游里只有一个是多租户,库若自动启用,其余部署升级后遥测**全量写失败**,再叠加遥测的静默降级铁律,就是无声全局丢数据——恰是本 issue 所担心的"不可逆"的最坏形态。其余理由: policy 必须绑定角色而库只拿到一条连接串;`CREATE POLICY` / `ALTER TABLE` 要求表属主,而按最佳实践部署时库的运行时角色恰好不是属主;SQLite 根本没有 RLS,承诺 RLS 会让两个后端语义不对等。 -RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 `research-wiki/designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5。 +RLS 模板与三个陷阱(表属主默认豁免 RLS 需 `FORCE`;租户上下文必须在**显式事务内** `set_config(..., true)`,asyncpg 默认 autocommit 下单发 `SET LOCAL` 会当场失效而 PG 只发 warning;只写 `USING` 不写 `WITH CHECK` 时租户 A 能插入标着 B 的行)见 README「多租户与自定义维度」一节——那份模板随包分发,`research-wiki/` 不在 sdist 内。 ## 1.2.0(2026-08-16) diff --git a/README.md b/README.md index 4be423d..0b9b801 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,30 @@ resp = await client.chat( 存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。 -**库只提供列,不启用 RLS、不建索引。** 要数据库层的强制隔离,请自行执行 `ENABLE` / `FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`,并建 `(tenant_id, created_at)` 复合索引;不执行则 `tenant_id` 只是一个可查可过滤的普通列。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。 +**库只提供列,不启用 RLS、不建索引。** 要数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。 + +```sql +ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY; +ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免 +CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')); +``` + +```sql +CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created + ON llm_calls (tenant_id, created_at); +``` + +`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。 + +三个陷阱,每一个的失败形态都是**静默的**: + +| 陷阱 | 后果 | +|---|---| +| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 | +| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 | +| policy 必须同时写 `USING` 与 `WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 | ## 错误模型(四分类) diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index d36896b..f321858 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -363,6 +363,16 @@ flowchart TB **`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。 +**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点: + +① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。 + +② **两者都不进缓存 key**。租户级缓存隔离由既有 `cache_namespace` 负责(§7.5);重复进 key 只会让全部存量缓存冷启动,且 `meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应 miss。 + +③ **维度的读取点恒为 `request`**,包括缓存命中行——那一行回答的是"本次调用由谁发起",不是缓存里历史那次。读历史会把本次记到上一个租户头上,两边的账同时错且无任何报错。 + +④ **库只交付列,不执行 RLS DDL、不建索引**(理由与模板见 `designs/2026-08-17-issue11-caller-dimensions-design.md` §4.5;下游可达的那份在 README「多租户与自定义维度」一节——`research-wiki/` 不在 sdist 内)。首要理由是 default-deny:启用 RLS 而无匹配 policy = 零行可写且静默不报错,三个下游只有一个是多租户,库若自动启用,其余部署升级后遥测全量写失败,叠加遥测静默降级铁律 = 无声全局丢数据。 + --- ## 6. 错误模型 @@ -475,10 +485,14 @@ flowchart TB ### 7.8 遥测与成本 -**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**。 +**必录字段**(继承三项目 15 字段规范): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**。 **`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(TelemetryMW 最外层调用)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 +**`reasoning_tokens` 列(2026-08-11,issue #6,端口 21 → 22)**: 推理 token 已计入 `completion_tokens`,故成本总额一直是对的——这不是计费缺口而是**归因**缺口:缺了它,"这次调用花的钱里有多少花在推理上"无法区分,也就无从判断某个 scope 该不该关推理。供应商不报时记 NULL 而非 0(不可得 ≠ 为零,与 `usage_source='unavailable'` 同一纪律)。 + +**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。 + (`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。 - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`。