') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) by claude[bot] · Pull Request #13630 · objectstack-ai/objectstack · GitHub
Skip to content

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) - #13630

Merged
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-13496-membership-null-fail-closed
Aug 31, 2026
Merged

fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496)#13630
zhuangjianguo merged 2 commits into
mainfrom
claude/issue-13496-membership-null-fail-closed

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#13496

Implements the maintainer's ruling of 2026-08-31 (总监席第 5 场决裁批 #1, verbatim 「同意」, option A). The ruling is quoted unchanged, per the repo's rule that a Chinese ruling keeps its original wording:

裁定:fail-closed。 membership 数组中的 null 成员触发与 null 标量同款处置——unresolved-variable / deny sentinel,⛔ 不 strip、不静默清洗。

执行要点:① 守卫落在 lowerMembership(成员级校验,非仅 Array.isArray);② 钉子必须双极性(正/负极性各一,含 not in 包裹形);③ 今日无生产者注入 null ⇒ 本修复对现网行为零可见变化,纯粹把模块自书纪律补到漏掉的形状;④ 本裁定只覆盖 fail-closed 不对称这一半。

The asymmetry this closes

compileCelToFilter already fails closed when a current_user.* variable resolves to undefined/null — the module docblock calls it "the no active org fail-closed path" and it is pinned for the SCALAR case. lowerMembership did not apply the same discipline one level in: it checked only Array.isArray(value) and emitted the list verbatim, so a null MEMBER of a resolved membership array went straight into a security $in. The one shape that IS a permission predicate was the one shape that did not fail closed.

lowerMembership now refuses a null/undefined member of a variable-resolved membership array with the same unresolved-variable reason, which plugin-security/rls-compiler.ts already turns into RLS_DENY_FILTER (if (!result.ok) return null;, line 343).

Execution point 2 — both polarities, and why it is the crux

A positive-polarity pin alone is green for both candidate repairs and therefore pins nothing about the one that was ruled on. The rejected alternative — stripping the unresolved member — is safe in POSITIVE polarity (an $in over the surviving members never grants more than those members grant) and inverts under the supported not in form: !(x in y) lowers to $not wrapping $in, and $in: [] matches nothing on every backend, so $not { $in: [] } matches the WHOLE table. Stripping is fail-OPEN exactly where the predicate is a blocklist.

Twelve pins were added. Eight assert the refusal, in both polarities:

polaritymembershippinned result
id in current_user.org_user_ids['u_me', null]unresolved-variable
id in current_user.org_user_ids[null]unresolved-variable
id in current_user.org_user_ids['u_me', undefined]unresolved-variable
!(id in current_user.org_user_ids)['u_me', null]unresolved-variable
!(id in current_user.org_user_ids)[null]unresolved-variable, not$not{$in:[]}
!(...) || owner == current_user.id[null]unresolved-variable
!(...) && owner == current_user.id['u_me', null]unresolved-variable
scalar vs positive vs negatedall three yield the identical reason

Four more are non-regression controls that must NOT move: a fully resolved list still compiles in both polarities; an empty list still compiles to $in: [] in both polarities (a legitimate declared predicate); an AUTHORED literal null in a list is untouched; isPushdownableCel is untouched.

The PM's Zone 2 assumption — tested, and it HOLDS

The expectation was that the guard would be a few lines inside lowerMembership with no polarity threading, because failing closed refuses before polarity exists. Measured true, and the mechanism is explicit: lowerCelAst wraps the whole descent in one try/catch, and lowerCondition's !_ case builds its $not only from the value its recursive call returns. A throw from lowerMembership therefore unwinds before any wrapper exists, so !, && and || all collapse to the single unresolved-variable result. The guard is 6 lines and reads no polarity. The measurement is the eight-row table above plus the "identical reason" pin.

Ablation

The implementation was committed first, then the guard body was deleted and a marker injected in its place. The mutation was proven on disk in both directions (injected marker count 1, deleted text count 0, on-disk hash a32c33da differing from the HEAD blob b87311f1) before anything was measured.

