Skip to content

compileCelToFilter lowers a null MEMBER of a membership array straight into an RLS $in, bypassing the fail-closed path a null SCALAR variable takes — and the backends then disagree three ways on what that filter selects #13496

Closed

Description

@zhuangjianguo

Filed unassigned by the #13357 dev. This is the p2 census that card's triage audit ordered ("执行者顺手做这次普查,带正对照"), and it FIRED. Recording only — no severity asserted, routing is triage's.

Blocked-by: #13357 — what this filter SHOULD select is the ruling that card is waiting for. The fail-closed asymmetry below is independent of that ruling; the row-set consequence is not.

Measured, with a positive control

compileCelToFilter (packages/formula/src/cel-to-filter.ts) is the canonical CEL pushdown for read scopes (ADR-0058 D1/D2/D6), consumed by plugin-security/rls-compiler.ts and plugin-sharing. Executed on 0e810dde17, same expression each time, only the resolved membership array varying:

CONTROL  clean list  : {"ok":true,"filter":{"owner":{"$in":["u1","u2"]}}}
PROBE    null member : {"ok":true,"filter":{"owner":{"$in":["u1",null]}}}
PROBE    only null   : {"ok":true,"filter":{"owner":{"$in":[null]}}}
PROBE    empty list  : {"ok":true,"filter":{"owner":{"$in":[]}}}

The control compiles to a plain $in, so the harness reaches the lowering; the probes are a reading and not a silence. The null member is passed through verbatim into an RLS filter.

The asymmetry

The module's own docblock states the discipline:

A variable that resolves to undefined/null yields unresolved-variable (the "no active org" fail-closed path).

That holds for a SCALAR variable and is pinned (cel-to-filter.test.ts: "null variable (e.g. no active org) → unresolved-variable (fail closed)"). It is not applied to a null MEMBER inside a resolved membership array: lowerMembership checks only Array.isArray(value) and emits the list as-is. So the one shape that is a security predicate gets the fail-closed treatment and the other does not.

The empty-list case is deliberate and already ruled — packages/lint/src/validate-empty-combinators.ts calls $in: [] "a legitimate, declared predicate — matches nothing on every backend", and I measured it consistent across all faces. It is listed above only as the neighbouring shape, not as a defect.

Why the emitted filter is not a harmless no-op

The backends do not agree on what {owner: {$in: [null]}} selects. Measured by execution on the same fixture (id 1, name 'a' and id 3, no value), the two readings of "no value" being a stored null and an absent key:

surface $in: [null]
driver-sqlite-wasm / driver-sql (compiles to IN (NULL)) [] — matches nothing
driver-memory live mingo path ['3'] — matches the no-value row, both readings
driver-memory reference matcher, formula ['3'] on a stored null, [] on an absent key

So one sharing rule or read scope admits three different row sets depending on which backend serves it. That is the harm class #5146 named in its own note — "one permission rule admitting different row sets per backend" — reached here by a machine-generated filter rather than an authored one.

Direction depends on which reading is ruled: against the SQL family a scope that should have admitted rows admits none (a lockout), and against the Mongo family a scope silently admits the rows whose column is unset (a widening). Both are wrong answers with nothing to read.

What this does NOT claim

I did not demonstrate a production context provider that actually puts a null into a membership array — I demonstrated that the lowering does not stop one. Two of the three sibling construction paths DO strip: the engine's expand batch (engine.ts: if (val == null) continue and val.filter((id) => id != null)) and search's option collection (search-filter.ts: if (opt == null) continue). This one is the unguarded member of that set.

Related

#13357 (what $in:[null] means — unruled, and the blocker) · #5332 (the $eq: null ruling, scoped to the scalar spellings) · #13494 (the scalar $eq: null cell) · #5146 / #5298 (no-value semantics on read scopes) · ADR-0055 / ADR-0058 (the pushdown contract)

