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

- **状态**: **已人类审批(2026-08-17)**;Codex 审查 4 项已全部采纳并修订
- **触发**: issue #11「遥测表 llm_calls 缺少租户维度,多租户调用方无法在数据库层隔离」
- **范围**: 公共 API(`chat`/`embed` 签名)、`ports.TelemetryRecorder` 端口、`types.ChatRequest`、两个遥测后端的 schema。属 CLAUDE.md §3 强制设计档 + 人类门。

---

## 1. 需求与边界

### 1.1 issue 原始诉求

GovDoc-SaaS 是多租户法律文书 SaaS,准备启用 `PGW_TELEMETRY_BACKEND=postgres`。落库是审计链的一半证据(另一半业务事实在其自有库,靠 `call_id` 缝合)。阻塞点: `llm_calls` 22 列**没有任何租户维度**,能区分来源的只有 `session_id`/`parent_call_id` 两个调用方自填、库内不校验的自由字符串。而这张表存**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),即多个租户的完整合同与标书全文混在同一张表里,表结构本身不提供按租户过滤的能力。

诉求四条: ①真实租户列(不是藏在 `session_id` 里);②`chat()` 与 `embed()` 两条路径都能传;③该列能挂 RLS 或至少能做复合索引与查询条件;④保留期与访问控制(**issue 明说可另开,本设计不含**)。

**不可逆性是本 issue 的核心论点,且成立**: 先启用后加列,补列之前写进去的每一行都没有租户归属,事后无法还原哪行属于谁——而那些行里是客户合同全文。

### 1.2 本次放大的范围(2026-08-17 人类决策)

issue 只要租户维度。人类决定放大为**调用方自定义维度**的通用能力,但明确收窄了两处:

- **只做调用方自定义的维度**。请求自带信息(模型名、供应商、源名)继续走现有 `model`/`provider`/`source_name`/`model_reported` 列,**库不往新容器写任何自采信息**。
- 保留期与访问控制不在本次范围(issue 第 4 条)。**已另开 issue #12**(2026-08-17)。

**范围补正(2026-08-17,写计划时发现后经人类追认)**: 覆盖**三条**遥测链路而非两条。issue 与本设计初稿都只说了 `chat()`/`embed()`,但 `OcrClient` 经同一 `TelemetryEmitter.emit_attempt` 写遥测(`ocr.py:426`),其 `_emit` 在 `ocr.py:398` 现场构造 `ChatRequest`,结构与 embedding 同构。**OCR 行与 chat 行落在同一张表**——只覆盖两条会让同一张表里一部分行有租户归属、一部分永远空白,且「先启用后加列则归属无法还原」这条不可逆性论证对 OCR 行同样成立。与 issue #10 同一判断(那次 issue 只报告 chat 的 400,OCR 被认定为同一缺陷的其余分支而一并修)。

### 1.3 明确不做