Ablated result: 8 failed, 56 passed. Exactly the eight refusal pins went red; all four non-regression controls stayed green. The sharpest reading is the identity pin, which reported ['unresolved-variable', 'ok', 'ok'] — the scalar path refusing while both membership polarities compiled, i.e. the card's asymmetry reproduced.

Restore ran under trap ... 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, and a HEAD-blob hash match: b87311f1d259d517c3342dd474d2d6830e21457a on both sides, non-empty.

No rebuild leg was needed and none is claimed: the suite imports ./cel-to-filter, a same-package relative specifier, so vitest compiles the mutated source directly rather than resolving a dependency's exports to dist/. The red result is itself the proof — a stale-dist ablation stays green.

Verification — all at final commit 1cdf3c14ef

  • pnpm --filter '@objectstack/formula^...' buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. Dependency closure built before any reading.
  • pnpm --filter @objectstack/formula testTest Files 25 passed (25), Tests 655 passed (655).
  • pnpm --filter @objectstack/formula typecheck — exit 0, script name echoed.
  • Downstream consumers of the narrowed contract (prefix filter, i.e. consumers rather than dependencies): 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.
  • Repo-wide ESLint, the whole population rather than a narrowed one: eslint . --no-inline-config --format json over 5554 files — 0 errors, 0 warnings.
  • 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"): all green except two that could not measure locally, recorded as NOT MEASURED rather than as passes — check-test-completeness (exit 3, 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; it needs a full pnpm build). Both are CI's on this PR.

check:type-check-debt — refused globally, measured narrowly

The gate refuses outright with 55 unbuilt workspace dependencies of the ledgered packages, which is a refusal to measure and not a red. Only @objectstack/formula's ledger entry can move from this diff, and formula's own closure (just @objectstack/spec) IS built — so its generated re-measure project was reproduced faithfully from the gate's own remeasureProject and run directly.

That replica is calibrated, not asserted: the first run scored 21 and itemised TS2591 x6, TS2345 x7, TS2352 x3, TS1470 x2, TS2339 x2, TS2739 x1, against a frozen ledger value of 17 — the pre-existing 17 exactly, plus 4 new TS2345 this branch had introduced. The module-level ok() helper pins its second argument to the exact shape of VARS, which the partial contexts in the new suite cannot satisfy. Repaired at the source (a locally widened helper, same assertion and same throw) rather than by touching the shrink-only baseline. Re-measured: 17, matching the ledger's frozen value.

Scope boundaries held

Clause 2 reading

Concur that clause 2 FIRES: this changes what a security pushdown refuses, so it warrants contract review at CONTRACT_REVIEW_TIER before ready. The ruling's point 3 (zero visible production change today) is material FOR the reviewer, not a reason to skip — and the reviewer's attention is best spent on one question: whether refusing at the compiler is the right altitude given the lockout it implies. A user whose membership array carries one unresolvable member now loses the whole read scope, including rows their resolved members legitimately grant, and the loss is silent at the RLS layer (the policy drops to the deny sentinel). That cost is what the ruling accepted, and it is measured to be unreachable from first-party providers today: resolve-authz-context.ts filters non-strings out of org_user_ids and the kernel spec declares org_user_ids: z.array(z.string()). The residual surface is host-supplied — compileCelToFilter's variables is a documented public option, and two call sites read org_user_ids through casts that bypass the declared type.

I read the scope of that review as packages/formula/src/cel-to-filter.ts only. The R9 report argued for widening it to cover rls-compiler.ts's isEmptyMembershipFilter; that was sound while stripping was still a live candidate, since stripping depended on that guard. With fail-closed ruled, the repair no longer leans on it at all, so it is context rather than half of this change's accept/reject surface.


Generated by Claude Code

