You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
read-scope-sql's emptied-membership folds are polarity-dependent at the lowering site itself: $in: [] folds to 1 = 0 one arm from $not, and $nin: [] folds to 1 = 1 (constant TRUE) #13571
Filed unassigned by the #13552 dev (PR #13570) while measuring that card's PM mechanism assumption 1 ("does fixing only the guard leave the analytics lowering path independently reachable?"). Recording only — severity and routing are triage's. #13552 is not resolved by this card and this card does not wait on it.
One case below, $nin with an empty array folds directly to 1 = 1 ("NOT IN () excludes nothing") — constant TRUE with no negation needed at all.
Why it did NOT block #13552, and what keeps it from biting today
In-repo, the scope input to this compiler is StrategyContext.getReadScope, wired through security.getReadFilter — i.e. the RLS compiler's output. After #13552 (PR #13570) the RLS path drops any policy whose compiled filter leans on an emptied membership at inverted polarity, so neither shape reaches this lowering through RLS. The CEL pushdown compiler also never emits $nin.
Why it is still worth a look
StrategyContext.getReadScope is a spec contract (packages/spec/src/contracts/analytics-service.ts), fillable by any provider; its own doc shows a hand-written example. A non-RLS provider handing this compiler { $not: { f: { $in: [] } } } or { f: { $nin: [] } } gets a scope clause that is TRUE for every row — on the READ-SCOPE lowering, where a wrong answer is ADR-0021 scope over-reach, not a loose chart filter.
The module's stated posture is fail-closed ("it throws rather than drop a predicate"). Folding an emptied membership to constant TRUE is the one spot where the emptied case widens instead of throwing or narrowing.
Possible dispositions for triage to weigh: treat an emptied membership at widening polarity as a compile refusal (matches the module's fail-closed posture); or fold it null-safely and document the invariant that only fail-closed producers may feed this compiler; or explicitly declare the producer contract (RLS-guard-filtered input only) and pin it.
Related
#13552 (the guard-side repair; PR #13570) · #5297 (closed — $not null-safety and $not: {} at this same site) · #5298 (no-value semantics) · ADR-0021 (read-scope contract) · ADR-0055 / ADR-0058 (pushdown contract)
✅ read-scope-sql.ts:25-26 逐字:"Any operator, value shape, or identifier it cannot translate THROWS. A read-scope predicate must never be silently dropped"
Claim: PM loop round 8
Session: session_016ZC5rNQj3WEet5HAmmAkMs
Branch: claude/issue-13571-read-scope-empty-fold-refusal
Worktree: objectstack-issue-13571
Domain: domain:services
File surface: packages/services/service-analytics/src/read-scope-sql.ts + its tests, one changeset (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: claude-fable-5 — ⛔ not the tier floor, and ⛔ not because of clause ②. Same reasoning that was applied to the sibling #13552: the correctness of this repair is an exhaustive enumeration (triage's 必答项 — is there a third fold-to-constant site?), and an incomplete enumeration ships a hole that looks closed and reds nothing. That is the expensive-to-get-wrong criterion, on a read-scope surface.
Clause-②: no — and stated with its reasoning because the call is not obvious. This changes runtime compile behaviour (an input that silently folded now throws), ⛔ not a published type or shape. Per the 2026-08-28 negative boundary, runtime security behaviour is not clause ②; and the lane red line is loosening, while this direction is tightening. ⚠️ Identical shape to #13552, which shipped Clause-②: no and merged clean as 09b0d7b. If your diff ends up altering a published type in packages/spec, re-declare yes and STOP — that would be a different card at a different tier.
Serial constraints cleared: service-analytics is untouched by every live branch. ✅ The sibling #13552 / PR #13570 MERGED as 09b0d7b — verified by content on origin/main, ⛔ not by the branch being gone. ⚠️ Triage read #13552 as still pm:queue at 02:19Z; that reading predates the merge. Either way ⛔ this card is not blocked on it and must not be marked so. ⛔ Held elsewhere: plugin-security/src/security-plugin.ts (PR #13514), service-messaging/** + plugin-webhooks/** + service-automation/builtin/http-nodes.ts (PR #13565).
分诊已收敛三选项 —— 方向不需要设计,⛔ 不可重议
The card offered three dispositions. Triage settled it, and with an argument sharper than the card's own. Measured, verbatim from the grading:
923: if (!Array.isArray(val)) throw readScopeCompileError(`… $in … needs an array (fail-closed).`);
924: if (val.length === 0) return FALSE_CLAUSE; // ← folds to a constant
929: if (!Array.isArray(val)) throw readScopeCompileError(`… $nin … needs an array (fail-closed).`);
930: if (val.length === 0) return '1 = 1'; // ← folds to constant TRUE
⇒ ⭐ The same operator arm throws for a wrong shape and silently folds an empty array to a constant, one line apart. That is not a missing discipline — it is abandoning, in the same place, the discipline it just enforced. And the module's own header says so at :25-26: "Any operator, value shape, or identifier it cannot translate THROWS. A read-scope predicate must never be silently dropped."
⇒ Adopt disposition 1: make :924 and :930 throw, following the in-repo precedent two lines above. ⛔ Do not re-open the three-option question; ⛔ do not invent a fourth.
⚠️Re-locate by symbol, ⛔ not by line. Those numbers are from 02:19Z and main has advanced several commits tonight.
⛔ Triage's mandatory scope — these ARE your acceptance criteria
两处一起改. ⛔ Not just the worse $nin one. Repairing one leaves the other advertising a discipline it does not keep.
非空控制 — construct a NON-RLS getReadScope provider (the spec contract permits it; its doc carries a hand-written example), feed it both shapes, and prove pre-fix = whole table, post-fix = refused. ⛔ A regression that only exercises the RLS path tests a route that, after fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570, cannot reach this compiler at all — i.e. it tests nothing.
Why this is worth doing although it "cannot bite today"
The card is honest that in-repo the only scope producer is the RLS compiler, and #13570 now stops both shapes upstream. ⛔ That is not a reason to hold, for the reason triage gives:
⚠️The $nin half needs no negation at all — it is a direct constant TRUE. #13552's shape required a $not wrapper to become allow-all; this one does not. So the entire protection today is "no in-repo producer emits $nin" — a vacancy with no mechanical guard standing over it, and thinner than the compile guard #13570 just installed. StrategyContext.getReadScope is a spec contract any provider may fill.
⇒ On a read-scope lowering, a wrong answer is ADR-0021 scope over-reach, ⛔ not a loose chart filter.
PM mechanism assumptions — measure these; I would rather be falsified
That throwing is safe at this site. The module's declared posture says throw, but a refusal that fires on a shape some live provider legitimately produces would be an availability regression rather than a security fix. Check who actually calls this compiler and say what you find; if any in-repo producer can emit an empty $in/$nin today, ⛔ stop and report — that changes the repair from "restore the discipline" to "restore it and migrate a caller".
That the two fixes are genuinely symmetric.$in: [] → 1 = 0 is correct in isolation and only wrong under inversion; $nin: [] → 1 = 1 is wrong on its own. If the honest repair differs between them (e.g. one throws, one stays but gains a null-safe form), say so with the measurement rather than making them uniform for tidiness.
That no pin asserts the current folding. If a test asserts 1 = 1 or FALSE_CLAUSE from an empty array, ⛔ do not edit it to stay green — stop and report; that pin is either wrong or is telling you the fold was deliberate.
Gates
Re-derive from your worktree at origin/main and your actual diff: node scripts/pm/dispatch-gates.mjs <changed paths>. ⚠️main moved several commits tonight (09b0d7b, d475838, …) — derive from the tree, ⛔ never from a remembered family list.
Report
Terminal report as a comment on this issue first, then as your return message. Machine-checkable fields only; ⛔ do not restate the PR body's narrative. The enumeration answer (必答项 2) is a required field of your report — the next card in this family will be cut from it.
Dev claim: os-dev subagent, PM loop round 8 (dispatching seat session session_016ZC5rNQj3WEet5HAmmAkMs, domain:services).
Branch: claude/issue-13571-read-scope-empty-fold-refusal (pushed)
Worktree: objectstack-issue-13571, base eb64351
File surface: packages/services/service-analytics/src/read-scope-sql.ts + its tests + one changeset. Adopting triage disposition 1 (compile refusal at both empty-membership fold sites, following the fail-closed precedent on the adjacent shape-check lines). Will deliver: both sites throwing, enumeration answer for the third-site 必答项, and a non-RLS getReadScope provider control proving pre-fix whole-table vs post-fix refusal.
{
"issue": 13571,
"status": "needs_decision",
"branch": "claude/issue-13571-read-scope-empty-fold-refusal",
"pr": null,
"premise_still_valid": true,
"summary": "The card's facts all hold (both folds present at compileOperator, header declares fail-closed), but all three PM mechanism assumptions are falsified by measurement, and assumptions 1 and 3 carry the dispatch's own stop-and-report instruction. Decisive: an in-repo producer emits a positive empty `$in` TODAY — RLSCompiler.compileFilter returns `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` for an emptied membership set beside an own-rows grant, pinned by PR #13570 (merged 2026-08-31, test name 'emptied POSITIVE membership as an $or arm stays inert — own rows keep flowing', rls-empty-membership-polarity.test.ts:107-112), and that filter flows through security.getReadFilter into compileScopedFilterToSql — so the settled uniform throw converts a ruled-correct live scope into a 500 on every analytics query for such users. Four pins assert the current folds (one explicitly 'not a refusal'), and this module's own #5322 section records that a 2026-08-04 maintainer ruling MOVED this compiler from refusing empty arrays fail-closed to reducing them, because a rejection cannot answer what an identity means as a branch of a composite — the exact shape RLS emits. The honest repair is asymmetric (`$nin: []` throw is safe and right; unconditional `$in: []` throw is an availability regression), which re-opens the disposition: options with a four-axes recommendation are in open_questions.",
"enumeration": "compileOperator on eb64351, arm by arm: `$eq` — null lowers to IS NULL (defined value semantics; undefined comparands refused upstream, #6125), no fold; `$ne` — null lowers to IS NOT NULL, else null-safe compare, no fold; `$gt`/`$gte`/`$lt`/`$lte` — direct bind, no fold; `$in` — FOLD SITE 1, length 0 returns FALSE_CLAUSE `1 = 0` (constant FALSE, widens only under inversion); `$nin` — FOLD SITE 2, length 0 returns `1 = 1` (constant TRUE, widens on its own); `$between` — `[]` fails the length-must-be-2 arity check and THROWS, no fold; `$contains`/`$icontains`/`$notContains`/`$startsWith`/`$endsWith` — assertRenderableText then a BOUND LIKE pattern; nearest miss considered and distinguished: an empty-string comparand lowers to a `%%` pattern (matches every non-NULL row), but that is the operator's honest empty-needle containment semantics, identical on every backend, a bound parameter rather than a constant fold, and it NARROWS under `$not` — not a third site; `$null`/`$exists` — boolean domain enforced upstream (#6387), two-way IS NULL / IS NOT NULL, no fold; default arm THROWS. VERDICT: no third fold-to-constant site inside compileOperator — the only constant returns in the function are the two named sites (grep confirms: FALSE_CLAUSE at the `$in` arm, `1 = 1` at the `$nin` arm, nowhere else). Outside compileOperator, compileNode folds `$and: []` to TRUE, `$or: []` to FALSE_CLAUSE, `$not: {}` to FALSE_CLAUSE — the #5322-ruled boolean identities, pinned, outside the question's scope but listed so this enumeration is checkable.",
"gates": "none run — zero diff; dispatch-gates.mjs derives from the merge-base changed set, which is empty. No gate family is owed by an empty changeset.",
"line_budget": "n/a — zero diff; no skills/** touched.",
"files_changed": [],
"deviations": [
"No draft PR delivered: dispatch mechanism assumptions 1 and 3 each carry an explicit stop-and-report instruction and both are falsified by measurement; triage acceptance item 1 (fix BOTH sites together) is unimplementable without either the measured availability regression or an out-of-surface caller migration (Option C).",
"Triage item 3's non-RLS provider control was not authored as a new test — no fix shipped, so there is no post-fix behaviour to regress; the pre-fix whole-table half is cited from existing in-repo pins (see tests).",
"Read plugin-security (rls-compiler.ts and its polarity tests) and driver-sql read-only, outside the declared file surface — these are the measurements assumption 1 itself ordered ('check who actually calls this compiler'); zero edits anywhere, in any repo."
],
"tests": "No suites run — zero diff, nothing to verify. Evidence is source and pin citations on origin/main eb64351, all read this session: read-scope-sql.ts compileOperator arms; rls-compiler.ts compileExpression + isEmptyMembershipFilter (guard doc: an emptied POSITIVE membership nested in a composite deliberately does NOT fire — 'must keep granting own rows'); rls-empty-membership-polarity.test.ts:107-112 pins the full compileFilter output `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` with admitted = own row; comparand-shape-refusal.test.ts:270-275 pins 'an empty $in / $nin still lowers to its boolean constant, not a refusal'; read-scope-not-null-safe.test.ts:350-353 pins `$not` over `$in: []` admitting ALL rows on real rows through this compiler (the card's exact widening, pinned as #5146-family conformance) and `$not` over `$nin: []` admitting none; read-scope-sql.test.ts:27-29 pins 'empty $in — 1 = 0 (fail-safe)'; read-scope-refusal-envelope.test.ts:246-250 lists the empty `$in` fold in its ACCEPTED (non-refusing) table citing #5243. For the #13640 finding: objectql-strategy.ts:601-602 merges getReadScope output into the engine filter; sql-driver.ts `$nin` arm (~:13314) lowers `$nin: []` as null-safe whereNotIn on an empty list (constant TRUE); filter-normalizer-not-null-safe.test.ts:534 pins `$nin: []` admits ALL on a live backend. The 非空控制's pre-fix half (whole table at this compiler) is therefore already pinned in-repo; no post-fix half exists because no fix shipped.",
"mcp_calls": "7 — issue_read get, issue_read get_comments, claim comment, search_issues (dedup for the finding), issue_write create #13640, this report comment, report read-back",
"open_questions": [
{
"question": "Which disposition for the two empty-membership folds, given the falsifying measurements? Triage settled 'make both sites throw', but: (1) a live RLS shape pinned yesterday depends on the positive `$in: []` fold reaching this compiler; (2) no in-repo producer can emit `$nin: []` (the CEL lowering never emits `$nin`; the #13570 guard defensively drops even-polarity empty-`$nin` policies); (3) four pins assert the current folds; (4) this file's own history (#5322 section) records a maintainer ruling moving it FROM fail-closed refusal of empty arrays TO the reduction, for composability — the line between the shape-check throw and the identity fold at :923/:924 is the line that ruling drew, not an abandoned discipline.",
"options": [
"A — asymmetric refusal: `$nin: []` throws (constant TRUE on its own is exactly the header's 'silently dropped predicate'; zero live producers, so zero availability cost); `$in: []` keeps folding to `1 = 0` (ruled #5243/#5322 identity, correct at its own arm, load-bearing for the pinned live RLS composite). The two `$nin` pins are updated deliberately as part of the ruling. Honest residue: `$not` over `$in: []` still compiles to constant TRUE here for non-RLS providers.",
"B — polarity-aware refusal, the card's original disposition-1 wording (refuse an emptied membership at WIDENING effective polarity): closes both non-RLS shapes at this lowering while keeping the live positive composite compiling. Cost: re-implements #13570's polarity walk at the lowering site (two polarity engines to keep in sync), breaks the `$not`-composition conformance pins, and needs its interaction with the #5322 `$not`-over-identity reductions designed — a naive 'refuse NOT of the FALSE constant' cannot tell net polarity.",
"C — triage's uniform throw at both sites plus a caller migration: rls-compiler must eliminate inert positive `$in: []` arms from composites before returning, re-opening behaviour PR #13570 pinned yesterday, touching packages/plugins/plugin-security outside this card's declared surface, and breaking four pins.",
"D — the card's disposition 3: document and pin the producer contract (fail-closed producers only), change no lowering; leaves the vacancy standing."
],
"recommendation": "A, argued on the four axes. Real business need: the only live producer is RLS, whose measured emitted-shape set requires the positive `$in: []` fold and never contains `$nin: []` — A costs zero live traffic and closes the only single-step constant-TRUE fold. Long-term soundness: shapes with a ruled cross-backend translation (constant FALSE) keep it; the one shape whose faithful translation vacates the whole scope is refused; polarity analysis stays owned by the producer-side guard (#13570) where policy context lives, instead of being duplicated. Making-AI-code-hard-to-get-wrong: an AI-authored provider handing `$nin: []` gets a loud 500 at compile instead of the whole table; the `$not`-inversion residue is surfaced as option B rather than left looking closed. Startup scope discipline: a one-line throw plus two deliberate pin updates, no cross-package blast radius into yesterday's merge. If the maintainer wants the inversion residue closed at this lowering too, that is A now plus B as a follow-up ruled design — not a silent extension of this card. Either way #13640 stands: the ObjectQL engine execution path bypasses this compiler entirely, so no disposition of this card guards that route."
}
],
"out_of_scope_findings": [
"filed as #13640: a non-RLS getReadScope scope bypasses read-scope-sql on the ObjectQL engine execution path — driver-sql lowers `$nin: []` to constant TRUE (whole table); no disposition of #13571 can guard that route"
]
}
✅ ACCEPT the stop — and ⚖️ the disposition is RULED A (asymmetric), against the direction stated in the triage grading
domain:services PM seat, session session_016ZC5rNQj3WEet5HAmmAkMs, R8. Reviewer of record. premise_still_valid: true, pr: null, zero diff — and that is the correct delivery. Both of the dispatch's stop-and-report triggers fired, and the dev honoured them instead of shipping the settled direction.
The falsification — re-measured by this seat on origin/main, ⛔ not taken from the report
1. The line triage called an abandoned discipline is where a maintainer ruling deliberately drew it.read-scope-sql.ts:370-378, verbatim:
"Until that ruling this compiler REFUSED both ("requires a non-empty array (fail-closed)"), while the five FILTER_LOGIC_CASES backends reduced them; #5322 took the reduction: it is the only reading that lets a nested tree be evaluated at all (a rejection cannot answer what $and: [] means as the third branch of a $or)"
And the module header at :58-69 names the date: the 2026-08-04 #5322 ruling moved this file from fail-closed refusal to reduction.
⇒ ⭐ Triage's argument was 「同一个算子臂,一行之隔,放弃了自己刚执行过的纪律」. Measured, it is the reverse: the boundary between shape errors throw and boolean identities reduce is a ruled boundary with a stated reason. ⛔ The premise that "the fix direction needs no design" does not survive.
2. An in-repo producer emits the positive empty $in TODAY — and yesterday's merge pinned it as correct.rls-empty-membership-polarity.test.ts:107, from PR #13570 (merged 09b0d7b):
it('emptied POSITIVE membership as an `$or` arm stays inert — own rows keep flowing',…expect(filter).toEqual({$or: [{owner: {$in: []}},{owner: 'u_me'}]});expect(admitted(filter)).toBe(1);// own row only
That filter reaches this compiler through security.getReadFilter. ⇒ A uniform throw converts a ruled-correct, deliberately inert live scope into a 500 on every analytics query for any user whose membership set is empty beside an own-rows grant. That is an availability regression, ⛔ not a security fix.
3. A pin asserts the current fold explicitly.comparand-shape-refusal.test.ts:269: "an empty $in / $nin still lowers to its boolean constant, not a refusal".
⇒ ⭐ This is precisely why the dispatch put "check who actually calls this compiler" and "no pin asserts the current behaviour" in as stop-and-report assumptions. Both fired. Had the settled direction been implemented, it would have reddened four pins and broken a scope that merged hours earlier.
⛔ On the disagreement with triage — recorded, not papered over
⛔ No pm:retriage, and deliberately: the routing comment delegated this explicitly — 「三个处置选项已在卡上,归 services 车道裁」. The grading (pm:queue · p2 · security · domain:services) is correct and undisputed. What is falsified is the disposition opinion the grading also expressed — and that call was this lane's to make. So this seat rules it rather than bouncing the card.
⚠️ Recording the shape for the record, because it is instructive and not a reproach: triage's argument was sharper than the card's own and still wrong, because it was an argument from adjacency in the source (two lines apart) rather than from history (why they differ). The two lines look like an inconsistency and are in fact a ruling.
⚖️ RULED — Option A, asymmetric refusal (domain:services lane, this seat)
$nin: [] → THROW. Constant TRUE on its own, no negation needed — exactly the header's "a read-scope predicate must never be silently dropped". Zero live producers: the CEL lowering never emits $nin, and fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570's guard defensively drops even-polarity empty-$nin policies. ⇒ Zero availability cost.
The two $nin pins are updated deliberately, as part of this ruling, ⛔ not edited to stay green.
Four axes, all pointing one way, no split: business need — the only live producer is RLS, whose measured emitted-shape set requires the positive fold and never contains $nin: []; long-term soundness — polarity analysis stays owned by the producer-side guard (#13570) where policy context lives, instead of a second polarity engine here to keep in sync; hard for AI to get wrong — an AI-authored provider handing $nin: [] gets a loud 500 at compile instead of the whole table; startup scope — one throw plus two deliberate pin updates, ⛔ no blast radius back into yesterday's merge.
⚠️Declared residue, ⛔ not hidden: after A, $not over $in: [] still compiles to constant TRUE at this lowering for a non-RLS provider. Closing that is option B (polarity-aware refusal), which needs its interaction with the #5322$not-over-identity reductions designed — ⛔ a naive "refuse NOT of the FALSE constant" cannot tell net polarity. That is a follow-up ruled design, ⛔ not a silent extension of this card.
⭐ The enumeration was delivered, and it is the card's most reusable output
必答项 2 answered arm by arm over compileOperator, with the near-miss considered and distinguished ($contains with an empty needle lowers to a bound %% pattern — honest containment semantics, a bound parameter rather than a constant fold, and it narrows under $not, so ⛔ not a third site). Verdict: no third fold-to-constant site. The three compileNode identities ($and: [], $or: [], $not: {}) are listed as out-of-scope-but-checkable, which is what makes the enumeration auditable rather than asserted.
Filed by the dev: the ObjectQL engine execution path bypasses this compiler entirely — driver-sql lowers $nin: [] as a null-safe whereNotIn on an empty list (constant TRUE), pinned at filter-normalizer-not-null-safe.test.ts:534. ⇒ No disposition of #13571 guards that route. ⛔ Not folded in here; it is a different lowering in a different package.
⇒ Re-dispatching option A now. Card stays pm:dispatched.
{
"issue": 13571,
"status": "done",
"branch": "claude/issue-13571-read-scope-empty-fold-refusal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/13649",
"premise_still_valid": true,
"summary": "Option A (asymmetric refusal) implemented per the domain:services verdict (13571#issuecomment-5473783095). `$nin: []` now throws in compileOperator in the module's one envelope (READ_SCOPE_COMPILE_FAILED / 500), matching the arity throw above it; `$in: []` keeps its ruled #5322/#5243 constant-FALSE fold, untouched. The module header gained a #13571 section and every updated pin carries the why (shape errors throw, boolean identities reduce; a reduction to scope-vacating constant TRUE sits on the throw side), so the asymmetry cannot read as an oversight. New controls: a non-RLS getReadScope provider (spec contract filled by hand) handing `{ owner: { $nin: [] } }` — post-fix refused with the envelope, pre-fix MEASURED admitting the whole fixture table; and the over-denial control pinning that the #13570 composite `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` still compiles and still admits exactly the own row (plus the denies-by-itself `$and` composite still returning zero rows). No plugin-security file touched; #13640 not folded in; option B not attempted — the `$not`-over-`$in:[]` residue stays declared, with the header pointing at the verdict.",
"enumeration": "Re-affirmed on the fixed tree (1880b33), arm by arm over compileOperator: `$eq`/`$ne` (null has defined IS NULL / IS NOT NULL semantics; undefined refused upstream #6125) — no fold; `$gt`/`$gte`/`$lt`/`$lte` — direct bind, no fold; `$in` — the ONE remaining empty-input fold, length 0 returns FALSE_CLAUSE `1 = 0` (constant FALSE, narrowing at its own arm; ruled to STAY, load-bearing for the #13570 composite); `$nin` — length 0 now THROWS (was fold site 2, closed by this PR); `$between` — `[]` fails the arity check and throws; the LIKE family (`$contains`/`$icontains`/`$notContains`/`$startsWith`/`$endsWith`) — bound patterns, the empty-needle `%%` case is honest containment semantics (a bound parameter, narrowing under `$not`), not a constant fold; `$null`/`$exists` — boolean-gated upstream #6387, two-way IS NULL/IS NOT NULL; default — throws. VERDICT unchanged from the decision round: no third fold-to-constant site existed; after this PR exactly one empty-input fold remains (`$in: []`, ruled) and zero widening ones. compileNode's `$and: []`/`$or: []`/`$not: {}` identities (#5322, pinned) are outside compileOperator and untouched.",
"gates": "Derived on the actual diff at 1880b33 with `node scripts/pm/dispatch-gates.mjs` (both sections read whole; harvested via --commands): 34 families + check:nul-bytes. All measured green: 33 scans green in batch (per-command redirect-then-capture); after the lint.yml-shaped workspace build (turbo 70/70 under the verify lock), check:type-check-debt green (its own judgment line: 'check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured ... none above its recorded number') and check:dual-build-cjs-loads green (measured pass, provenance floors). pnpm lint (repo-wide, CI-owned) run whole: exit 0. NOT MEASURED per its own exit-3 branch: check-test-completeness (reads a saved turbo test log only CI produces); not a red.",
"line_budget": "n/a — no skills/** touched.",
"files_changed": [
".changeset/read-scope-empty-nin-refusal.md",
"packages/services/service-analytics/src/read-scope-sql.ts",
"packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts",
"packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts",
"packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts",
"packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts"
],
"deviations": [
"check-test-completeness recorded NOT MEASURED on its own instruction (exit 3 = prerequisite, needs CI's turbo run test log) — the only family not measured locally.",
"pnpm lint was run whole rather than narrowed — it fit the foreground window (exit 0), so no narrowing proof was needed.",
"Clause-② stays no, re-checked on the final diff: nothing under packages/spec was touched (files_changed is the whole surface)."
],
"tests": "All on 1880b33, clean worktree, verdicts quoted from the runs: package suite `Test Files 84 passed (84) · Tests 1819 passed (1819)` (os-verify-lock VERDICT command-exit 0); the four touched files re-run --reporter=verbose: 108/108 with each new/updated pin confirmed by name (provider refusal, OVER-DENIAL CONTROL, denies-by-itself composite, envelope row ⑬, ledger 15/13, both comparand-shape pins, the not-null-safe pin); package typecheck exit 0 (tsconfig includes src whole — test files inside the program). Reverse verification, direction predicted first: fix committed, read-scope-sql.ts swapped to the e238c79 blob, mutation proven on disk (grep: 0 hits new refusal text, 1 hit old fold), suites re-run → 7 red / 101 green, all reds keyed to the new refusal; the provider control's real failure output was `AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined` — the pre-fix whole-table admission, which is triage item 3's pre-fix half measured. Over-denial controls stayed green pre-fix, as predicted. Restoration proven by bytes: git diff HEAD empty and worktree blob hash equal to the HEAD blob (79641a7...). No dist ablation preflight owed: the measured suites import ../read-scope-sql.js relative source through vitest, no exports-resolved dist on the path. The new refusal message measured against looksLikeInternalErrorLeak: false.",
"mcp_calls": "12 — full run: issue_read x3 (body, comments, verdict), claim comment, search_issues dedup, issue_write create #13640, decision-round report comment + read-back, create_pull_request, pull_request_read (body read-back), this report comment, its read-back",
"open_questions": [],
"out_of_scope_findings": [
"filed as #13640 (decision round, stands unchanged): a non-RLS getReadScope scope bypasses read-scope-sql on the ObjectQL engine execution path — driver-sql lowers `$nin: []` to constant TRUE; no disposition of #13571 guards that route"
]
}
✅ ACCEPT — option A implemented, PR #13649. ⏳ Ready-flip held until CI converges
domain:services PM seat, session session_016ZC5rNQj3WEet5HAmmAkMs, R8. Reviewer of record. Verified against the tree, ⛔ not against the report.
The two things I checked hardest, both verified by diff
1. $in: [] is untouched. The only behavioural line in read-scope-sql.ts that changed is the $nin arm:
- if (val.length === 0) return '1 = 1'; // NOT IN () excludes nothing+ // `$not` needed. Deliberately ASYMMETRIC with `$in: []` → FALSE_CLAUSE+ if (val.length === 0) throw readScopeCompileError(`… $nin for "${field}" is empty — an empty+ exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
⇒ The ruled #5322/#5243 identity survives byte-for-byte, and the asymmetry is documented at the site itself ("Deliberately ASYMMETRIC with $in: [] → FALSE_CLAUSE"). ⭐ The refusal message states the consequence — "would compile the read scope to constant TRUE" — rather than just naming the rule, so an operator who hits it learns why.
2. The over-denial control is a named pin, and its file explains why the alternative was rejected.read-scope-empty-nin-refusal.test.ts:150:
it('OVER-DENIAL CONTROL: the #13570-pinned RLS composite still compiles and still admits exactly the own row',…)
and the file header at :28-36:
"A uniform throw at both arms fails this block, which is the availability regression the #13571 verdict rejected."
⇒ ⭐ This is the part that makes the ruling durable rather than a one-time decision. A future reader who sees two adjacent arms behaving differently — exactly the shape that misled the triage grading — now finds a pin that goes red and a comment that says why. The failure mode this card was born from cannot recur silently.
Checklist
item
verdict
Scope
✅ 6 files, all service-analytics + one changeset. ⛔ Zero plugin-security, zero packages/spec
Ruling adherence
✅ Option A exactly: $nin throws, $in untouched, ⛔ option B not attempted, ⛔ #13640 not folded in
Clause ②
✅ no, re-checked against the final diff — nothing under packages/spec
Pin updates
✅ The four touched pins were updated deliberately, each carrying the why (shape errors throw, boolean identities reduce, and a reduction to a scope-vacating constant TRUE sits on the throw side). ⛔ Not edited to stay green
CI
⏳ pending
Triage item 3 is now fully discharged — including the half that was impossible last round
Last round the non-RLS provider control could not be authored: there was no post-fix behaviour to compare against. It exists now, and both halves are measured:
post-fix: a hand-filled non-RLS getReadScope provider handing { owner: { $nin: [] } } is refused with the envelope (error class + code asserted, ⛔ not merely "it threw"), and the message was checked against looksLikeInternalErrorLeak → false.
pre-fix: the reverse run's real failure output was AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined — ⇒ the whole fixture table admitted, which is triage item 3's pre-fix half, measured rather than asserted.
⭐ And the detail that makes the ablation trustworthy: the over-denial controls stayed green pre-fix, as predicted. 7 red / 101 green, every red keyed to the new refusal. A fix whose over-denial controls are insensitive to it is a fix that is provably narrow.
The enumeration, re-affirmed on the fixed tree
After this PR: exactly one empty-input fold remains ($in: [], ruled to stay) and zero widening ones. The near-miss is still distinguished rather than swept in — the LIKE family's empty-needle %% is a bound parameter with honest containment semantics that narrows under $not, ⛔ not a constant fold.
⇒ Landing and residue
Path face clean, no clause-② limb, no governed surface. This seat will flip ready and enqueue once every check is green — ⛔ not the required subset.
⚠️ Two things stay open and are declared, not hidden:
the $not-over-$in: [] residue at this lowering for non-RLS providers — option B, a follow-up ruled design, ⛔ not a silent extension;
Activity
zhuangjianguo commentedon Aug 31, 2026
路由(skills 席代分诊,维护者 2026-08-31 指令):
domain:services·security·priority:p2—— 落点packages/services/service-analytics/src/read-scope-sql.ts的下沉层;#13570 之后仓内不可达(p2 依据),但getReadScope是 spec 契约、外部 provider 可直喂,极性反转把清空成员集折成恒真是 ADR-0021 scope 越权形 ⇒ 加security。三个处置选项已在卡上,归 services 车道裁。Generated by Claude Code
claude commentedon Aug 31, 2026
分诊判级 ·
finding→pm:queue·security· p2 维持 ·domain:services维持三条主张逐条实测,全部成立 —— 且其中一条比卡自己写得更锋利
read-scope-sql.ts:25-26逐字:"Any operator, value shape, or identifier it cannot translate THROWS. A read-scope predicate must never be silently dropped"$in: []折成1 = 0:924if (val.length === 0) return FALSE_CLAUSE; // IN () matches nothing — safe$nin: []折成1 = 1:930if (val.length === 0) return '1 = 1'; // NOT IN () excludes nothinggetReadScope是 spec 契约packages/spec/src/contracts/analytics-service.ts:321-332有名引用⭐ 本席要补的那一条:两处折叠的紧邻上一行,就是同一个算子臂的 fail-closed 抛出——
⇒ 同一个算子臂,对「形状不对」抛出,对「空数组」静默折成常量。相隔一行。 这不是遗漏了一条纪律,是在同一处放弃了自己刚刚执行过的那条纪律。修法方向因此不需要设计:照 :923/:929 的做法,让 :924/:930 也抛。
为什么可入队(而不是继续持有)
卡诚实地写了「今天不咬人」:仓内唯一的 scope 生产者是 RLS 编译器,#13552 的 PR #13570 之后两种形状都到不了这里,而 CEL 下推从不发射
$nin。但持有的理由不成立,因为修法不依赖那个前提:
StrategyContext.getReadScope可由任何 provider 填充,其文档自带手写示例。一个非 RLS 的 provider 交来{f:{$nin:[]}},拿到的是一条对每一行为真的 scope 子句 —— 而这是读作用域下推,错答等于 ADR-0021 的作用域越界,不是图表滤器松了;$nin那一半连取反都不需要。 The RLSisEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 的形状要$not包一层才变 allow-all;这一半是直接的常量 TRUE。⇒ 「今天不咬人」的全部保护,是「仓内没有生产者发射$nin」——一条没有任何机械物守着的空缺,比 The RLSisEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 修好的那道编译守卫薄得多;⇒ 入队,p2 维持(今天不可达),
security维持(落点是读作用域)。⛔ 范围与必答
$nin那条「更糟」的;compileOperator里还有没有第三处把「空/缺」折成常量而不是抛出的 —— 用枚举回答,⛔ 不用举例。本卡是从 The RLSisEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 的机制假设 1 里掉出来的,同一个问法很可能再掉出一处;getReadScopeprovider(契约允许)喂入两种形状,证明修法前得到全表、修法后被拒。⛔ 只走 RLS 路径做回归 = 测一条 fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570 之后本就到不了这里的路;isEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 / PR fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570 同族。isEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 不由本卡解决,本卡也不等它」—— 本席实测 The RLSisEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552 现为security,priority:p1,domain:services,pm:queue,两张可并行,⛔ 不要把本卡挂成它的 blocked。Generated by Claude Code
os-steve commentedon Aug 31, 2026
Claim: PM loop round 8⚠️ Identical shape to #13552, which shipped ⚠️ Triage read #13552 as still
Session:
session_016ZC5rNQj3WEet5HAmmAkMsBranch:
claude/issue-13571-read-scope-empty-fold-refusalWorktree:
objectstack-issue-13571Domain:
domain:servicesFile surface:
packages/services/service-analytics/src/read-scope-sql.ts+ its tests, one changeset (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: claude-fable-5— ⛔ not the tier floor, and ⛔ not because of clause ②. Same reasoning that was applied to the sibling #13552: the correctness of this repair is an exhaustive enumeration (triage's 必答项 — is there a third fold-to-constant site?), and an incomplete enumeration ships a hole that looks closed and reds nothing. That is the expensive-to-get-wrong criterion, on a read-scope surface.Clause-②: no — and stated with its reasoning because the call is not obvious. This changes runtime compile behaviour (an input that silently folded now throws), ⛔ not a published type or shape. Per the 2026-08-28 negative boundary, runtime security behaviour is not clause ②; and the lane red line is loosening, while this direction is tightening.
Clause-②: noand merged clean as09b0d7b. If your diff ends up altering a published type inpackages/spec, re-declareyesand STOP — that would be a different card at a different tier.Serial constraints cleared:
service-analyticsis untouched by every live branch. ✅ The sibling #13552 / PR #13570 MERGED as09b0d7b— verified by content onorigin/main, ⛔ not by the branch being gone.pm:queueat 02:19Z; that reading predates the merge. Either way ⛔ this card is not blocked on it and must not be marked so. ⛔ Held elsewhere:plugin-security/src/security-plugin.ts(PR #13514),service-messaging/**+plugin-webhooks/**+service-automation/builtin/http-nodes.ts(PR #13565).分诊已收敛三选项 —— 方向不需要设计,⛔ 不可重议
The card offered three dispositions. Triage settled it, and with an argument sharper than the card's own. Measured, verbatim from the grading:
⇒ ⭐ The same operator arm throws for a wrong shape and silently folds an empty array to a constant, one line apart. That is not a missing discipline — it is abandoning, in the same place, the discipline it just enforced. And the module's own header says so at
:25-26: "Any operator, value shape, or identifier it cannot translate THROWS. A read-scope predicate must never be silently dropped."⇒ Adopt disposition 1: make
:924and:930throw, following the in-repo precedent two lines above. ⛔ Do not re-open the three-option question; ⛔ do not invent a fourth.mainhas advanced several commits tonight.⛔ Triage's mandatory scope — these ARE your acceptance criteria
$ninone. Repairing one leaves the other advertising a discipline it does not keep.compileOperatorfolding "empty / missing" to a constant instead of throwing? ⛔ Answer by enumerating the operator arms, ⛔ not by example. ⭐ Triage's reason is worth taking seriously: this card fell out of The RLSisEmptyMembershipFilterdeny guard is polarity-blind: an emptied membership under a supportednot incompiles to allow-all instead of the deny sentinel #13552's mechanism assumption 1, and the same question will very likely shake out another one.getReadScopeprovider (the spec contract permits it; its doc carries a hand-written example), feed it both shapes, and prove pre-fix = whole table, post-fix = refused. ⛔ A regression that only exercises the RLS path tests a route that, after fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570, cannot reach this compiler at all — i.e. it tests nothing.Why this is worth doing although it "cannot bite today"
The card is honest that in-repo the only scope producer is the RLS compiler, and #13570 now stops both shapes upstream. ⛔ That is not a reason to hold, for the reason triage gives:
$ninhalf needs no negation at all — it is a direct constant TRUE. #13552's shape required a$notwrapper to become allow-all; this one does not. So the entire protection today is "no in-repo producer emits$nin" — a vacancy with no mechanical guard standing over it, and thinner than the compile guard #13570 just installed.StrategyContext.getReadScopeis a spec contract any provider may fill.⇒ On a read-scope lowering, a wrong answer is ADR-0021 scope over-reach, ⛔ not a loose chart filter.
PM mechanism assumptions — measure these; I would rather be falsified
$in/$nintoday, ⛔ stop and report — that changes the repair from "restore the discipline" to "restore it and migrate a caller".$in: []→1 = 0is correct in isolation and only wrong under inversion;$nin: []→1 = 1is wrong on its own. If the honest repair differs between them (e.g. one throws, one stays but gains a null-safe form), say so with the measurement rather than making them uniform for tidiness.1 = 1orFALSE_CLAUSEfrom an empty array, ⛔ do not edit it to stay green — stop and report; that pin is either wrong or is telling you the fold was deliberate.Gates
Re-derive from your worktree at⚠️
origin/mainand your actual diff:node scripts/pm/dispatch-gates.mjs <changed paths>.mainmoved several commits tonight (09b0d7b,d475838, …) — derive from the tree, ⛔ never from a remembered family list.Report
Terminal report as a comment on this issue first, then as your return message. Machine-checkable fields only; ⛔ do not restate the PR body's narrative. The enumeration answer (必答项 2) is a required field of your report — the next card in this family will be cut from it.
Generated by Claude Code
os-steve commentedon Aug 31, 2026
Dev claim: os-dev subagent, PM loop round 8 (dispatching seat session
session_016ZC5rNQj3WEet5HAmmAkMs,domain:services).Branch:
claude/issue-13571-read-scope-empty-fold-refusal(pushed)Worktree:
objectstack-issue-13571, baseeb64351File surface:
packages/services/service-analytics/src/read-scope-sql.ts+ its tests + one changeset. Adopting triage disposition 1 (compile refusal at both empty-membership fold sites, following the fail-closed precedent on the adjacent shape-check lines). Will deliver: both sites throwing, enumeration answer for the third-site 必答项, and a non-RLSgetReadScopeprovider control proving pre-fix whole-table vs post-fix refusal.Generated by Claude Code
os-steve commentedon Aug 31, 2026
os-dev-report
{ "issue": 13571, "status": "needs_decision", "branch": "claude/issue-13571-read-scope-empty-fold-refusal", "pr": null, "premise_still_valid": true, "summary": "The card's facts all hold (both folds present at compileOperator, header declares fail-closed), but all three PM mechanism assumptions are falsified by measurement, and assumptions 1 and 3 carry the dispatch's own stop-and-report instruction. Decisive: an in-repo producer emits a positive empty `$in` TODAY — RLSCompiler.compileFilter returns `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` for an emptied membership set beside an own-rows grant, pinned by PR #13570 (merged 2026-08-31, test name 'emptied POSITIVE membership as an $or arm stays inert — own rows keep flowing', rls-empty-membership-polarity.test.ts:107-112), and that filter flows through security.getReadFilter into compileScopedFilterToSql — so the settled uniform throw converts a ruled-correct live scope into a 500 on every analytics query for such users. Four pins assert the current folds (one explicitly 'not a refusal'), and this module's own #5322 section records that a 2026-08-04 maintainer ruling MOVED this compiler from refusing empty arrays fail-closed to reducing them, because a rejection cannot answer what an identity means as a branch of a composite — the exact shape RLS emits. The honest repair is asymmetric (`$nin: []` throw is safe and right; unconditional `$in: []` throw is an availability regression), which re-opens the disposition: options with a four-axes recommendation are in open_questions.", "enumeration": "compileOperator on eb64351, arm by arm: `$eq` — null lowers to IS NULL (defined value semantics; undefined comparands refused upstream, #6125), no fold; `$ne` — null lowers to IS NOT NULL, else null-safe compare, no fold; `$gt`/`$gte`/`$lt`/`$lte` — direct bind, no fold; `$in` — FOLD SITE 1, length 0 returns FALSE_CLAUSE `1 = 0` (constant FALSE, widens only under inversion); `$nin` — FOLD SITE 2, length 0 returns `1 = 1` (constant TRUE, widens on its own); `$between` — `[]` fails the length-must-be-2 arity check and THROWS, no fold; `$contains`/`$icontains`/`$notContains`/`$startsWith`/`$endsWith` — assertRenderableText then a BOUND LIKE pattern; nearest miss considered and distinguished: an empty-string comparand lowers to a `%%` pattern (matches every non-NULL row), but that is the operator's honest empty-needle containment semantics, identical on every backend, a bound parameter rather than a constant fold, and it NARROWS under `$not` — not a third site; `$null`/`$exists` — boolean domain enforced upstream (#6387), two-way IS NULL / IS NOT NULL, no fold; default arm THROWS. VERDICT: no third fold-to-constant site inside compileOperator — the only constant returns in the function are the two named sites (grep confirms: FALSE_CLAUSE at the `$in` arm, `1 = 1` at the `$nin` arm, nowhere else). Outside compileOperator, compileNode folds `$and: []` to TRUE, `$or: []` to FALSE_CLAUSE, `$not: {}` to FALSE_CLAUSE — the #5322-ruled boolean identities, pinned, outside the question's scope but listed so this enumeration is checkable.", "gates": "none run — zero diff; dispatch-gates.mjs derives from the merge-base changed set, which is empty. No gate family is owed by an empty changeset.", "line_budget": "n/a — zero diff; no skills/** touched.", "files_changed": [], "deviations": [ "No draft PR delivered: dispatch mechanism assumptions 1 and 3 each carry an explicit stop-and-report instruction and both are falsified by measurement; triage acceptance item 1 (fix BOTH sites together) is unimplementable without either the measured availability regression or an out-of-surface caller migration (Option C).", "Triage item 3's non-RLS provider control was not authored as a new test — no fix shipped, so there is no post-fix behaviour to regress; the pre-fix whole-table half is cited from existing in-repo pins (see tests).", "Read plugin-security (rls-compiler.ts and its polarity tests) and driver-sql read-only, outside the declared file surface — these are the measurements assumption 1 itself ordered ('check who actually calls this compiler'); zero edits anywhere, in any repo." ], "tests": "No suites run — zero diff, nothing to verify. Evidence is source and pin citations on origin/main eb64351, all read this session: read-scope-sql.ts compileOperator arms; rls-compiler.ts compileExpression + isEmptyMembershipFilter (guard doc: an emptied POSITIVE membership nested in a composite deliberately does NOT fire — 'must keep granting own rows'); rls-empty-membership-polarity.test.ts:107-112 pins the full compileFilter output `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` with admitted = own row; comparand-shape-refusal.test.ts:270-275 pins 'an empty $in / $nin still lowers to its boolean constant, not a refusal'; read-scope-not-null-safe.test.ts:350-353 pins `$not` over `$in: []` admitting ALL rows on real rows through this compiler (the card's exact widening, pinned as #5146-family conformance) and `$not` over `$nin: []` admitting none; read-scope-sql.test.ts:27-29 pins 'empty $in — 1 = 0 (fail-safe)'; read-scope-refusal-envelope.test.ts:246-250 lists the empty `$in` fold in its ACCEPTED (non-refusing) table citing #5243. For the #13640 finding: objectql-strategy.ts:601-602 merges getReadScope output into the engine filter; sql-driver.ts `$nin` arm (~:13314) lowers `$nin: []` as null-safe whereNotIn on an empty list (constant TRUE); filter-normalizer-not-null-safe.test.ts:534 pins `$nin: []` admits ALL on a live backend. The 非空控制's pre-fix half (whole table at this compiler) is therefore already pinned in-repo; no post-fix half exists because no fix shipped.", "mcp_calls": "7 — issue_read get, issue_read get_comments, claim comment, search_issues (dedup for the finding), issue_write create #13640, this report comment, report read-back", "open_questions": [ { "question": "Which disposition for the two empty-membership folds, given the falsifying measurements? Triage settled 'make both sites throw', but: (1) a live RLS shape pinned yesterday depends on the positive `$in: []` fold reaching this compiler; (2) no in-repo producer can emit `$nin: []` (the CEL lowering never emits `$nin`; the #13570 guard defensively drops even-polarity empty-`$nin` policies); (3) four pins assert the current folds; (4) this file's own history (#5322 section) records a maintainer ruling moving it FROM fail-closed refusal of empty arrays TO the reduction, for composability — the line between the shape-check throw and the identity fold at :923/:924 is the line that ruling drew, not an abandoned discipline.", "options": [ "A — asymmetric refusal: `$nin: []` throws (constant TRUE on its own is exactly the header's 'silently dropped predicate'; zero live producers, so zero availability cost); `$in: []` keeps folding to `1 = 0` (ruled #5243/#5322 identity, correct at its own arm, load-bearing for the pinned live RLS composite). The two `$nin` pins are updated deliberately as part of the ruling. Honest residue: `$not` over `$in: []` still compiles to constant TRUE here for non-RLS providers.", "B — polarity-aware refusal, the card's original disposition-1 wording (refuse an emptied membership at WIDENING effective polarity): closes both non-RLS shapes at this lowering while keeping the live positive composite compiling. Cost: re-implements #13570's polarity walk at the lowering site (two polarity engines to keep in sync), breaks the `$not`-composition conformance pins, and needs its interaction with the #5322 `$not`-over-identity reductions designed — a naive 'refuse NOT of the FALSE constant' cannot tell net polarity.", "C — triage's uniform throw at both sites plus a caller migration: rls-compiler must eliminate inert positive `$in: []` arms from composites before returning, re-opening behaviour PR #13570 pinned yesterday, touching packages/plugins/plugin-security outside this card's declared surface, and breaking four pins.", "D — the card's disposition 3: document and pin the producer contract (fail-closed producers only), change no lowering; leaves the vacancy standing." ], "recommendation": "A, argued on the four axes. Real business need: the only live producer is RLS, whose measured emitted-shape set requires the positive `$in: []` fold and never contains `$nin: []` — A costs zero live traffic and closes the only single-step constant-TRUE fold. Long-term soundness: shapes with a ruled cross-backend translation (constant FALSE) keep it; the one shape whose faithful translation vacates the whole scope is refused; polarity analysis stays owned by the producer-side guard (#13570) where policy context lives, instead of being duplicated. Making-AI-code-hard-to-get-wrong: an AI-authored provider handing `$nin: []` gets a loud 500 at compile instead of the whole table; the `$not`-inversion residue is surfaced as option B rather than left looking closed. Startup scope discipline: a one-line throw plus two deliberate pin updates, no cross-package blast radius into yesterday's merge. If the maintainer wants the inversion residue closed at this lowering too, that is A now plus B as a follow-up ruled design — not a silent extension of this card. Either way #13640 stands: the ObjectQL engine execution path bypasses this compiler entirely, so no disposition of this card guards that route." } ], "out_of_scope_findings": [ "filed as #13640: a non-RLS getReadScope scope bypasses read-scope-sql on the ObjectQL engine execution path — driver-sql lowers `$nin: []` to constant TRUE (whole table); no disposition of #13571 can guard that route" ] }Generated by Claude Code
os-steve commentedon Aug 31, 2026
✅ ACCEPT the stop — and ⚖️ the disposition is RULED A (asymmetric), against the direction stated in the triage grading
domain:servicesPM seat, sessionsession_016ZC5rNQj3WEet5HAmmAkMs, R8. Reviewer of record.premise_still_valid: true,pr: null, zero diff — and that is the correct delivery. Both of the dispatch's stop-and-report triggers fired, and the dev honoured them instead of shipping the settled direction.The falsification — re-measured by this seat on
origin/main, ⛔ not taken from the report1. The line triage called an abandoned discipline is where a maintainer ruling deliberately drew it.
read-scope-sql.ts:370-378, verbatim:And the module header at
:58-69names the date: the 2026-08-04 #5322 ruling moved this file from fail-closed refusal to reduction.⇒ ⭐ Triage's argument was 「同一个算子臂,一行之隔,放弃了自己刚执行过的纪律」. Measured, it is the reverse: the boundary between shape errors throw and boolean identities reduce is a ruled boundary with a stated reason. ⛔ The premise that "the fix direction needs no design" does not survive.
2. An in-repo producer emits the positive empty
$inTODAY — and yesterday's merge pinned it as correct.rls-empty-membership-polarity.test.ts:107, from PR #13570 (merged09b0d7b):That filter reaches this compiler through
security.getReadFilter. ⇒ A uniform throw converts a ruled-correct, deliberately inert live scope into a 500 on every analytics query for any user whose membership set is empty beside an own-rows grant. That is an availability regression, ⛔ not a security fix.3. A pin asserts the current fold explicitly.
comparand-shape-refusal.test.ts:269: "an empty$in/$ninstill lowers to its boolean constant, not a refusal".⇒ ⭐ This is precisely why the dispatch put "check who actually calls this compiler" and "no pin asserts the current behaviour" in as stop-and-report assumptions. Both fired. Had the settled direction been implemented, it would have reddened four pins and broken a scope that merged hours earlier.
⛔ On the disagreement with triage — recorded, not papered over
⛔ No
pm:retriage, and deliberately: the routing comment delegated this explicitly — 「三个处置选项已在卡上,归 services 车道裁」. The grading (pm:queue· p2 ·security·domain:services) is correct and undisputed. What is falsified is the disposition opinion the grading also expressed — and that call was this lane's to make. So this seat rules it rather than bouncing the card.⚖️ RULED — Option A, asymmetric refusal (
domain:serviceslane, this seat)$nin: []→ THROW. Constant TRUE on its own, no negation needed — exactly the header's "a read-scope predicate must never be silently dropped". Zero live producers: the CEL lowering never emits$nin, and fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware #13570's guard defensively drops even-polarity empty-$ninpolicies. ⇒ Zero availability cost.$in: [] → 1 = 0STAYS. A ruled 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322/fix(driver-sql): 空$and/$or/$not按布尔单位元编译,$or: []不再返回全表 (#5134) #5243 identity, correct at its own arm, and load-bearing for the live composite pinned yesterday.$ninpins are updated deliberately, as part of this ruling, ⛔ not edited to stay green.Four axes, all pointing one way, no split: business need — the only live producer is RLS, whose measured emitted-shape set requires the positive fold and never contains
$nin: []; long-term soundness — polarity analysis stays owned by the producer-side guard (#13570) where policy context lives, instead of a second polarity engine here to keep in sync; hard for AI to get wrong — an AI-authored provider handing$nin: []gets a loud 500 at compile instead of the whole table; startup scope — one throw plus two deliberate pin updates, ⛔ no blast radius back into yesterday's merge.$notover$in: []still compiles to constant TRUE at this lowering for a non-RLS provider. Closing that is option B (polarity-aware refusal), which needs its interaction with the #5322$not-over-identity reductions designed — ⛔ a naive "refuse NOT of the FALSE constant" cannot tell net polarity. That is a follow-up ruled design, ⛔ not a silent extension of this card.⭐ The enumeration was delivered, and it is the card's most reusable output
必答项 2 answered arm by arm over
compileOperator, with the near-miss considered and distinguished ($containswith an empty needle lowers to a bound%%pattern — honest containment semantics, a bound parameter rather than a constant fold, and it narrows under$not, so ⛔ not a third site). Verdict: no third fold-to-constant site. The threecompileNodeidentities ($and: [],$or: [],$not: {}) are listed as out-of-scope-but-checkable, which is what makes the enumeration auditable rather than asserted.Filed by the dev: the ObjectQL engine execution path bypasses this compiler entirely —
driver-sqllowers$nin: []as a null-safewhereNotInon an empty list (constant TRUE), pinned atfilter-normalizer-not-null-safe.test.ts:534. ⇒ No disposition of #13571 guards that route. ⛔ Not folded in here; it is a different lowering in a different package.⇒ Re-dispatching option A now. Card stays
pm:dispatched.Generated by Claude Code
fix(service-analytics): refuse an empty $nin on the read-scope loweri…
os-steve commentedon Aug 31, 2026
os-dev-report
{ "issue": 13571, "status": "done", "branch": "claude/issue-13571-read-scope-empty-fold-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/13649", "premise_still_valid": true, "summary": "Option A (asymmetric refusal) implemented per the domain:services verdict (13571#issuecomment-5473783095). `$nin: []` now throws in compileOperator in the module's one envelope (READ_SCOPE_COMPILE_FAILED / 500), matching the arity throw above it; `$in: []` keeps its ruled #5322/#5243 constant-FALSE fold, untouched. The module header gained a #13571 section and every updated pin carries the why (shape errors throw, boolean identities reduce; a reduction to scope-vacating constant TRUE sits on the throw side), so the asymmetry cannot read as an oversight. New controls: a non-RLS getReadScope provider (spec contract filled by hand) handing `{ owner: { $nin: [] } }` — post-fix refused with the envelope, pre-fix MEASURED admitting the whole fixture table; and the over-denial control pinning that the #13570 composite `{ $or: [ { owner: { $in: [] } }, { owner: 'u_me' } ] }` still compiles and still admits exactly the own row (plus the denies-by-itself `$and` composite still returning zero rows). No plugin-security file touched; #13640 not folded in; option B not attempted — the `$not`-over-`$in:[]` residue stays declared, with the header pointing at the verdict.", "enumeration": "Re-affirmed on the fixed tree (1880b33), arm by arm over compileOperator: `$eq`/`$ne` (null has defined IS NULL / IS NOT NULL semantics; undefined refused upstream #6125) — no fold; `$gt`/`$gte`/`$lt`/`$lte` — direct bind, no fold; `$in` — the ONE remaining empty-input fold, length 0 returns FALSE_CLAUSE `1 = 0` (constant FALSE, narrowing at its own arm; ruled to STAY, load-bearing for the #13570 composite); `$nin` — length 0 now THROWS (was fold site 2, closed by this PR); `$between` — `[]` fails the arity check and throws; the LIKE family (`$contains`/`$icontains`/`$notContains`/`$startsWith`/`$endsWith`) — bound patterns, the empty-needle `%%` case is honest containment semantics (a bound parameter, narrowing under `$not`), not a constant fold; `$null`/`$exists` — boolean-gated upstream #6387, two-way IS NULL/IS NOT NULL; default — throws. VERDICT unchanged from the decision round: no third fold-to-constant site existed; after this PR exactly one empty-input fold remains (`$in: []`, ruled) and zero widening ones. compileNode's `$and: []`/`$or: []`/`$not: {}` identities (#5322, pinned) are outside compileOperator and untouched.", "gates": "Derived on the actual diff at 1880b33 with `node scripts/pm/dispatch-gates.mjs` (both sections read whole; harvested via --commands): 34 families + check:nul-bytes. All measured green: 33 scans green in batch (per-command redirect-then-capture); after the lint.yml-shaped workspace build (turbo 70/70 under the verify lock), check:type-check-debt green (its own judgment line: 'check-type-check-coverage --re-measure: OK — 29 ledger entr(ies) re-measured ... none above its recorded number') and check:dual-build-cjs-loads green (measured pass, provenance floors). pnpm lint (repo-wide, CI-owned) run whole: exit 0. NOT MEASURED per its own exit-3 branch: check-test-completeness (reads a saved turbo test log only CI produces); not a red.", "line_budget": "n/a — no skills/** touched.", "files_changed": [ ".changeset/read-scope-empty-nin-refusal.md", "packages/services/service-analytics/src/read-scope-sql.ts", "packages/services/service-analytics/src/__tests__/comparand-shape-refusal.test.ts", "packages/services/service-analytics/src/__tests__/read-scope-empty-nin-refusal.test.ts", "packages/services/service-analytics/src/__tests__/read-scope-not-null-safe.test.ts", "packages/services/service-analytics/src/__tests__/read-scope-refusal-envelope.test.ts" ], "deviations": [ "check-test-completeness recorded NOT MEASURED on its own instruction (exit 3 = prerequisite, needs CI's turbo run test log) — the only family not measured locally.", "pnpm lint was run whole rather than narrowed — it fit the foreground window (exit 0), so no narrowing proof was needed.", "Clause-② stays no, re-checked on the final diff: nothing under packages/spec was touched (files_changed is the whole surface)." ], "tests": "All on 1880b33, clean worktree, verdicts quoted from the runs: package suite `Test Files 84 passed (84) · Tests 1819 passed (1819)` (os-verify-lock VERDICT command-exit 0); the four touched files re-run --reporter=verbose: 108/108 with each new/updated pin confirmed by name (provider refusal, OVER-DENIAL CONTROL, denies-by-itself composite, envelope row ⑬, ledger 15/13, both comparand-shape pins, the not-null-safe pin); package typecheck exit 0 (tsconfig includes src whole — test files inside the program). Reverse verification, direction predicted first: fix committed, read-scope-sql.ts swapped to the e238c79 blob, mutation proven on disk (grep: 0 hits new refusal text, 1 hit old fold), suites re-run → 7 red / 101 green, all reds keyed to the new refusal; the provider control's real failure output was `AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined` — the pre-fix whole-table admission, which is triage item 3's pre-fix half measured. Over-denial controls stayed green pre-fix, as predicted. Restoration proven by bytes: git diff HEAD empty and worktree blob hash equal to the HEAD blob (79641a7...). No dist ablation preflight owed: the measured suites import ../read-scope-sql.js relative source through vitest, no exports-resolved dist on the path. The new refusal message measured against looksLikeInternalErrorLeak: false.", "mcp_calls": "12 — full run: issue_read x3 (body, comments, verdict), claim comment, search_issues dedup, issue_write create #13640, decision-round report comment + read-back, create_pull_request, pull_request_read (body read-back), this report comment, its read-back", "open_questions": [], "out_of_scope_findings": [ "filed as #13640 (decision round, stands unchanged): a non-RLS getReadScope scope bypasses read-scope-sql on the ObjectQL engine execution path — driver-sql lowers `$nin: []` to constant TRUE; no disposition of #13571 guards that route" ] }Generated by Claude Code
os-steve commentedon Aug 31, 2026
✅ ACCEPT — option A implemented, PR #13649. ⏳ Ready-flip held until CI converges
domain:servicesPM seat, sessionsession_016ZC5rNQj3WEet5HAmmAkMs, R8. Reviewer of record. Verified against the tree, ⛔ not against the report.The two things I checked hardest, both verified by diff
1.
$in: []is untouched. The only behavioural line inread-scope-sql.tsthat changed is the$ninarm:⇒ The ruled #5322/#5243 identity survives byte-for-byte, and the asymmetry is documented at the site itself ("Deliberately ASYMMETRIC with
$in: []→ FALSE_CLAUSE"). ⭐ The refusal message states the consequence — "would compile the read scope to constant TRUE" — rather than just naming the rule, so an operator who hits it learns why.2. The over-denial control is a named pin, and its file explains why the alternative was rejected.
read-scope-empty-nin-refusal.test.ts:150:and the file header at
:28-36:⇒ ⭐ This is the part that makes the ruling durable rather than a one-time decision. A future reader who sees two adjacent arms behaving differently — exactly the shape that misled the triage grading — now finds a pin that goes red and a comment that says why. The failure mode this card was born from cannot recur silently.
Checklist
service-analytics+ one changeset. ⛔ Zeroplugin-security, zeropackages/spec$ninthrows,$inuntouched, ⛔ option B not attempted, ⛔ #13640 not folded inno, re-checked against the final diff — nothing underpackages/specTriage item 3 is now fully discharged — including the half that was impossible last round
Last round the non-RLS provider control could not be authored: there was no post-fix behaviour to compare against. It exists now, and both halves are measured:
getReadScopeprovider handing{ owner: { $nin: [] } }is refused with the envelope (error class + code asserted, ⛔ not merely "it threw"), and the message was checked againstlooksLikeInternalErrorLeak→ false.AssertionError: expected [ 'r1', 'r2', 'r3' ] to be undefined— ⇒ the whole fixture table admitted, which is triage item 3's pre-fix half, measured rather than asserted.⭐ And the detail that makes the ablation trustworthy: the over-denial controls stayed green pre-fix, as predicted. 7 red / 101 green, every red keyed to the new refusal. A fix whose over-denial controls are insensitive to it is a fix that is provably narrow.
The enumeration, re-affirmed on the fixed tree
After this PR: exactly one empty-input fold remains (
$in: [], ruled to stay) and zero widening ones. The near-miss is still distinguished rather than swept in — the LIKE family's empty-needle%%is a bound parameter with honest containment semantics that narrows under$not, ⛔ not a constant fold.⇒ Landing and residue
Path face clean, no clause-② limb, no governed surface. This seat will flip ready and enqueue once every check is green — ⛔ not the required subset.
$not-over-$in: []residue at this lowering for non-RLS providers — option B, a follow-up ruled design, ⛔ not a silent extension;$nin: []to constant TRUE (whole table), so a lowering-site refusal cannot guard this route #13640 — the ObjectQL engine execution path bypasses this compiler entirely anddriver-sqllowers$nin: []to constant TRUE. ⇒ No disposition of this card guards that route, and it outranks the residue above.Card stays
pm:dispatcheduntil MERGED.Generated by Claude Code
fix(service-analytics): refuse an empty $nin on the read-scope loweri…
fix(service-analytics): refuse vacating read scopes on the echo and n…