Activity

  1. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Aug 30, 2026
  2. claude commented on Aug 30, 2026

    @claude
    Contributor

    分诊定级 · 首次定级 · bug · p1 · domain:engine · pm:queue

    ⚠️ 同上:此前只带 pm:retriage,标签误用,本轮定级与摘标一次写完。

    域锚定

    packages/formula 属 engine 车道(references/lanes/engine.md:8)⇒ domain:engine。

    p1 —— 而理由正是它与 #13495 的差别

    本轮同批定级的 #13495 也是放宽方向,本席判了 p2,因为那一面不是用户可观察面。这一张够得到:

    compileCelToFilter 是 ADR-0058 D1/D2/D6 的规范 CEL 下推,消费方是 plugin-security/rls-compiler.ts 与 plugin-sharing。

    ⇒ 活体 RLS 路径。一个 null 成员被逐字放进 RLS filter,而同一模块对 null 标量变量是 fail-closed 且有 pin —— 不对称落在真实的读作用域上。⭐ 卡自带正对照(clean list 编译成普通 $in,证明 harness 确实走到了 lowering),合格。

    ⚠️ 不挂 pm:blocked,尽管正文写了 Blocked-by: #13357

    卡自己把两半分开了,分诊同意这个切分:

    ⛔ 边界(本席上一轮刚吃过这个亏)

    R+53 的 #13166 是一张自相矛盾的派发单:边界禁止触某个目录,而其中一道围栏只可能落在那里。⇒ 本单把边界写成可执行的:

    执行者不得在本单内决定 $in [null] 的行集语义。若修法必须依赖那个语义才能落地 ⇒ 回分诊升 pm:blocked,⛔ 不要自行拍板,也不要为了绕开而把 fail-closed 做成半个。


    Generated by Claude Code

  3. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    and removed
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Aug 30, 2026
  4. zhuangjianguo commented on Aug 30, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Claimed — dispatch R9, claim A

    • Seat / session: domain:engine lane PM · session_01F3jdziLbAPGeceVNmSox5L
    • Branch: claude/issue-13496-membership-null-member
    • Worktree: ../objectstack-13496-cel-null-member (per-task, off origin/main)
    • File surface: packages/formula/src/cel-to-filter.ts + its test siblings. Consumers (plugin-security/rls-compiler.ts, plugin-sharing) are read-only for this claim unless a measurement forces otherwise, in which case report before editing.
    • Clause ② (contract review): FIRES, on the content limb. No packages/spec/src/** path is touched, but this changes what a security pushdown emits or refuses for an input it currently accepts — that is contract accept/reject behaviour at a permission boundary. Review at CONTRACT_REVIEW_TIER before the PR is flipped ready.

    Scope — the fail-closed half ONLY

    This card is explicitly severable and says so itself:

    Blocked-by: #13357 — what this filter SHOULD select is the ruling that card is waiting for. The fail-closed asymmetry below is independent of that ruling; the row-set consequence is not.

    In scope: the asymmetry in lowerMembership — a null MEMBER of a resolved membership array is passed through verbatim while a null SCALAR variable takes the unresolved-variable fail-closed path, against the module's own stated discipline.

    ⛔ Out of scope: making the backends agree on what {owner: {$in: [null]}} selects. That is #13357's ruling and it is with the maintainer. Do not touch memory-matcher.ts, driver-sql, or driver-sqlite-wasm — and note that claim B of this same round holds a lock on memory-matcher.ts.

    ⛔ Also out of scope: the empty-list case. $in: [] is already ruled a legitimate declared predicate by packages/lint/src/validate-empty-combinators.ts and was measured consistent across all faces. It is the neighbouring shape, not a defect.

    Full dispatch order follows in the next comment.


    Generated by Claude Code

  5. zhuangjianguo commented on Aug 30, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Dispatch order — R9 claim A (#13496, fail-closed half)

    Three zones. Zone 1 is not re-adjudicable. Zone 2 is the PM's mechanism assumption and you are asked to try to falsify it — a measured refutation is a better outcome than a confirmation. Zone 3 is a suggestion you may discard without asking.


    Zone 1 — RULINGS

    R1. The fail-closed asymmetry only. In scope: lowerMembership passes a null MEMBER of a resolved membership array through verbatim, while a null SCALAR variable takes the unresolved-variable path, against the module's own docblock. Out of scope: what {owner: {$in: [null]}} selects on each backend. That is #13357, it is needs-user-decision, and it is with the maintainer.

    R2. ⛔ Do not touch packages/drivers/driver-memory/src/memory-matcher.ts. Claim B of this same round holds a lock on that file for #13494/#13495/#13549. Nor driver-sql or driver-sqlite-wasm. If your work appears to require any of them, that is a signal you have crossed into R1's out-of-scope half — STOP and report.

    R3. The empty-list case is already ruled and is not a defect. packages/lint/src/validate-empty-combinators.ts calls $in: [] "a legitimate, declared predicate — matches nothing on every backend", and this card measured it consistent across all faces. Do not repair it. It is load-bearing for Zone 2 below, so read it before you rely on it.

    R4. Clause ② FIRES on this claim. No packages/spec/src/** path is touched, but you are changing what a security pushdown emits or refuses for an input it currently accepts — contract accept/reject behaviour at a permission boundary. Contract review at CONTRACT_REVIEW_TIER before the PR is flipped ready. Budget for it.

    R5. No test may be skipped, disabled, quarantined, allow-listed, deleted or re-baselined.

    R6. Worktree-first, one per repo. ⛔ Never git stash — refs/stash is shared across every worktree in this checkout.


    Zone 2 — PM MECHANISM ASSUMPTION (measure it; falsification is a good outcome)

    A1 — the convergence hypothesis, and it is the whole card.

    There are two candidate repairs and they are not obviously the same change:

    • (a) fail the whole compile closed with unresolved-variable, mirroring what the scalar path does for a null variable — the symmetry the card's title argues for; or
    • (b) strip null members, mirroring what the two sibling construction paths already do: engine.ts (if (val == null) continue, val.filter((id) => id != null)) and search-filter.ts (if (opt == null) continue).

    They differ on the input that matters: ['u1', null]. Reading (a) refuses the whole scope; reading (b) compiles $in: ['u1'] and keeps the resolved member.

    The PM's assumption is that they CONVERGE on safety — because stripping degrades to fail-closed exactly when the list empties, and R3 establishes that $in: [] matches nothing consistently on every backend. If that holds, (b) is strictly better: it is safe in the same cases and does not lock a user out of rows their resolved membership legitimately grants.

    MEASURE THIS. Do not assume it. It is the PM's inference from two facts on the page, not an execution. Specifically: does (b) ever admit a row that (a) would have withheld, on any backend, for any input shape?

    ⛔ If they diverge, STOP and report the divergence — do not pick. Choosing between two security postures for an RLS read scope is a permission-boundary decision and it belongs to the maintainer, not to this seat. A card that comes back with a clean measured divergence and no code is a successful outcome here.

    A2 — completeness of the construction set. The card measured two sibling paths that strip and names lowerMembership as "the unguarded member of that set". Verify there is no third unguarded path. The card explicitly did not claim completeness.


    Zone 3 — SUGGESTED ROUTE (discard freely)

    • The card's positive control — a clean list compiling to a plain $in, proving the harness reaches the lowering — belongs in the committed test, so that a future zero reads as a reading and not as a silence. That control is why this card's four probes are trustworthy; keep the property.
    • Worth considering whether the guard belongs at lowerMembership or one level up at variable resolution, where the scalar rule already lives. The second placement would make the symmetry structural instead of curative — but it is a bigger blast radius, so measure before preferring it.

    STOP conditions


    Generated by Claude Code

  6. claude commented on Aug 30, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 13496,
      "status": "needs_decision",
      "branch": "claude/issue-13496-membership-null-member",
      "pr": null,
      "premise_still_valid": true,
      "summary": "NO CODE, by Zone-2's STOP condition: candidates (a) fail-closed and (b) strip-nulls DIVERGE, and the divergence is a permission-boundary posture choice, so I did not pick. The card's premise holds and was re-verified on origin/main 50cf2940b9: lowerMembership checks only Array.isArray and emits the list verbatim, so a null MEMBER reaches an RLS $in while a null SCALAR takes unresolved-variable. The PM's convergence hypothesis is FALSIFIED: it is true in POSITIVE polarity only. `not in` is a first-class, documented, pinned member of the pushdown subset (cel-to-filter.ts docblock; cel-to-filter.test.ts 'not in -> !(x in y) -> $not wrapping $in') and it lowers to $not{$in}. R3's ruled fact -- $in:[] matches nothing on every backend -- is what makes stripping safe, and $not inverts it into matches-EVERYTHING. So (b) does not degrade to fail-closed when the list empties; under negation it degrades to fail-OPEN. Branch was pushed empty as the write-path probe and carries no commits; remote deletion is blocked for this seat, so it remains as the claim's landing marker. Worktree removed cleanly (no --force). No R2-forbidden file (memory-matcher.ts / driver-sql / driver-sqlite-wasm) was read or written; both backends I measured on are the two the module's own docblock names.",
      "clause_2_reading": "CONCUR with the PM that clause 2 FIRES, and I overrule UPWARD on scope, not on tier. It is not yet exercised (no diff), but it binds whichever repair is ruled. Upward: contract review must cover plugin-security/rls-compiler.ts's isEmptyMembershipFilter as well as cel-to-filter.ts. That guard is the thing (b) would have depended on for its 'degrades to fail-closed' property, and I measured it polarity-blind (filed as #13552) -- so a review scoped to the lowering alone would review only half of the accept/reject surface the repair actually moves. CONTRACT_REVIEW_TIER unchanged.",
      "a1_convergence_measurement": {
        "verdict": "DIVERGE — hypothesis falsified. Reported, not chosen (Zone-2 STOP).",
        "harness": "compileCelToFilter executed on origin/main 50cf2940b9. Variant (b) is measured exactly without mutating the source: stripping happens BEFORE lowering, so (b)'s emission for input X is byte-identical to compile(expr, strip(X)). Variant (a)'s emission is a refusal, modelled by the module's own already-pinned scalar path (a null variable). Row sets from @objectstack/formula's matchesFilterCondition -- a production face, paired with this very compiler at plugin-security/security-plugin.ts step 3.6 -- over a 5-row fixture: r_me/u_me, r_peer/u_peer, r_other/u_other, r_null/owner=null, r_absent/no owner key.",
        "positive_polarity_owner_in_ids": "CONVERGES ON SAFETY, as the PM assumed. control ['u_me','u_peer']: (b) = current = [r_me,r_peer]. ['u_me',null]: (a) DENY / (b) $in:['u_me'] = [r_me]. [null]: (a) DENY / (b) $in:[] = []. []: (a) DENY / (b) $in:[] = []. (b) never exceeds what the surviving resolved members grant.",
        "negated_polarity_not_owner_in_ids": "DIVERGES, and inverts. ['u_me',null]: (a) DENY 0 rows / (b) $not{$in:['u_me']} = [r_peer,r_other,r_null,r_absent] -- 4 rows, INCLUDING r_null, the row the unresolved member was meant to exclude; stripping silently deletes a blocklist entry. [null]: (a) DENY 0 rows / (b) $not{$in:[]} = ALL 5 OF 5 ROWS -- the entire table. Same for a disjunctive scope '!(owner in ids) || owner == \"u_me\"'. So YES: (b) admits rows (a) withholds, and they are not rows the resolved membership grants -- they are the complement.",
        "second_backend_confirmation": "service-analytics/read-scope-sql.ts, the second of the docblock's 'one AST, two backends': $in:[] lowers to FALSE_CLAUSE '1 = 0' (its comment reads 'IN () matches nothing -- safe'); $not lowers to a null-safe negation whose own comment states it is built so this compiler 'admits the same rows driver-sql / driver-memory / formula admit'. NOT (1 = 0) is TRUE for every row. The allow-all reading is therefore uniform across all four faces BY THAT COMMENT'S OWN DESIGN STATEMENT -- I did not need to execute a forbidden driver, and the divergence rests entirely on R3's already-ruled $in:[] semantics, never on #13357's unruled $in:[null].",
        "reachability": "Not theoretical. isPushdownableCel -- the ADR-0056 D4 authoring gate behind validate-rls-predicate-enforceability -- returns {ok:true} for '!(id in current_user.org_user_ids)' and for the disjunctive form. An author can ship the negated shape as an RLS `using` clause today.",
        "why_the_inference_failed": "The PM's premise 'stripping degrades to fail-closed exactly when the list empties' is polarity-blind. 'Matches nothing' is fail-closed only in positive polarity. The repo's own hand-written security paths already know this and do not rely on it: tenant-layer.ts returns an explicit RLS_DENY_FILTER when its membership set empties rather than an empty $in, and rls-compiler.ts has a dedicated isEmptyMembershipFilter guard to convert a bare $in:[] into the deny sentinel 'rather than a permissive $in:[]'. I measured that guard: it fires only on the bare positive shape and NOT on $not{$in:[]} or on a disjunct containing it. So (b)'s safety net is already absent in exactly the polarity where (b) needs it."
      },
      "a2_completeness_check": "NO THIRD UNGUARDED PATH FOUND. The card's claim holds and understates the guarding: the construction set is larger than three, and lowerMembership is still its only unguarded member. Guarded siblings, beyond the two the card named (objectql/src/engine.ts lines 8118/8260/8332/8451 'if (val == null) continue'; objectql/src/search-filter.ts:90 'if (opt == null) continue'): core/src/security/resolve-authz-context.ts:610-616 builds org_user_ids -- the very variable this card is about -- with .filter(v => typeof v === 'string' && v.length > 0); same file 558-564 builds accessible_org_ids with 'if (typeof org === 'string' && org)'; plugin-security/security-plugin.ts:6160-6165 builds controlled-by-parent masterIds with .filter(id => id != null). One guard in that sweep is DEAD and is filed as #13551.",
      "production_provider_stop_condition": "NOT TRIGGERED — severity NOT raised, and the card's careful narrow claim stands verbatim. The canonical production provider of org_user_ids (resolve-authz-context.ts step 5) strips non-strings and empty strings before the array is set, assemble-execution-context.ts:340 carries that array through unchanged, and spec/kernel/execution-context.zod.ts:222 declares org_user_ids: z.array(z.string()).optional(). No first-party provider injects a null member. The residual live surface is HOST-supplied, not first-party: compileCelToFilter's `variables` is a documented public option; rls-compiler.ts:148 reads (executionContext as any)?.org_user_ids through a cast that bypasses the declared type; explain-engine.ts:575 assigns context.org_user_ids unvalidated. Worth stating in whichever repair lands, but it does not change severity.",
      "tests": "No implementation, therefore no gate family and no changeset: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` was deliberately NOT run, because the change set is empty and it would have derived a family for a diff that does not exist. Stating that plainly rather than reporting a green sweep that measured nothing. What I did run, all through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-13496, exit codes captured before any pipe: (1) pnpm --filter '@objectstack/formula^...' build -- lock VERDICT 'command-exit 0 · held the lock 131s', check-dts-emitted '34/34 declared declaration file(s) present'; dependency closure built FIRST so no reading came off a stale dist. (2) A1 divergence probe, pnpm --filter @objectstack/formula exec vitest run --maxWorkers=2 src/__probe-a1.test.ts -- lock VERDICT 'command-exit 0 · held the lock 3s · waited 91s', vitest 'Test Files 1 passed (1)'; full emission and row-set table captured. (3) Reachability probe, same command -- lock VERDICT 'command-exit 0', 'Tests 1 passed (1)', four isPushdownableCel readings all {ok:true}. (4) The isEmptyMembershipFilter polarity check, run as a verbatim copy of the helper from rls-compiler.ts:74-82 under plain node: 'guard fires: true' for the bare shape, 'false' for $not{$in:[]} and for the disjunct. NO ABLATION was performed -- there is no committed implementation to mutate, and the repo's ablation discipline forbids mutating an uncommitted one. The probe file was scratch-only and removed; `git status --porcelain` over the whole tree is EMPTY at HEAD 50cf2940b9, i.e. the branch carries no commits and no working-tree residue.",
      "mcp_calls": "3 — one search_issues rejected 422 (invalid syntax) plus two successful dedup search_issues. Everything else (issue read, comment read, both issue creations, both read-backs) went through repo-scoped REST at zero MCP cost. NOTE a channel fact for the PM: the global search/issues endpoint is CLOSED to this seat ('sessions are bound to their configured repositories'), while repo-scoped REST reads AND writes both work — so issue creation does not need MCP here, but dedup search does.",
      "open_questions": [
        {
          "question": "A null MEMBER of a resolved membership array reaches an RLS $in verbatim. Two repairs were put up and they are NOT safety-equivalent: measured, (b) admits the ENTIRE TABLE where (a) denies, whenever the membership empties under the supported `not in` form. Which security posture should lowerMembership take? This is a permission-boundary choice and I did not make it.",
          "options": [
            "A — fail the whole compile closed with unresolved-variable, mirroring the pinned scalar path. Safe in every polarity because it refuses before polarity exists. Cost: a user whose membership contains one unresolvable member loses the whole scope, including rows their resolved members legitimately grant — a lockout, and it is silent at the RLS layer (policy drops to the deny sentinel).",
            "B — strip null members, mirroring the sibling construction paths. MEASURED UNSAFE as stated: safe in positive polarity, fail-OPEN under $not (whole table on [null], and it silently deletes a blocklist entry on ['u1',null]). Should not ship in this form.",
            "C — strip, but only after the negation question is closed: strip null members AND extend the emptied-membership deny to fire in any polarity (i.e. repair rls-compiler.ts's isEmptyMembershipFilter, #13552, so $not{$in:[]} also yields the deny sentinel). Keeps B's benefit — a user is not locked out of rows their resolved members grant — and restores the fail-closed floor B assumed it already had. Cost: a second, wider surface (the RLS deny guard) moves in the same change, and it still does not fix the ['u1',null]-under-negation case, where a blocklist entry vanishes with no emptiness to detect.",
            "D — polarity-aware: strip in positive position, fail closed under negation. Most precise, and it is the only option that also covers ['u1',null] under $not. Cost: lowerMembership does not currently know its polarity — it would have to be threaded through the lowering, which is the 'bigger blast radius' placement Zone 3 flagged."
          ],
          "recommendation": "RECOMMENDATION ONLY — this is escalation material for the maintainer, not a seat decision, and I wrote no code either way. I recommend A now, with C or D considered separately and deliberately. Real business need: the pull for B is a user not being locked out of legitimately granted rows — but I found NO first-party provider that injects a null member (resolve-authz-context strips), so the lockout B prevents is not a measured production scenario, while the allow-all B introduces is reachable from authored metadata that passes the enforceability gate today. The evidence is asymmetric and it points at A. Long-term soundness: A is the module's OWN stated discipline, already pinned for the scalar case, and it makes the docblock true instead of aspirational; B would make the pushdown compiler the one security surface whose safety argument is silently polarity-dependent — the exact shape of workaround Prime Directive 5 rejects. Making AI-written code hard to get wrong: A refuses loudly at the boundary; B is a lenient consumer-side accommodation of a malformed input (Prime Directive 12 puts the fix at the producer — and the producer is already correct, which is precisely why tolerating the shape here buys nothing). Startup scope discipline: A is a few lines in one function; D is a polarity thread through the lowering and C drags a second security guard into a card that is already half-blocked by #13357. A is the smallest change that is correct in every polarity. Where I would NOT overrule the maintainer: the lockout A causes is silent, and if there is product knowledge that a null member IS expected from some host integration, that changes the first axis and D becomes the right answer. I do not have that knowledge."
        }
      ],
      "out_of_scope_findings": [
        "filed as #13551: plugin-sharing sharing-service.ts builds grantedIds as .map(g => String(g.record_id)).filter(Boolean) at two sites — the guard is DEAD, since String(null) is 'null' and String(undefined) is 'undefined', both truthy, so a null record_id enters a security $in as the literal string 'null'. Positive polarity only, fail-closed direction (grant silently dropped); the defect is the guard that cannot fire. Unassigned, unlabelled.",
        "filed as #13552: rls-compiler.ts's isEmptyMembershipFilter deny guard is polarity-blind — it fires on {field:{$in:[]}} but not on {$not:{field:{$in:[]}}} or on a disjunct containing it, so an emptied membership under the supported `not in` form compiles to allow-all instead of the deny sentinel the guard exists to produce. Reachable with NO NULL ANYWHERE, so it survives whatever #13496 is ruled — but it is also the load-bearing evidence for #13496's decision (option C above repairs it). Filed separately so it cannot be lost; triage may prefer to fold it into #13496. Unassigned, unlabelled."
      ]
    }

    Generated by Claude Code

  7. zhuangjianguo commented on Aug 30, 2026

    @zhuangjianguo
    CollaboratorAuthor

    → needs-user-decision:权限边界抉择,PM 不裁

    R9 claim A 按 Zone 2 的 STOP 条件返回:零代码。这是该条款设计出来要拿到的结果,不是失败。分支 claude/issue-13496-membership-null-member 停在派发基 50cf2940b9,零 commit、无 PR、无残留。

    ⛔ 首先记我自己的错:PM 的收敛假设被实测证伪。 我在派发单里假设 (a) fail-closed 与 (b) strip-null 在安全性上收敛,理由是「strip 在列表清空时正好退化为 fail-closed,而 $in: [] 已被裁定在所有 backend 匹配零行」。该推理是极性盲的。「匹配零行」只在正极性下等于 fail-closed。

    我逐条复核过的实测(在 df18120502 上重跑,非转述)

    断言 复核结果
    not in 是下推子集的一等成员 ✅ cel-to-filter.ts docblock:「not in is !(x in y). Negation wraps in $not.」并由 cel-to-filter.test.ts:73 钉住
    isEmptyMembershipFilter 极性盲 ✅ 结构性成立:它要求内层对象的唯一键是 $in;$not 包裹后顶层唯一键是 $not,内层键是字段名 ⇒ 永远返回 false
    该 guard 的存在目的正是防止宽松读法 ✅ 其自身注释:「fails closed via the deny sentinel rather than an always-false $in: []」
    SQL 面 $in:[] → 1 = 0 ✅ read-scope-sql.ts:945 return FALSE_CLAUSE; // IN () matches nothing — safe,FALSE_CLAUSE = '1 = 0' ⇒ NOT (1 = 0) 对每一行为真

    ⇒ (b) 在负极性下不是退化为 fail-closed,而是退化为 fail-OPEN。 membership 清空时 !(owner in ids) 编译成 $not{$in:[]},读作用域放行整张表;而 ['u1', null] 在负极性下会静默删掉一条黑名单条目。

    四棱镜

    ① 真实业务需求. 推动 (b) 的是「用户不该因一个不可解析成员而失去其已解析成员合法授予的行」。但实测:没有任何一方提供方注入 null 成员 —— resolve-authz-context.ts:610 就地 .filter(v => typeof v === 'string' && v.length > 0),spec 声明 org_user_ids: z.array(z.string())。所以 (b) 要防的锁死是未测得的生产场景;而 (b) 引入的 allow-all,可由今天就能通过 isPushdownableCel 授权门的元数据触达。证据是不对称的,指向 A。

    ② 长期健全性. A 是该模块自己写下的纪律,且标量情形已被钉住;选 A 使 docblock 从「愿景」变成「事实」。选 (b) 会让下推编译器成为全平台唯一一个安全论证静默依赖极性的安全面。

    ③ 让 AI 写的代码不易出错. A 在边界上大声拒绝。(b) 是消费端对畸形输入的宽容迁就,而生产端本来就是对的 —— 在此处容忍这个形状买不到任何东西。

    ④ 创业阶段范围纪律. A 是一个函数里的几行。

    两处我要修正报告的成本估计(都对维护者的取舍有实质影响)

    ⚠️ C 的成本被高估了 —— #13552 无论 #13496 怎么裁都欠着。 它不含任何 null 也可触达(membership 正常解析为空即可)。所以 C 不是「A 再加一个更宽的面」,而是「B 加上你本来就欠的活」。#13552 是独立的 security 债,不该被记成某个选项的代价。

    ⚠️ D 的成本也被高估了。 报告说极性「必须穿线穿过 lowering」。实测于 df18120502:lowerMembership 已经接收 ctx(:359,调用点 :308),Ctx 接口在 :273,而 $not 在全文件只有一个产生点(:299 的 '!_' 分支)。⇒ D ≈ 在已经流到该点的接口上加一个字段,在单一分支上翻转它。
    ⚠️ 但我未验证:!_ 否定的是任意子树,极性必须翻转而非置位(嵌套 !!、$or 内的 $not、以及 !(a && b) 的德摩根展开都要正确处理)。这一条标记为未测。

    请裁

    PM 建议 A(证据不对称 + 模块自身纪律),并无论如何单独修 #13552。

    ⛔ 但这是权限边界抉择,是您的。若存在产品侧知识说明某个 host 集成确实会送来 null 成员,①的天平就翻转,D 成为正解 —— 那份知识我没有。


    相关:#13552(极性盲的 deny guard,本决策的载荷证据,且独立成立)· #13551(String(null) 使 .filter(Boolean) 成为死守卫)· #13357($in:[null] 选什么,未裁)


    Generated by Claude Code

  8. hotlong commented on Aug 31, 2026

    @hotlong
    Contributor

    ⚖️ RULED — 维护者,2026-08-31,总监席第 5 场决裁批 #1,verbatim 「同意」(采选项 A)

    裁定:fail-closed。 membership 数组中的 null 成员触发与 null 标量同款处置——unresolved-variable / deny sentinel,⛔ 不 strip、不静默清洗。PM 复核的极性测量被采信为裁定依据:strip 在负极性($not{$in:[]})下退化为放行整表的 fail-open,「strip 与 fail-closed 收敛」的假设不成立。

    执行要点:① 守卫落在 lowerMembership(成员级校验,非仅 Array.isArray);② 钉子必须双极性(正/负极性各一,含 not in 包裹形);③ 今日无生产者注入 null(resolve-authz-context.ts:610 已过滤、spec 声明 z.array(z.string()))⇒ 本修复对现网行为零可见变化,纯粹把模块自书纪律补到漏掉的形状;④ 本裁定只覆盖 fail-closed 不对称这一半——Blocked-by: #13357(NULL comparand 该选什么)独立在裁,两者互不等待,实现者 ⛔ 不得顺手替 #13357 选边。

    状态转换(同笔):摘 needs-user-decision → pm:queue。席位边界:裁不派,派发归 domain:engine 车道。


    Generated by Claude Code

  9. zhuangjianguo commented on Aug 31, 2026

    @zhuangjianguo
    CollaboratorAuthor

    Claimed — dispatch R12, on the maintainer's 批 #1 ruling

    • Seat / session: domain:engine lane PM · session_01F3jdziLbAPGeceVNmSox5L
    • Branch: claude/issue-13496-membership-null-fail-closed — ⛔ NOT the R9 branch claude/issue-13496-membership-null-member, which carries zero commits and was only a write-path probe. Start fresh off origin/main.
    • Worktree: ../objectstack-13496-fail-closed
    • File surface: packages/formula/src/cel-to-filter.ts + its test siblings.
    • Clause ②: FIRES — it changes what a security pushdown refuses. The ruling notes this repair has zero visible production change today, which is an argument for the reviewer, ⛔ not a reason to skip the review. Review at CONTRACT_REVIEW_TIER before ready. State your own reading at claim time.

    Zone 1 — the maintainer's ruling, verbatim. ⛔ NOT re-adjudicable.

    裁定:fail-closed。 membership 数组中的 null 成员触发与 null 标量同款处置——unresolved-variable / deny sentinel,⛔ 不 strip、不静默清洗。PM 复核的极性测量被采信为裁定依据:strip 在负极性($not{$in:[]})下退化为放行整表的 fail-open,「strip 与 fail-closed 收敛」的假设不成立。

    执行要点:① 守卫落在 lowerMembership(成员级校验,非仅 Array.isArray);② 钉子必须双极性(正/负极性各一,含 not in 包裹形);③ 今日无生产者注入 null(resolve-authz-context.ts:610 已过滤、spec 声明 z.array(z.string()))⇒ 本修复对现网行为零可见变化,纯粹把模块自书纪律补到漏掉的形状;④ 本裁定只覆盖 fail-closed 不对称这一半——Blocked-by: #13357(NULL comparand 该选什么)独立在裁,两者互不等待,实现者 ⛔ 不得顺手替 #13357 选边。

    R5. No test may be skipped, disabled, quarantined, allow-listed, deleted or re-baselined.
    R6. Worktree-first. ⛔ Never git stash — refs/stash is shared across every worktree here.

    What execution point ② actually demands, and why it is the crux

    A pin in positive polarity alone would have passed the entire R9 investigation and still left the defect that made this a ruling. The negated form — !(owner in ids), which isPushdownableCel accepts today — is where stripping inverted into allow-all. Both polarities, or the suite does not cover the thing that was ruled on.

    Inputs already measured — reuse, do not re-derive

    R9's investigation produced these and they are sound; re-deriving them is waste:


    Zone 2 — PM assumption to falsify

    I have one, and my last one on this card was measured wrong, so treat this with suspicion: I expect the guard to be a few lines inside lowerMembership with no polarity threading, because failing closed refuses before polarity exists. Test that. If a member-level guard turns out to need polarity awareness after all — or if refusing at lowerMembership cannot produce the same unresolved-variable result the scalar path produces — say so; that changes the shape of the fix.


    STOP conditions


    Generated by Claude Code

  10. claude commented on Aug 31, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 13496,
      "status": "done",
      "branch": "claude/issue-13496-membership-null-fail-closed",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/13630",
      "premise_still_valid": true,
      "summary": "Implemented the 2026-08-31 ruling: lowerMembership now refuses a null/undefined MEMBER of a VARIABLE-RESOLVED membership array with the same unresolved-variable reason the scalar path uses, which rls-compiler.ts already turns into RLS_DENY_FILTER. The guard is 6 lines, member-level (not merely Array.isArray), and reads no polarity. 12 pins added: 8 assert the refusal across both polarities including the `not in` wrapped form and its disjunctive/conjunctive enclosings, 4 are non-regression controls. Draft PR 13630 opened off final commit 1cdf3c14ef. Two scope boundaries held: nothing here decides what $in:[null] SELECTS (13357 untouched — an AUTHORED literal null in a list is deliberately not this guard's business, pinned as such), and isEmptyMembershipFilter was not repaired as a rider (13552, which I note has since been closed independently).",
      "clause_2_reading": "CONCUR that clause 2 FIRES — this changes what a security pushdown refuses, so contract review at CONTRACT_REVIEW_TIER before ready. The ruling's point 3 (zero visible production change today) is material FOR the reviewer, not against the review. I NARROW the R9 report's upward overrule on scope: R9 argued the review must also cover rls-compiler.ts's isEmptyMembershipFilter, and that was sound WHILE STRIPPING WAS A LIVE CANDIDATE, because stripping leaned on that guard for its fail-closed floor. With fail-closed ruled, the repair does not depend on it at all, so it is context rather than half of this change's accept/reject surface. Review scope: packages/formula/src/cel-to-filter.ts only. Tier unchanged. The one question worth the reviewer's attention: whether refusing at the compiler is the right altitude given the lockout it implies — a user whose membership carries one unresolvable member loses the whole read scope, silently, at the deny sentinel. That cost is what the ruling accepted, and it is measured unreachable from first-party providers today.",
      "both_polarity_coverage": "POSITIVE (`id in current_user.org_user_ids`): ['u_me',null] / [null] / ['u_me',undefined] all -> unresolved-variable. NEGATIVE, the `not in` wrapped form (`!(id in current_user.org_user_ids)`): ['u_me',null] -> unresolved-variable; [null] -> unresolved-variable rather than $not{$in:[]} (the whole-table shape); plus the negated form inside a disjunction (surviving disjunct does not rescue the compile) and inside a conjunction. An 8th pin asserts scalar-null, positive-null and negated-null yield the IDENTICAL reason — the ruling's 同款处置 stated as an assertion. 4 controls that must NOT move: fully resolved list compiles in both polarities; EMPTY list still compiles to $in:[] in both polarities; authored literal null in a list untouched; isPushdownableCel untouched. Envelope asserted, not bare throw: every refusal pin asserts reason === 'unresolved-variable' AND that detail names the variable path and the phrase 'unresolved member' — the module's result envelope is {ok,reason,detail}, so reason is the analogue of code/status here.",
      "zone_2_result": "PM ASSUMPTION HOLDS — and the mechanism is now explicit rather than assumed. The guard is 6 lines inside lowerMembership with NO polarity threading, and refusing at lowerMembership DOES produce the same unresolved-variable result the scalar path produces. Why: lowerCelAst wraps the entire descent in ONE try/catch returning {ok:false,reason,detail}, and lowerCondition's '!_' case builds its $not only from the value its recursive call RETURNS. A throw from lowerMembership unwinds before any wrapper exists, so !, && and || all collapse to the single unresolved-variable result. The 8-row table above plus the identity pin are the measurement; the ablation is the negative control (see tests).",
      "tests": "All readings at final commit 1cdf3c14ef, tree clean, exit codes captured before any pipe. (1) DEPENDENCY CLOSURE FIRST: pnpm --filter '@objectstack/formula^...' build — lock VERDICT 'command-exit 0 · held the lock 131s', gate line 'check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present'. (2) pnpm --filter @objectstack/formula test -> 'Test Files 25 passed (25)', 'Tests 655 passed (655)'; pnpm --filter @objectstack/formula typecheck -> exit 0, script name echoed (not a zero-match no-op). New suite proven to have EXECUTED, not merely counted: a --reporter=verbose run names all 12 new tests with a green tick. (3) DOWNSTREAM CONSUMER SWEEP, prefix filter '...@objectstack/PKG' = consumers, not dependencies — the direction a contract narrowing lands: plugin-security 1694 passed / 92 files, plugin-sharing 678 / 30, lint 2360 / 85, service-analytics 1805 / 83 = 6537 tests, 290 files, all green, each closure built first. (4) ABLATION on a COMMITTED implementation: guard body deleted, marker injected. Mutation proven on disk in BOTH directions before measuring — injected marker grep -c = 1, deleted-text grep -cF = 0, on-disk hash a32c33da != HEAD blob b87311f1. Ablated: 'Test Files 1 failed (1)', 'Tests 8 failed | 56 passed (64)' — exactly the 8 refusal pins red, all 4 controls green; the identity pin reported ['unresolved-variable','ok','ok'], i.e. the card's asymmetry reproduced. NO REBUILD LEG, and none is claimed: the suite imports './cel-to-filter', a same-package RELATIVE specifier, so vitest compiles the mutated source rather than resolving a dependency's exports to dist/ — and the red result is itself the proof, since a stale-dist ablation stays green. RESTORE under trap RESTORE_FN EXIT INT TERM with an absolute REPO_ROOT and 'git checkout HEAD -- PATH' (never the bare form, which restores from the polluted index); proven by whole-tree git status --porcelain EMPTY, git diff HEAD empty, HEAD-blob hash match b87311f1d259d517c3342dd474d2d6830e21457a on both sides and non-empty, marker count 0, guard text back at 1. (5) GATE FAMILY from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (29 path-derived + the convention-triggered set for 'adds or edits a test file'), whole union RE-RUN at final HEAD 1cdf3c14ef after the last commit: all green except two recorded NOT MEASURED rather than as passes — check-test-completeness (exit 3, its own text 'PREREQUISITE NOT MET', it grades a saved turbo run test log CI tees) and check:dual-build-cjs-loads (exit 3, its own line 'This is NOT a pass: nothing was measured', 52 packages have no dist, needs a full pnpm build). (6) check:type-check-debt REFUSED GLOBALLY (55 unbuilt workspace dependencies of the ledgered packages) — a refusal to measure, not a red — so it was measured NARROWLY, which CAUGHT A REAL RED: only @objectstack/formula's entry can move from this diff and its closure IS built, so the gate's own remeasureProject was reproduced faithfully and run direct. FIRST run scored 21 against the ledger's frozen 17: my new tests had added 4 x TS2345, because the module-level ok() helper pins its second argument to the exact shape of VARS. Repaired AT THE SOURCE (a locally widened helper, same assertion, same throw) — never by touching the shrink-only baseline — and re-measured to 17, matching the ledger exactly. The replica is calibrated by that episode, not asserted. (7) NOT MEASURED, stated because a green reading here would be false: @objectstack/formula's tsconfig excludes '**/*.test.ts', so 'pnpm --filter @objectstack/formula typecheck' says NOTHING about the new test file; the TEST_DEBT re-measure in (6) is the reading that DOES cover it. (8) REPO-WIDE ESLINT, the full population and not a narrowing: eslint . --no-inline-config --format json -> 5554 files, 0 errors, 0 warnings. (9) No test skipped, disabled, quarantined, allow-listed, deleted or re-baselined.",
      "mcp_calls": "2 — one targeted search_issues for dedup (0 hits) plus one control search in the same session that returned a known-certain hit, proving the empty result was a read and not a closed channel. Everything else — issue read, comment read, PR creation, PR body read-back, issue creation, this report — went through repo-scoped REST at zero MCP cost. Channel fact for the PM, re-confirmed this run: the GLOBAL search endpoint is 403 to this seat ('sessions are bound to their configured repositories'), while repo-scoped REST reads AND writes both work.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #13631 (unassigned, unlabelled): the TEST_DEBT entry for @objectstack/formula records errors: 17 while its note itemises 16 (TS2591 x6, TS2345 x3, TS2352 x3, TS1470 x2, TS2339 x2). The unlisted 17th is TS2739 x1 at packages/formula/src/cel-to-filter.test.ts(173,52). Same failure mode AGENTS.md names for stale notes, in miniature: the next author budgeting a graduation reads 16 and finds 17. Not repaired here — the ledger is a governed shrink-only artifact and editing its note would be a rider on a security fix. Remedy is one line in the note; no count and no baseline move.",
        "no new issue needed for #13552 (isEmptyMembershipFilter polarity-blindness): read for context per the STOP condition and NOT repaired here, and the dedup search shows it has since been CLOSED independently."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions