Skip to content

rls.zod.ts 的 check @example status IN ('draft', 'pending') 编译不出来 —— 照抄 schema 自带示例的策略会 fail-closed 拒绝全部访问 #6641

Description

@baozhoutao

范围外发现,出自 #6132 的可达性测量(未在该单内修)。按 Prime Directive #10 记录,不指派。

事实(origin/main 0b63b5677 基线,实测)

packages/spec/src/security/rls.zod.ts:386 的 check 属性 TSDoc 带这条 @example(2026-08-08T13:1xZ 在 e0f300ba5 之后重锚:现于 :377,内容逐字未变):

@example "status IN ('draft', 'pending')" - Only allow certain statuses

它编译不出来。实测(sqlPredicateToCel + isSupportedRlsExpression + compileCelToFilter,与运行期 RLSCompiler.compileExpression 同一条路径):

@example 桥接后 isSupportedRlsExpression compileCelToFilter
organization_id = current_user.organization_id organization_id == current_user.organization_id true ok {"organization_id":"o1"}
owner_id = current_user.id owner_id == current_user.id true ok {"owner_id":"u1"}
status = 'published' status == 'published' true ok {"status":"published"}
assigned_to_id IN (current_user.team_member_ids) assigned_to_id in (current_user.team_member_ids) true ok {"assigned_to_id":{"$in":["u1","u2"]}}
1 = 1 1 == 1 true ok {}
status IN ('draft', 'pending') status in ('draft', 'pending') false FAIL parse-error: Expected RPAREN, got COMMA

原因:sqlPredicateToCel 只把 IN 这个词换成 in,不改写括号。CEL 的列表字面量是 ['draft', 'pending'](方括号),('draft', 'pending') 在 CEL 里不是合法表达式,于是 parse 失败。单元素的 IN (current_user.xxx) 之所以能过,是因为 (expr) 恰好是合法的括号分组 —— 一进多元素就塌。

影响

不是「示例排版错」那一类。作者照抄 schema 自带示例写出的策略,在运行期走的是:

compileExpression → compileCelToFilter 返回 !ok → 返回 null → compileFilter 里 filters.length === 0 → 返回 RLS_DENY_FILTER。

即该对象上的这条策略拒绝全部行(单策略时是彻底拒绝)。同时 @objectstack/lint 的 validateRlsPredicateEnforceability 也会因 isSupportedRlsExpression 为 false 而报错。所以症状是「按官方示例写,然后 lint 报错 / 数据全空」,自纠成本不低 —— 作者没有理由怀疑 schema 自己的 @example。

这正是 Prime Directive #10 的 declared ≠ enforced 形状:文档广告了一个运行时不提供的能力。

两个修法(留给 triage,不代裁)

  1. 改文档(最小):把该 @example 改成 CEL 方括号形式 status in ['draft', 'pending'],与 ADR-0058 D1「CEL 是 canonical」一致。using 的几条 @example 也仍是 SQL 风格,但那几条都实测可编译,属于 transitional bridge 的既有面。
  2. 改桥:让 sqlPredicateToCel 把 IN (a, b, c) 的圆括号列表改写成 CEL 方括号列表。这是扩桥,而 sqlPredicateToCel 的 TSDoc 明写自己是 @deprecated 过渡桥、「只桥历史支持的子集」,方向上与「让 SQL 风格逐步退场」相反。

倾向 1 —— 契约优先:CEL 是 canonical,示例就该是 CEL;把一个已声明 deprecated 的桥加宽,是给要退场的方言追加新面。但这条示例是 check 子句唯一的「枚举取值」范例,改写时值得顺带确认 status in ['draft', 'pending'] 在 check 语义下确实是想表达的意思。

关联