不做可配置的"提升列白名单"(见 §3 方案 C 的否决理由);不自动 `ENABLE ROW LEVEL SECURITY`;不自动建索引;不改动 `_BACKFILL` 的自动 ALTER 策略(调研提出的独立议题,属任务外重构,**已另开 issue #13**)。

---

## 2. 关键既有事实(设计必须服从的约束)

| # | 事实 | 出处 | 对本设计的约束 |
|---|---|---|---|
| F1 | `cache_namespace` **已是必填的租户/项目隔离维度**,per-call 可传并进缓存 key,正是为修正 GovDoc「单 client 服务多租户」的缓存毒化 | ARCH §7.5 | 缓存层租户隔离**已完成**,缺口只在遥测层。新维度**不得**再进缓存 key |
| F2 | `chat()` 签名「冻结」,但带默认值的 keyword-only 参数不破坏该承诺 | ARCH §5.2 + issue #4 先例 | 新参数只能是 keyword-only + 默认值 |
| F3 | 公共类型新增字段必须带默认值(三项目 fake 构造零改动) | ARCH §5.1 约定① | `ChatRequest` 新字段必须有默认 |
| F4 | `TelemetryRecorder` 端口 22 字段冻结,且**新增参数不设默认值**(库外无第三方实现者) | `ports.py:247` | 端口扩到 24 字段,不给默认值 |
| F5 | 遥测调用点收敛为单一 helper,禁止复制参数列表 | 库铁律 | 只改 `TelemetryEmitter._record` 一处 |
| F6 | 遥测写失败降级 warning,不冒泡 | 库铁律 | 校验失败必须在**进洋葱之前**报错,否则被降级吞掉 |
| F7 | SQLite 补列探测用 `PRAGMA table_info`;新列必须排在 `created_at` 之后(列序不得分叉) | `sqlite.py:53-60,123` | 新列追加到现有 22 列末尾 |
| F8 | PG 侧建表/补列**先探测后 DDL**(权限检查早于 `IF NOT EXISTS`) | `postgres.py:174-210`, issue #3/#9 | 复用现有机制,不新增 DDL 路径 |

---

## 3. 备选方案对比

### 方案 A: `tenant_id` 提列 + `meta` JSON 容器(推荐)

`llm_calls` 增两列: `tenant_id`(真实列,可挂 RLS、可建复合索引)与 `meta`(JSON 容器,承载任意调用方自定义 KV,**默认不建索引**)。API 增两个 keyword-only 参数。

**支持证据**: LiteLLM(同为 LLM 网关、同为每调用一行进 Postgres)的 `LiteLLM_SpendLogs` 正是此形态——`team_id`/`organization_id`/`end_user`/`user`/`session_id` 全部提列并索引,而 `metadata`/`request_tags` 两个 Json 列**没有任何索引**。Grafana Loki 的三层(labels 索引 / structured metadata 不索引但可筛 / log line)是同一分野的更严格版本。六家 LLM 可观测平台(Langfuse/LangSmith/Helicone/Braintrust/Phoenix/OpenLLMetry)无一例外都是"少数物化列 + 一个 KV blob"。

**代价**: 下游若想再提一个高频维度(如 `business_id`)要等库发新版。这是有意接受的——见方案 C。

### 方案 B: 纯 `meta` JSON,不提任何列

最小改动、最通用。**否决**,两条独立的实证:

① **RLS 会静默退化**。策略挂 `meta->>'tenant_id'` 语法合法,但 PG 的 *Planner Statistics and Security* 规则在 RLS 场景下对非 LEAKPROOF 函数**当作没有统计信息**来规划,而 `->>`(`jsonb_object_field_text`)未标记 leakproof。pgsql-general 有实证案例(日志直接打印 `not using statistics because function ... is not leak-proof`),Tom Lane 确认根因,报告者**最终解法就是把索引列改成非 JSONB**;Tom Lane 同时警告手工标 leakproof "possibly a security problem"。

② **planner 对 JSONB 本就没有可用统计**(与 RLS 无关的独立问题)。`@>` 走硬编码 0.1% 选择率;Heap 的复现里真实 50% 选择率被估成 0.1%,行数低估 12 万倍,nested loop join 从 300ms 变 **584 秒**。

对一个「bug 会同时击穿所有下游」的库,一个在真实数据量下不可预测退化、且退化点极难诊断的方案不可选。

### 方案 C: 可配置提升列白名单(下游声明 `promoted_keys=[...]`,库据此建列)

最通用,下游不必等库发版。**否决**,理由分三层:

① **业界一致禁止**。dbt(`on_schema_change` 默认 `ignore`,新列静默丢弃)、Airbyte("不建议改动最终表,你的改动可能在同步中丢失")、Fivetran(用户自加的列,后续 MERGE **把值置 NULL**,官方唯一方案是建视图)——三家数据集成工具立场完全一致。

② **本库场景的失败模式更糟**。下游 A 配 `["tenant"]`、B 配 `["dataset"]` 共用一张表: 各自向对方的列写 NULL(尚可忍);但若两方对**同名 key 推断出不同类型**(A 认为 `run_id` 是 TEXT、B 是 BIGINT),第二个到达者的 `ADD COLUMN` 会被 `IF NOT EXISTS` 静默跳过,**从此一直静默写错类型**——不报错、数据持续污染,是最坏的失败形态。

③ **与端口契约冲突**。`_COLUMNS`/`_INSERT` 从常量变成运行时拼接,标识符来自配置,SQL 注入面从零变成需要严格校验;`TelemetryRecorder` 的「22 字段冻结」与列序断言测试全部失效。

**旁证**: 没有任何成熟系统允许"任意 key 自动获得真实列/索引待遇"。唯一的"自动推断"派是 Elasticsearch 的 dynamic mapping,也是唯一有公开事故名的(mapping explosion: 默认 `total_fields.limit=1000`,超限整个写入请求报错;不治理则 master 节点 heap 飙升、`put-mapping` 队列堵塞)。其补救开关 `ignore_dynamic_beyond_limit` 默认仍为 `false`——官方宁可拒写也不默默膨胀。

### 推荐

**方案 A**。它同时满足 issue 的 RLS 硬需求(真实列)与人类要求的通用性(JSON 容器),且与同类系统的实际做法逐字吻合。下游需要给 `meta` 里某个 key 加速时,路径是**表达式索引**(`CREATE INDEX CONCURRENTLY ON llm_calls ((meta->>'k'))`,无 schema 变更、无 ACCESS EXCLUSIVE、写入开销远低于 GIN)或库外建视图,由下游自行决定——这正是 Fivetran 给出的官方答案。

---

## 4. 设计细节

### 4.1 公共 API

四个公共方法对称新增两个 keyword-only 参数(F2/F3):

`chat(messages, *, ..., tenant_id: str | None = None, meta: Mapping[str, Any] | None = None)`;`embed(texts, *, ..., 同两参数)`;`recognize_text(image, *, ..., 同两参数)` 与 `parse_layout(image, *, ..., 同两参数)`。

**OCR 两个方法都要改**——只改一个即漏,而漏掉的那半会静默写出无归属的行。

**为什么 `tenant_id` 独立成参而不是 `meta` 里的一个约定 key**: 它是唯一享有真实列待遇的维度,独立成参让"这个 key 特殊"在签名上自明(P4 显式优于隐式);混在 `meta` 里则需要库偷偷抽取一个魔法 key,调用方拼错 `tenant_id`/`tenantId` 不会报错、只会静默降级成普通维度——正是本 issue 抱怨的失败形态。

**为什么不复用 `cache_namespace`**: 语义不同。namespace 是**缓存隔离单位**(可以是项目名),租户是**数据归属**;二者在 GovDoc 恰好同值不代表概念相同。复用会让下游无法表达"同租户下多个缓存命名空间",且把缓存决策与审计归属绑死。

### 4.2 校验规则(全部在进洋葱之前报 `ValueError`)

| 项 | 规则 | 依据 |
|---|---|---|
| `tenant_id` | 非空字符串;长度 ≤ 128;首尾空白报错 | 空串是哨兵值的地盘(§4.4),调用方传空串多为 bug |
| `meta` key | 非空;`[a-z0-9_.]` 且长度 ≤ 64 | 照搬 OTel semconv 字符集;Langfuse 限「仅字母数字」偏严 |
| `meta` key 数量 | ≤ 16 | 量级参考: Salesforce 自定义索引 25、Loki labels 15、OTel 属性 128(对库偏宽) |
| `meta` value | 仅 `str`/`int`/`float`/`bool`;嵌套需调用方自行序列化 | OTel AnyValue 的可移植子集;后端普遍只可靠支持标量 |
| `meta` value: float | **必须 `math.isfinite`**;`nan`/`inf`/`-inf` 报错 | 见下 |
| `meta` value 长度 | `str` ≤ 256 字符 | 对齐 Sentry tag 的 200、Langfuse 的 200 量级 |
| 保留前缀 | key 以 `pg_` 开头 → 报错 | LangSmith `ls_`、Traceloop `traceloop.`、Helicone `Helicone-` 同款。**库本次不写入任何 `pg_` key**,纯预留防未来撞名 |

**非有限 float 必须在入口拒绝(Codex 审查发现)**。`json.dumps({'k': float('nan')})` 产出 `{"k": NaN}`——这是 Python 的扩展语法,**不是合法 JSON**,PG 的 JSONB 会拒收。若放行,一个调用方的输入错误会变成遥测写入失败,再被 F6 的降级吞成 warning,即**把调用方的 bug 转化为静默丢数据**,恰好违反 P5。故两处同时收口: 入口用 `math.isfinite` 校验,序列化用 `json.dumps(..., allow_nan=False)`(实测该参数会对非有限值抛 `ValueError`),后者是入口失守时的第二道闸而非主防线。

**超限必须报错,不得静默丢弃**。Langfuse 的做法是 value 超 200 字符直接丢弃——这条**不抄**,违反 P5「严禁默认值掩盖错误」。报错点选在**两个公共入口**(`chat()` 与 `embed()`)而非遥测写入点,理由是 F6: 遥测层的一切失败都被降级成 warning,校验放那里等于没有校验。这与 `overlay` 保护键的现有先例同构(`client.py:223` 的 `validate_request_overlay`),校验函数同样共用一份,不在两个入口各写一遍。

### 4.3 内部流转: 三条独立链路

`ChatRequest` 增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] = field(default_factory=dict)`(F3)。二者是**只读快照**,库内中间件永不修改——与 `sampling` 字段同一纪律(issue #4 决策 A)。

`TelemetryEmitter._record` 是唯一的 recorder 调用点(F5),向 recorder 多传两个参数;三个 emit 入口(`emit_attempt`/`emit_cache_hit`/`emit_terminal_failure`)统一从 `request` 读取,不各自组装。

**embedding 走的是另一条链,必须单独贯穿(Codex 审查发现)**。`EmbeddingClient` 不经过 chat 洋葱: `embed()` → `_embed_batch()` → `_attempt()` → `_emit()`,而 `_emit()` 在 `embedding.py:360` **现场构造** `ChatRequest` 仅为复用同一个 Emitter,当前只填了 `session_id`/`parent_call_id`。若只改 `chat()`,结果是 chat 行有维度而 embed 行恒为空——**恰好落空 issue 第 2 条诉求**(两条路径都要能传)。故新维度须沿这四层逐层透传,并在 `_emit()` 构造 `ChatRequest` 时填入。

**OCR 是第三条链,同构同办**(2026-08-17 范围补正)。`recognize_text()`/`parse_layout()` → `_call()` → `_attempt()` → `_emit()`,同样在 `_emit()`(`ocr.py:398`)现场构造 `ChatRequest`。两个公共方法都是入口,都要校验并透传。

**不顺手重构这两条链的参数列表**: 它们已在逐层传 `session_id`/`parent_call_id`,再加两个即四个同类参数,把它们收成一个值对象在美学上更优,但那会改动 embedding 与 OCR 现有的全部内部签名,属任务外重构(反 gold-plating)。本次只做加法;若日后参数继续增长,再单独立项。

**缓存命中行与终态失败行同样带维度**: 前者读 `request` 而非缓存中的历史响应(维度是"本次调用由谁发起",不是历史那次);后者虽无具体源,但租户归属是已知的——这两行恰恰是审计最需要的(缓存命中意味着这次没花钱但确实发生了;终态失败意味着这个租户的请求没被服务)。

**不进缓存 key**(F1)。三条理由: ①`cache_namespace` 已负责隔离,重复; ②进 key 会让全部存量缓存冷启动; ③`meta` 承载的是审计维度而非语义维度,同 messages 同 namespace 下换个 `batch_id` 不应导致 miss。

### 4.4 存储层

两端各追加两列到现有 22 列**末尾**(F7),经现有 `_BACKFILL` 机制补列(F8):

- Postgres: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta JSONB NOT NULL DEFAULT '{}'::jsonb`
- SQLite: `tenant_id TEXT NOT NULL DEFAULT ''` + `meta TEXT NOT NULL DEFAULT '{}'`

**为什么 `NOT NULL DEFAULT ''` 而不是可空**: PG 的 `USING` 表达式返回 **false 或 null 的行都不可见,且静默跳过不报错**。NULL 的 `tenant_id` 在任何 policy 下都不是"未归属",而是**对所有人永久不可见的黑洞**。用哨兵空串则老行归属显式可查(`COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出还有多少行未归属)。同时 PG 11+ 加带非易失默认值的列**不重写全表**(值存进 `pg_attribute.attmissingval`),SQLite 加列是元数据操作,且 SQLite 硬性要求 `NOT NULL` 列必须有非 NULL 常量默认值——三条约束在这个写法上同时满足。

**`meta` 序列化**: `json.dumps(ensure_ascii=False)`,与既有 `messages` 列同口径。空 dict 落 `'{}'` 而非 NULL,保持"缺省即空容器"的单一语义。

**库不自动建索引**。`CREATE INDEX` 是 DDL,非 `CONCURRENTLY` 会锁写,而 `CONCURRENTLY` 不能在事务里跑。与不自动 ENABLE RLS 同源(§4.5),交下游执行。文档给出模板: `(tenant_id, created_at)` 复合索引——列序判据是**启用 RLS 后 policy 会给每一条查询隐式追加 `tenant_id = ...` 等值谓词**,它出现在 100% 的谓词里,必然是前导列(Supabase 实测: policy 引用列加索引 171ms → <0.1ms)。

### 4.5 RLS: 库止步于列 + 模板

**库绝不执行 `ENABLE`/`FORCE ROW LEVEL SECURITY` 与 `CREATE POLICY`**,只在文档提供可复制的 DDL 模板。四条理由:

① **default-deny 会击穿非多租户下游**。启用 RLS 而无匹配 policy → 零行可见/可写,静默不报错。三个下游里只有 GovDoc 是多租户,Video-Tree 与 CHSAnalyzer 都不是;库若自动启用,这两家升级后遥测**全量写失败**,再叠加 F6 的静默降级 = **无声全局丢数据**。这才是本 issue「不可逆」担忧的真正落点。

② **库无权知道角色拓扑**。policy 必须绑定角色,且 AWS 官方要求应用角色**非属主且无 `BYPASSRLS`**;库拿到的只是一条连接串。

③ **权限不对等**。`CREATE POLICY`/`ALTER TABLE` 要求表属主;按最佳实践部署时库的运行时角色恰好不是属主。

④ **SQLite 无 RLS**,承诺 RLS 会让两个后端语义不对等;只承诺"列"则两端一致。

**同类先例一致**: graphile-worker、Ent+Atlas、Citus 官方的 django-multitenant 都把 policy 授权留给使用方;未找到任何"库自动为下游表启用 RLS"的正面先例。

文档必须同时告知三个陷阱: 表属主默认**豁免** RLS(需 `FORCE`);租户上下文只能用 `set_config(..., true)` 且**必须在显式事务内**(asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效而 PG **只发 warning 不报错**,表现为策略永远拿不到租户 → fail-closed 到零行);policy 必须同时写 `USING` 与 `WITH CHECK`,只写前者则租户 A 能插入标着 B 的行。

模板正文(交付物是 wiki 用户文档的一节,此处定稿口径):

```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), ''));
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
  ON llm_calls (tenant_id, created_at);