…rray in the CEL pushdown
lowerMembership checked only Array.isArray and emitted the list verbatim, so a
null MEMBER of a resolved membership variable reached a security $in while a
null SCALAR variable took the pinned unresolved-variable fail-closed path. It now
refuses the member with the same reason, in every polarity.
Refusing rather than stripping: `not in` lowers to $not wrapping $in, and
$in: [] matches nothing, so stripping inverts into allow-all under negation.
Refusing throws before any $not wrapper is built, so no polarity threading is
needed in the lowerer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…ST_DEBT errors
The module-level ok() pins its second argument to the exact shape of VARS, so
the partial contexts this suite builds were 4 new TS2345 on a shrink-only
ledger. Same assertion, same throw, widened only where this suite needs it;
re-measured @objectstack/formula TEST_DEBT back to the frozen 17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eb64351f092071aeccfb4eb7fb8492e8d08c4362packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8560f53d5587879152caaa20ae4cfaa1d273857c — the merge of head 1cdf3c14efdfbccb7c290629d4acf984c969302a into base eb64351f092071aeccfb4eb7fb8492e8d08c4362, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8560f53d5587879152caaa20ae4cfaa1d273857c && git checkout 8560f53d5587879152caaa20ae4cfaa1d273857c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eb64351f092071aeccfb4eb7fb8492e8d08c4362 1cdf3c14efdfbccb7c290629d4acf984c969302a && git checkout -B drift-repro eb64351f092071aeccfb4eb7fb8492e8d08c4362 && git merge --no-ff 1cdf3c14efdfbccb7c290629d4acf984c969302a
node scripts/docs-audit/affected-docs.mjs --json eb64351f092071aeccfb4eb7fb8492e8d08c4362

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

条款② contract review — verdict: PASS (reviewed at head 1cdf3c14ef, scope packages/formula/src/cel-to-filter.ts; agent seat, so this is a findings comment, not an approval — the verdict goes to the dispatching PM). The fail-closed direction is the maintainer's settled ruling and was not re-litigated; what follows is whether THIS implementation's accept/reject change is what it claims. It is.

1. The refusal denies at every consumer — verified by enumeration. Repo-wide at head, compileCelToFilter has exactly three non-test callers. (a) plugin-security/rls-compiler.ts:337 — the only value-mode caller with real variables, i.e. the only place the new guard can fire in production. !result.ok → null; compileFilter then returns RLS_DENY_FILTER when no applicable policy compiled, and the sentinel (id: '__rls_deny__:…') yields zero rows on reads (AND-injected where + read-scope-sql), fails the by-id visibility match, and fails the step-3.6 post-image check (matchesFilterCondition can never match the sentinel) — reads and writes both deny. (b) plugin-sharing/bootstrap-declared-sharing-rules.ts:132 and (c) lint/validate-sharing-rule-enforceability.ts:438 both compile with variables: {}, where any current_user.* reference refuses at resolveValue before an array exists — the guard is unreachable; !ok skips the rule at seed / raises the authoring finding, never seeds match-all. Shape-mode consumers (isPushdownableCel — lint RLS gate, isSupportedRlsExpression, objectui celAuthoring) are provably inert: Array.isArray(SHAPE_VALUE) is false. No consumer swallows the refusal into an unfiltered read. One precision note: with MULTIPLE applicable policies the refused one is dropped from the OR-union rather than producing the sentinel — that narrows (deny-ward, never widens) and matches the pre-existing scalar unresolved-variable behaviour exactly; the PR prose's "turns into RLS_DENY_FILTER" is exact only in the single-applicable-policy case.

2. #13357 boundary holds, verified beyond the pin. The guard's precondition is container.kind === 'var'; an authored list classifies as literal. Empirically at head: owner in ['a', null] compiles in POSITIVE polarity, and — not pinned by the PR — !(owner in ['a', null]) compiles to $not{$in:['a',null]} too; an authored null with resolvable variables present still lowers; a variable inside an authored list stays unsupported (pre-existing). Nothing here decides what $in:[…,null] selects.

3. Unwinding claim verified in control flow and under deeper composition. One try/catch in the file (lowerCelAst:264-269); '!_' builds $not only from its recursive call's return; combine evaluates children eagerly. No enclosing construct can intercept. Attacked with shapes deeper than the pins: !!(x in y), membership buried in (A && !(in)) || B, and negation OUTSIDE a disjunction containing the membership — all collapse to the single unresolved-variable; the double-negated resolved control still compiles.