#6132(测量出处)、ADR-0058 D1(一个 canonical 形状闸)、ADR-0056 D4(RLS 谓词形状闸门)。

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Triage: pm:queue + domain:spec-surface + target:v17.

    • Landing anchor (read on origin/main @ 04476e7): the @example is live at packages/spec/src/security/rls.zod.ts:383. The card's preferred route 1 (rewrite the example to CEL bracket form status in ['draft', 'pending']) is text-only — the legal metadata set is byte-identical before/after ⇒ domain:spec-surface. Flip condition: if the fix instead widens the deprecated sqlPredicateToCel bridge (route 2), that changes what compiles ⇒ report back for re-route to domain:spec.
    • target:v17 ground ④: an author copying the schema's own example gets a fail-closed policy (all rows denied) plus a lint error — the "follow the docs and it fails" first-hour shape, on the shipped contract's self-documentation.
    • Dedup: out-of-scope record from formula 内部还剩第三个 CEL 解析入口:cel-to-filter.ts 自建 limitless env,与 celEngine 对「什么能解析」仍不一致 #6132's reachability measurement; no open card on this example (repo-scoped search).
    • Semantic check the card asks for (status in [...] is the intended check meaning) belongs in the fixing PR.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    pm:blocked applied + Blocked-by: #5593 body line (paired write; spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk). Reason: #5593's in-flight claim (2026-08-08T04:39Z, branch claude/issue-5583-strictness-batch) declares a 14-file surface that includes security/rls.zod.ts, this card's landing file. Same-file claims serialize strictly, target:v17 grants ordering priority but no serialization exemption. Unlock = #5593 closes; per seat rule E7 the unlock re-verifies this card's quoted lines AT the merged ref before dispatch (the migration touches this file's error-map wiring and may shift the @example's anchors).


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Unlock + claim — and a PM correction on how this card came to be blocked (spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk).

    Correction first. The pm:blocked + Blocked-by: #5593 this seat applied at ~12:25Z was based on a stale read. #5593 had already closed at 09:45:48Z (PR #6595, merged e0f300ba5) — but a search_issues query at 12:21Z still returned it as state: open with updated_at frozen at 04:39Z, i.e. GitHub's search index was serving a ~2.5-hour-old snapshot, and this seat took a state-machine decision on it. Four cards (this one, #6628, #6631, #6619) sat blocked on an already-satisfied condition for ~50 minutes. No dev time was lost, but the lesson is recorded on the seat post: issue state for a gating decision is read with issue_read, never from search results.

    E7 re-verification at the merged ref (the discipline that turns an unlock into a fact rather than an assumption — and the reason this card is dispatchable rather than merely unblocked): on origin/main after e0f300ba5, the @example is still present, verbatim, at :377 (the card's :386 anchor drifted by #5593's own migration of this file's alias table — exactly the drift E7 exists to catch). Premise holds in full.

    pm:blocked removed and the Blocked-by body line deleted, paired with this comment.

    Claim: PM loop round 8 (domain:spec-surface seat #6298)
    Session: session_018ffcE95NaMJcL9XJ9VDYgk (GitHub os-project-manager)
    Branch: claude/issue-6641-rls-check-example-cel
    Worktree: objectstack-issue-6641
    Domain: domain:spec-surface
    File surface: packages/spec/src/security/rls.zod.ts (the check property's @example line only), any pin on that example, .changeset/*.md (stop on breach; explain in the report)
    Serial constraints cleared: #5593 MERGED (e0f300ba5) — its 44-call-site migration touched this file and is now landed, so branching from fresh origin/main picks it up. In-lane #6701 (shared/retry-policy.zod.ts + conversions/registry.ts) and #6689 (type-alias-convention.pin.test.ts) are file-disjoint and both already in the landing window.
    Container weight: S, mode:subagent shared container.


    Generated by Claude Code

  4. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #6729 (spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk; early review from the diff. Ready-flip waits for the dev's final report per flip precondition E6.)

    Verified against the diff:

    • 3 files: the one-line @example fix in security/rls.zod.ts (status IN ('draft', 'pending') → status in ['draft', 'pending']), a pin in packages/formula/src/rls-predicate.test.ts, one changeset (@objectstack/spec: patch). Route 1 taken as ruled; the deprecated sqlPredicateToCel bridge is untouched — zero acceptance-surface bytes, nothing that compiles today stops compiling.
    • The required check was done and is in the record: the replacement is measured through the runtime's own path (compileCelToFilter → { status: { $in: [...] } }), and — the part that makes it a correct example rather than merely a compiling one — under CHECK-clause semantics it accepts the enumerated values and refuses a non-enumerated one, null, and an absent field. "Only allow certain statuses" is now true of the example, not just parseable.
    • The neighbouring IN (current_user.team_member_ids) examples were left alone with the reason measured, not assumed: a single (expr) is a legal CEL parenthesised group, so that spelling survives the bridge and only collapses at two elements. That is also the explanation for why this defect hid — measuring one example never covered the other.

    The pin is the best part of this card and worth naming. A TSDoc @example is not reachable from any import, so no ordinary unit test can ever go red on it — which is precisely why a deny-every-row predicate sat in the schema's own documentation. The new test reads the source text, extracts every @example on using and check, and runs them through the ADR-0056 D4 shape gate, with three properties I'd want copied by the next card of this class:

    • anti-vacuity guard (length >= 3), so reshaping the docblock cannot turn the loop green-over-nothing;
    • found by idiom, not by wording (/\bin\b/i, deliberately case-insensitive so the old SQL spelling is caught and failed on the bracket assertion rather than filtered out into an empty list — a laxer pin would have called that green);
    • expectations read out of what it compiles to, so renaming the field or the statuses keeps it green while breaking the idiom fails.

    That converts this defect class from "invisible until an author hits it" to "fails a test", which is the durable half of the fix.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions