Files
PolyGateway/research-wiki/designs/issue11-caller-dimensions.md
T
iomgaa bf2fbd6c5e docs: fold the OCR path into the approved scope for issue #11
OcrClient emits through the same helper and its rows land in the same
table as chat rows. Covering only chat and embed would leave one table
holding rows that have a tenant and rows that never will, and the
issue's own irreversibility argument applies to those rows too.

The design said two paths because the issue said two paths. Corrected
at the source rather than only in the plan, so a later reader does not
find OCR work with no design behind it.
2026-08-17 06:22:47 -04:00

6.2 KiB

type, node_id, title, date
type node_id title date
design design:issue11-caller-dimensions 调用方自定义维度设计(issue #11) 2026-08-17

调用方自定义维度设计(issue #11)

正文: 2026-08-17-issue11-caller-dimensions-design.md。状态: 已人类审批(2026-08-17),进入 writing-plans。

审批时三个待定项按设计原值定稿,人类未提出改动: meta key 数量上限 16 / str value 上限 256 / tenant_id 上限 128(量级推断,无本项目实测依据);库不自动建索引、不自动启用 RLS(GovDoc 需 DBA 执行模板 SQL 才拿到数据库层隔离);_BACKFILL 自动 ALTER 降级议题不纳入本次

  • 选定方案: tenant_id 提真实列(RLS 硬需求)+ meta JSON 容器承载任意调用方自定义 KV(默认不建索引)。四个公共方法(chat/embed/recognize_text/parse_layout)各增两个带默认值的 keyword-only 参数,签名冻结承诺不破。端口 22 → 24 字段。
  • 范围(2026-08-17 人类决策): 只做调用方自定义的维度;请求自带信息(模型名/供应商/源名)继续走现有列,库不往 meta 写任何自采信息。issue 第 4 条(保留期与访问控制)另开。
  • 范围补正(2026-08-17,写计划时发现后经人类追认): 覆盖 chat / embed / OCR 三条遥测链路。issue 与设计初稿都只说了前两条,但 OcrClient 经同一 emitter 写遥测(ocr.py:426)且行落同一张表,漏掉会让同表内一部分行有归属、一部分永远空白,不可逆性论证对其同样成立(同 issue #10 判断)。
  • 为什么必须提列而不能纯 JSON: 两条独立实证。① RLS 挂 meta->>'tenant_id' 语法合法但会静默退化——PG 的 Planner Statistics and Security 规则在 RLS 场景下对非 LEAKPROOF 函数当作没有统计信息规划,而 ->> 未标 leakproof;pgsql-general 实证案例的最终解法就是"索引列改成非 JSONB",Tom Lane 警告手工标 leakproof 是安全问题。② 与 RLS 无关的独立问题: planner 对 JSONB 本就无可用统计,@> 走硬编码 0.1% 选择率,Heap 复现里行数低估 12 万倍、join 从 300ms 变 584 秒。
  • 为什么不做"可配置提升列白名单": dbt/Airbyte/Fivetran 三家一致禁止用户自定义列(Fivetran 的后续 MERGE 直接把用户列置 NULL,官方方案是建视图)。本库场景更糟: 两个下游对同名 key 推断出不同类型时,第二个到达者的 ADD COLUMNIF NOT EXISTS 静默跳过,从此一直静默写错类型——不报错、持续污染。且 _COLUMNS/_INSERT 从常量变运行时拼接,SQL 注入面从零出现,端口"22 字段冻结"与列序断言全部失效。
  • 同类系统佐证: LiteLLM(同为 LLM 网关、同为每调用一行进 PG)的 SpendLogs 正是此形态——team_id/organization_id/end_user/session_id 全部提列并索引,而 metadata/request_tags 无任何索引。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野。六家 LLM 可观测平台无一例外都是"少数物化列 + 一个 KV blob"。没有任何成熟系统允许任意 key 自动获得列/索引待遇;唯一的自动推断派 ES dynamic mapping 也是唯一有公开事故名的(mapping explosion)。
  • 库止步于列 + policy 模板,绝不自动 ENABLE RLS: 启用 RLS 而无匹配 policy 是 default-deny(零行可写,静默不报错)。三个下游里只有 GovDoc 多租户,库若自动启用,另两家升级后遥测全量写失败,叠加"遥测写失败静默降级"铁律 = 无声全局丢数据——这才是 issue「不可逆」担忧的真正落点。另三条理由: 库无权知道角色拓扑;按最佳实践部署时库的运行时角色恰好不是表属主、无权 CREATE POLICY;SQLite 无 RLS,承诺它会让两后端语义不对等。先例(graphile-worker/Ent+Atlas/django-multitenant)一致把 policy 授权留给使用方。
  • 哨兵值而非 NULL: PG 的 USING 表达式返回 false 或 null 的行都不可见且静默跳过,故 NULL 的 tenant_id 不是"未归属"而是对所有人永久不可见的黑洞。用 NOT NULL DEFAULT '' 则老行可一条 SQL 审计;同时满足 PG 11+ 加非易失默认值列不重写全表、SQLite 要求 NOT NULL 列必须有非 NULL 常量默认值。
  • 超限报错而非静默丢弃: Langfuse 的"value 超 200 字符直接丢弃"不抄,违反 P5。报错点在 chat() 入口而非遥测写入点——遥测层一切失败都被降级成 warning,校验放那里等于没有校验(同 overlay 保护键先例)。
  • 不进缓存 key: cache_namespace 已是必填的租户隔离维度并已进 key(ARCH §7.5),重复;且进 key 会让存量缓存全量冷启动。
  • 被否决备选: 纯 meta JSON 不提列(RLS 静默退化);可配置提升列白名单(多下游共表静默写错类型);复用 cache_namespace 传租户(缓存隔离单位 ≠ 数据归属,会让下游无法表达"同租户多命名空间");tenant_id 混在 meta 里当约定 key(拼错不报错,静默降级成普通维度)。
  • 审查留痕(Codex,2026-08-17): 报 4 项,逐条核实后全部采纳。① embed() 路径覆盖不足——EmbeddingClient 不走 chat 洋葱,_emit()embedding.py:360 现场构造 ChatRequest,只改 chat 会导致 embed 行维度恒空,恰好落空 issue 第 2 条诉求;② 非有限 float 会击穿"序列化不可达"论断——json.dumpsnan 写成 NaN 字面量(非合法 JSON,PG JSONB 拒收),失败会被降级吞成 warning,即调用方输入错误转化为静默丢遥测;实测确认后改为入口 math.isfinite + 序列化 allow_nan=False 双层收口;③ 校验入口表述只写 chat(),与双路径 API 不一致;④ §4.5 承诺"提供 RLS 模板"却只给了索引模板,已补上含 FORCE/USING+WITH CHECK/NULLIF(current_setting(...)) 的完整定稿。第 ② 条的推翻过程已写进正文 §6,因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式容易复发。
  • 另开议题: _BACKFILL 自动 ALTER 是否应降级为默认关闭(Hangfire EnableHeavyMigrations 先例、APScheduler 4.x 版本不认识即拒绝启动)——与本 issue 同源但属独立架构变更,按反 gold-plating 不纳入本次。