```

`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——两者合起来使**未设租户 = 零行**(fail-closed),而不是全部行。

---

## 5. 旧版行为审计

本次不是重写/迁移类任务(无 `reference/` 旧模块被替换),但触及三项既有契约,逐条声明:

| 契约 | 处置 |
|---|---|
| `TelemetryRecorder` 22 字段冻结 | **替换为 24 字段**。新参数不设默认值(F4)。库外无第三方实现者,两个内建 recorder 同步改 |
| `llm_calls` 22 列 / 列序 | **保留**列序纪律,新列追加末尾;旧表经 `_BACKFILL` 补列,补列失败仍只降级为逐行丢弃(不置 `_failed`) |
| `chat()`/`embed()` 签名 | **保留**"冻结"承诺——新参数是带默认值的 keyword-only,既有调用点零改动 |

**有意放弃**: 无。**未声明的隐式丢弃**: 无。

---

## 6. 非功能维度

**并发与取消**: 新增字段是不可变快照,随请求在各自链路内流转,无共享可变状态,并发调用互不干扰。取消路径不变——`TelemetryMW` 捕获 `CancelledError` 时的 `emit_terminal_failure` 同样带上维度后立即重抛,遥测写入不延迟取消传播(ARCH §5.1 约定④)。校验在两个公共入口的同步代码里完成(chat 侧在进洋葱之前,embed 侧在切批之前),不涉及 await,无取消窗口。