4. Narrowing is not over-broad, and the differential is exact. A 16-case attack suite at head: all pass — ''/0/false/NaN members still compile, empty list still $in:[] in both polarities, nested variable paths and custom variableRoots refuse with the right path. The same suite against the BASE compiler (eb64351f): exactly the 6 refusal-expecting cases flip (they compiled a null member into a security $in before), all 10 boundary/control cases byte-identical at both revisions. The accept-set change is precisely the claimed narrowing and nothing else. Two edge observations, both deny-ward or pre-existing: a HOLE in a sparse membership array refuses (reads as undefined — fail-closed, consistent); a NESTED array member ([['a', null]]) is not refused — top-level members only, which is the pre-existing lowering and outside the ruling's shape.

5. Diagnosability (observation only — the ruling settled the trade). The refusal's detail ("unresolved member at index N") is discarded at rls-compiler.ts:343, and the drop-warn is gated on !isSupportedRlsExpression(predicate) — TRUE for membership shapes — so nothing logs. Explain reports the sentinel with verdict narrows (its isDenyAll matches only its own __deny_all__), though record-grained attribution excludes correctly and the __rls_deny__ sentinel string is visible in readFilter to an operator who knows it. This is exactly the observability of the pre-existing scalar no-active-org path — inherited, not degraded.

Formula suite reproduced green at head (655/655, 25 files). Review worktree restored byte-identical (b87311f1…) and removed; no PR files touched.


Generated by Claude Code

@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

PM review — ACCEPT. Clause ② returned PASS. Release on the last check.

domain:engine lane PM, session_01F3jdziLbAPGeceVNmSox5L. Head 1cdf3c14ef.

Clause ② — PASS, and the attack behind it was real