**降级方向**: 分两段,方向相反且都符合铁律。**校验失败 → 报错**(`ValueError`,在 `chat()`/`embed()` 入口,调用方可见);**遥测写失败 → 静默降级 warning**(缓存/遥测后端不可用属"静默降级"档,不是限流/熔断的"报错而非放行"档)。补列失败 → 逐行降级丢弃,不判死。

**幂等与重复**: 不变。`call_id` 仍是主键,`ON CONFLICT DO NOTHING`/`INSERT OR IGNORE` 语义不受影响。同一 `call_id` 重复写入仍被忽略,新增两列不引入新的重复语义。

**持久化与原子性**: 不变。每行单条 INSERT,两个新列与既有 22 列在同一条语句里落盘,不存在部分写入。

`meta` 的 JSON 序列化在 emitter 内完成。**初稿曾断言"序列化失败不可达",此论断已被 Codex 审查推翻并修正**: 非有限 float 能通过"值是 `float`"这类朴素类型检查,却产出 PG 拒收的 `NaN`/`Infinity` 字面量,于是失败会落到 emitter 的降级 try 里被吞成 warning——调用方的输入错误变成静默丢遥测。修正后是真正的双层收口: 入口 `math.isfinite` 拒绝(主防线,调用方可见),序列化 `allow_nan=False`(第二道闸)。这里记下推翻过程,是因为"入口校验完备 ⇒ 下游不可能失败"这个推理模式本身容易复发。

---

## 7. 错误分类与测试策略

**错误分类**: 校验失败抛 `ValueError`,**不属**四分类——与 `overlay` 保护键的现有先例一致(构造期错误,发生在洋葱之外,`RetryMW` 不参与)。这是有意的: 它不是"一次调用失败",而是"这次调用根本没资格发出"。四分类不新增成员。

**测试策略**(合并前需先失败后通过的证据):

单元层——校验规则逐条红线(key 字符集/数量上限/value 类型/长度/`pg_` 前缀拒绝/**非有限 float**),每条断言**报错而非静默丢弃**(这是 §4.2 的核心承诺,也是与 Langfuse 分道的地方);`ChatRequest` 快照不可变;三个 emit 入口都带上维度(尤其**缓存命中行与终态失败行**——这两条最容易被漏,而它们恰是审计刚需)。

**三条链路各测一遍**——`chat()`、`embed()`、OCR 两方法都必须有"传入维度 → 遥测行带该维度"的用例。embed 与 OCR 尤其不能省: 它们各经四层透传,任一层漏传都不会报错、只会让维度恒为空。批量切批时**每一批的行都应带同一份维度**(维度属于本次 `embed()` 调用,不随批次变化);OCR 的 `recognize_text` 与 `parse_layout` **各测一个**,只测一个会漏掉另一个的透传缺口。

非有限 float 单独一条: 断言 `chat(meta={"x": float("nan")})` 抛 `ValueError` 而**不是**写入时降级成 warning——这是 §6 记录的那个被推翻论断的机械化守卫。

集成层——真实 SQLite 与真实 Postgres 各跑一遍: 新建库列齐全;**旧表(22 列)经 `_BACKFILL` 补列后能写入**,且老行 `tenant_id` 读出为哨兵空串而非 NULL(这是 §4.4 不可逆性论证的机械化验收);补列权限不足时逐行降级而非判死(沿用 issue #9 的既有测试形态)。

契约层——`TelemetryRecorder` 端口 24 字段与两个 recorder 的 `_COLUMNS` 逐字对齐(现有列序断言测试扩展);`meta` 空 dict 落 `'{}'` 而非 NULL。

**不测**: RLS 行为本身(库不执行 RLS DDL,那是下游部署的验收项);索引效果(库不建索引)。

---

## 8. 开放问题(留待人类审批时确认)

1. `meta` key 数量上限取 16 是量级推断(Loki 15 / Salesforce 25 / OTel 128),无本项目实测依据。若下游有明确诉求可调,但**必须有一个有限上限**。
2. `tenant_id` 长度上限 128 同为推断值。
3. 调研另外提出「`_BACKFILL` 自动 ALTER 应降级为默认关闭」(Hangfire `EnableHeavyMigrations` 先例: 防止不受控升级造成长停机或死锁;APScheduler 4.x 则是读到不认识的 schema 版本直接 `RuntimeError` 拒绝启动)。此议题与本 issue 同源(都源于库自管下游 schema)但**属独立架构变更**,按反 gold-plating 不纳入本次。**已另开 issue #13**(2026-08-17)。