The review's strongest result is the one I asked for hardest: no consumer swallows the refusal. Repo-wide enumeration found exactly three non-test callers of compileCelToFilter.

  • rls-compiler.ts:337 is the only value-mode caller with real variables — the only place this guard fires in production. !ok → null → RLS_DENY_FILTER: zero rows on reads (AND-injected, and getReadFilter's own contract says it never returns allow-all), fails the by-id visibility match, and fails the step-3.6 write post-image check. Reads and writes both deny.
  • The two other callers pass variables:{}, so the guard is unreachable there — and !ok skips the rule at seed / raises an authoring finding, never match-all.
  • Shape mode is provably inert (Array.isArray(SHAPE_VALUE) is false).

⚠️ One precision correction to the PR body, recorded here rather than pushed

The PR says the refusal "turns into RLS_DENY_FILTER". That is exact only for the single-applicable-policy case. With multiple applicable policies, a refused policy is dropped from the OR-union rather than yielding the sentinel.

Still deny-ward — a narrowing — and identical to the pre-existing scalar path, so it is not a defect and not a reason to hold. But the PR body is a durable artefact and states something slightly stronger than what happens. Correcting it here rather than spending a resume-and-push cycle on one sentence of prose.

The #13357 boundary held under a harder test than the pin

The pin asserts an authored literal null is untouched. The reviewer went further and constructed the un-pinned negated authored form!(owner in ['a', null])$not{$in:['a',null]}, unchanged — plus authored-null-with-resolvable-variables and variable-inside-authored-list. The guard's precondition is container.kind === 'var' and authored lists classify literal, so the boundary is structural, not incidental. Nothing here decides anything for #13357.

The unwinding claim verified, and attacked past the pins

Single try/catch at lowerCelAst:264-269; '!_' builds $not only from the recursive return (:302); combine evaluates eagerly; nothing intercepts. Attacked with !!(x in y), membership buried in (A && !(in)) || B, and negation outside a disjunction — all collapse to one unresolved-variable, with a double-negated resolved control still compiling.

Not over-broad — the differential is exact

16-case suite: all pass at head; at base eb64351fexactly the 6 refusal-expecting cases flip, and all 10 boundary/controls are identical at both revisions. '' / 0 / false / NaN members still compile; empty list → $in:[] in both polarities. Two edges recorded: a sparse-array hole refuses (deny-ward, consistent), and a nested member [['a', null]] is not refused — top-level only, pre-existing lowering, outside the ruling's shape.

Observation 5 — the lockout is silent, and it is INHERITED

The detail ("unresolved member at index N") is discarded at rls-compiler.ts:343, and the drop-warn is gated on !isSupportedRlsExpression, which is true for membership shapes — so nothing logs. Explain reports the sentinel as narrows rather than denies (its isDenyAll matches only its own __deny_all__), though record attribution excludes correctly and __rls_deny__ is visible in readFilter.

⭐ This is exactly the observability of the pre-existing scalar no-active-org path — inherited, not degraded by this PR, and therefore not a reason to hold a ruling implementation. I am filing it separately so it is not lost: a security denial that logs nothing and is misreported by explain is a real operator problem, and it now has a precise measurement.

Release

CI: 28 success, 2 skipped, 0 red, one check still running. ⛔ Bar is every check green. On full green: ready + auto-merge, queue merges. ⛔ Never a hand-merge.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review August 31, 2026 04:28
@zhuangjianguo
zhuangjianguo added this pull request to the merge queueAug 31, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 33357337261 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (6/6) — 失败步骤: Run this shard's tests

    @objectstack/core:test: FAIL src/security/authz-store-unavailable.test.ts > [#13279] every transport that authorizes through resolveAuthzContext > CONTROL: the scanner finds transports at all, and f
    ↳ 失败原因: @objectstack/core:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/security/authz-store-unavailable.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 4 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@zhuangjianguoClaude

Copy link
Copy Markdown
Collaborator

Dequeued by a failure that is not this PR's — standing down, once

This PR was dequeued from the merge queue at 04:50:50Z with reason: CI_FAILURE. Naming the check and the cause rather than re-running:

Failing check:Test Core (6/6), in merge_group run 33357337261 on the pr-13630 queue branch (job 99381928726).

FAIL packages/core/src/security/authz-store-unavailable.test.ts
> [#13279] every transport that authorizes through resolveAuthzContext
> CONTROL: the scanner finds transports at all, and finds THIS repo
Error: Test timed out in 5000ms.
❯ src/security/authz-store-unavailable.test.ts:214:3

Test Files 1 failed | 45 passed (46) · Tests 1 failed | 1134 passed (1135). The shard then aborted, so 11 of 12 scheduled packages never rancheck-test-completeness reported each one "scheduled but never reached". The other three queue checks (Governed Surface Guard, Spec Liveness Check, Lint & Type Check) all succeeded.

Why it is not this PR's

This PR changes three files.changeset/cel-pushdown-membership-null-member-fail-closed.md, packages/formula/src/cel-to-filter.ts, packages/formula/src/cel-to-filter.test.ts. It does not touch packages/core at all, and nothing in packages/formula is imported by the failing test.

The cause is #13645: authz-store-unavailable.test.ts walks the entire packages/ tree and synchronously reads every .ts file into memory, twice per run (once in this CONTROL test, once in the SET-EQUALITY test), with no caching, under vitest's default 5000 ms timeout. The cost is O(size of packages/) paid twice against a fixed budget, so any PR that adds files to the monorepo can trip it and the blame lands on whoever happens to be pushing.

⛔ Not calling this a flake, and not spending the re-run

When #13645 was filed, "reproduces identically" was recorded as UNMEASURED — the sanctioned re-run had returned 403 while sibling jobs were still in flight. It is measured now, by a stronger route than a re-run: the identical failure, at the identical line, occurred on two independent PRs with disjoint diffs — this one (04:33:56Z, packages/formula only) and #13635 (04:44:01Z, packages/objectql + docs + a gate script). Neither touches packages/core.

That also rules out a re-run as the remedy. The failure mode is monotonic, not random — it gets worse with every file added to the repo and there is no random component to re-roll — so a re-run would re-fail and cost a cycle. The one sanctioned re-run stays unspent on this PR.

What happens next

No fix for #13645 existed at the time of this comment, so there is nothing to port into this PR yet. One is now dispatched, against #13645, as its own change in packages/core — ⛔ deliberately not widened into this PR, whose scope is the CEL pushdown guard.

This PR is otherwise mergeable_state: clean at 1cdf3c14ef and needs no code change. It will be re-queued once #13645 lands on main; re-queueing before that would only re-dequeue it on the same timeout. Still watched.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@zhuangjianguo@claude