Skip to content

fix(formula): refuse == / != against the bare current_user root at the CEL lowering - #20189

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19959-cel-root-comparand-refused
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-19959-cel-root-comparand-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19959

Clause-②: no (narrowing)

A row-level or sharing CEL predicate that compares with == / != against the bare current_user root is now refused at the CEL lowering (unsupported). It used to lower against the whole caller context object, so a check written record.owner_id != current_user admitted every write it was written to refuse.

Inherits #19886 ruling A (5805254639) with its reason: the published $eq / $ne contract admits a literal or a { $field } reference, and ADR-0058 D2 declares the operand opposite a field as a literal, a current_user scalar or a pre-resolved current_user set. The whole context object is none of them, so the refusal pulls back to the declared contract.

What changed

  • packages/formula/src/cel-to-filter.ts: two guards under == / !=, applied wherever an operand is resolved (opposite a field, and on either side of a constant comparison):
    • the variable ROOT alone (a one-segment path) is refused BEFORE resolution, in both compile modes. So the shape check (isPushdownableCel, isSupportedRlsExpression) reports it at authoring time, and every compile gives the same unsupported whatever it binds, the sharing seeder's variables: {} included;
    • a variable that RESOLVES to an object (not an array, not a Date) is refused per request, like a resolved array.
    • The message names the path the author wrote, never a value, and prescribes a key: record.owner_id != current_user.id.
  • The constant branch is covered because current_user != 'guest' folded to "no restriction" (an object is never strictly equal to a literal). That is the same whole-object comparison one branch over, in the same function, so it is fixed in place (bounded-fix exemption: same defect class, mechanical, no other claim on the file, same gate family).
  • Tests: formula face (new file, 25 cases); plugin-security through the real SecurityPlugin + ObjectQL on driver-sql (new file, 15 cases); lint (5 RLS cases plus 1 control, 3 sharing cases).
  • ADR-0087 semantic entry cel-predicate-variable-root-comparand-refused under protocol major 18, plus the regenerated registry. BREAKING changeset at minor for formula, plugin-security, plugin-sharing and lint, and patch for spec.
  • No lint source changed: both rules now report the shape through the shape verdict (measured below). No plugin-security source changed: explain follows the compile refusal by itself.

The refusal's target, and the census behind it

Is there a legitimate non-array object comparand under == / !=? None was found at 9347c1f23:

  • { $field } references never reach comparandOf: the field-to-field branch emits them itself.
  • A CEL map literal is refused by classify. A bytes literal is refused by coerceLiteral. A function call is unsupported.
  • The RLS context (rls-compiler.ts compileFilter) binds id, organization_id and email as strings, and positions, org_user_ids and accessible_org_ids as arrays. It merges only array-valued rlsMembership keys. The sharing seeder binds {}. The lint reference pass binds scalars and arrays.
  • Custom variableRoots: one in-tree use, a formula test that reads the scalar key ctx.department. No caller binds a root to a scalar.

The rule is therefore: refuse the bare root statically, in every mode, and refuse any resolved object except a Date, which the $eq / $ne contract admits as a literal.

Regression census at 9347c1f23, over the whole objectstack tree (tests, examples, content, skills, apps and docs included): a regex for ==, !=, = or any ordering operator against current_user with no key after it, on either side, returns 0 hits. Positive control: 2 of 2 on a synthetic file. The keyed form (current_user. after an operator) returns 406 lines. hotcrm at 2f7b232 returns 2 hits, both prose (a URL query string), and 0 predicates. No shipped policy, sharing condition, formula example or docs example changes verdict.

Pre-fix and post-fix, measured

The pre-fix readings come from an ablation of both guards: the anchor replaced through scripts/ablation-replace.mjs, formula rebuilt, and ablation-dist-preflight.mjs confirming the marker in 2 built files. Restore: blob equal to HEAD, git diff HEAD empty, formula rebuilt, marker absent from all 6 built files, tree clean, then all green again.

leg (real plugin, driver-sql) pre-fix post-fix
sole using: !=, == and !(==) against the root 400 from driver-sql (the object comparand cannot be bound) [], ground truth intact
sole check: != and !(==), insert and by-id update admitted and stored PERMISSION_DENIED / 403, nothing lands
sole check: == against the root 403 (strict equality to an object matches nothing) 403
USING-only != policy, insert (write pass) admitted and stored 403, nothing stored
OR-sibling read (== current_user.id beside the root) 400 the sibling's rows only
OR-sibling write (status != 'archived' beside the root) archived admitted open admitted, archived 403
explain (read, recordId) narrows denies, allowed: false, record.visible: false, readFilter is RLS_DENY_FILTER, no membership id echoed
delegator leg (agent with no RLS, acting for a real sys_user whose set carries the root check) admitted and stored 403, nothing stored

Each leg has a control spelled the way the refusal points (current_user.id, or a scalar check on the delegator's set). Every control stays green under the ablation, so each leg discriminates.

Lint, on the same stack, using and check:

  • RLS rule: pre-fix SILENT on every root spelling. Post-fix rls-predicate-unenforceable on both clauses.
  • Sharing rule: pre-fix sharing-rule-runtime-variable-condition. Post-fix sharing-rule-unlowerable-condition. The review record said both lints were silent; that holds for the RLS rule only.

Ablation counts: formula 20 of 49 red (the new file's 20 refusal pins; its 5 neighbours and the 24 list-comparand pins stay green). plugin-security 10 of 15 red (the 4 controls and the already-fail-closed == check stay green). lint 8 of 149 red.

Compile surfaces (lowering semantics changed, so one line each)

The refusal sits upstream of every compile surface. The CEL RLS / sharing path now hands a driver only RLS_DENY_FILTER, a scalar id equality, or seeds nothing.

  • driver-sql applyFilterCondition (sql-driver.ts:16077; sqlite-wasm and turso local inherit it): out of scope, refused before any compile surface. Measured: a sole using read returns [] (pre-fix 400).
  • turso RemoteTransport buildWhereSQL (remote-transport.ts): out of scope, refused upstream; it receives the sentinel only. Not run.
  • service-analytics compileScopedFilterToSql (read-scope-sql.ts:658): out of scope. Its scope is ctx.getReadScope, the RLS read filter, which is the sentinel after the refusal. Not run.
  • service-analytics lowerAnalyticsWhere (strategies/filter-normalizer.ts:2171): out of scope. It lowers an analytics where, never a CEL predicate (service-analytics does not call compileCelToFilter).
  • formula matchesFilterCondition (matches-filter.ts:209 on this tree): out of scope. The check it evaluates is now the sentinel. Measured 403 through the real plugin; pre-fix, $ne with the object answered true.
  • objectql having-filter applyHaving / matchesHaving (having-filter.ts:905 / :918): out of scope. A HAVING filter comes from the query, never from the CEL RLS / sharing lowering.
  • driver-memory checkCondition (memory-matcher.ts:361): out of scope, refused upstream. Not run.
  • driver-mongodb translateFieldOperators (mongodb-filter.ts:875 on this tree): out of scope, refused upstream. A raw $ne with an object from a raw filter stays outside this card. No live producer bypasses the compile: the core {token} resolver yields strings only.

Verification (every reading at 60da27e66 unless named)

  • @objectstack/formula: pnpm test 1027 of 1027 passed across 37 files. pnpm typecheck exit 0; test layer OK.
  • @objectstack/lint: vitest run 4160 of 4160 passed across 108 files. pnpm typecheck exit 0; test layer OK.
  • @objectstack/plugin-security: vitest run 2755 of 2755 passed across 137 files. pnpm typecheck exit 0; test layer 0 errors.
  • @objectstack/plugin-sharing: vitest run 913 of 913 passed across 37 files.
  • tsc --listFiles on each package's tsconfig.test.json lists every new or edited test file (1 each).
  • node scripts/pm/dispatch-gates.mjs --commands: 91 families derived. 88 exit 0. 3 exit 3, PREREQUISITE NOT MET, so NOT MEASURED: check:dual-build-cjs-loads, check:i18n and check:type-check-debt each need the full ./packages/* build, which CI runs. --ran reconciliation: 91 accounted, 0 UNRUN.
  • ESLint, narrowed to the 7 changed .ts files with --no-inline-config --format json: 7 files, 0 errors, 0 warnings. The population is the diff's .ts files; the changeset is not linted, and no file was reported as ignored. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, stated at its line 326), so this diff cannot move the verdict of an untouched file.
  • Driver-less git merge-tree of this branch against origin/main 9e7824a44: clean.

NOT MEASURED: live mongod, PG and MySQL. The three gates above. bootstrapDeclaredSharingRules end to end: the seeder's own call (compileCelToFilter(source, { variables: {} })) is pinned at the formula face and through the sharing lint.

Acceptance notes


Generated by Claude Code

…er == / !=

A CEL predicate comparing a field to the bare `current_user` root lowered
against the whole caller context object, so a `check` written
`record.owner_id != current_user` admitted every write. The root is now
refused before resolution in both compile modes (the shape check reports it),
and a variable resolving to an object is refused per request.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…osed on every leg

Sole using, sole check (insert and by-id update), USING-only on the write
pass, OR-sibling, explain and the delegator leg, each with a control spelled
the way the refusal points.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…under ADR-0087

One semantic entry under protocol major 18, the regenerated registry, and a
BREAKING changeset (Clause-② no, narrowing) at minor for formula,
plugin-security, plugin-sharing and lint, patch for spec.

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 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 9e7824a445b1f7e33c91ada4b4f591db84ff76cc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 32bc79d4c391c1ab9ace724cf6dc0213755ba5ee — the merge of head 60da27e665f8b15aa74609b5a83e72f5c35197d2 into base 9e7824a445b1f7e33c91ada4b4f591db84ff76cc, 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 32bc79d4c391c1ab9ace724cf6dc0213755ba5ee && git checkout 32bc79d4c391c1ab9ace724cf6dc0213755ba5ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e7824a445b1f7e33c91ada4b4f591db84ff76cc 60da27e665f8b15aa74609b5a83e72f5c35197d2 && git checkout -B drift-repro 9e7824a445b1f7e33c91ada4b4f591db84ff76cc && git merge --no-ff 60da27e665f8b15aa74609b5a83e72f5c35197d2

node scripts/docs-audit/affected-docs.mjs --json 9e7824a445b1f7e33c91ada4b4f591db84ff76cc

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 60da27e665f8b15aa74609b5a83e72f5c35197d2

Read: card #19959 with all 5 comments; #19886 ruling A (5805254639); PR #19947 record 5810400326; PR #20189 object, body, 8-file list, full diff (+714/-9), 5 commits, check-runs at the head (de-duplicated by name on latest started_at); at the head ref: cel-to-filter.ts whole, matches-filter.ts, rls-compiler.ts (context binding 453-513, shape gate 540-596, compile 680-730), security-plugin.ts (3140-3185, 7070-7095, 7315-7360), explain-engine.ts (1571-1624), bootstrap-declared-sharing-rules.ts (140-300), both lint rules (RLS 370-545, 740-800; sharing 425-525), rls-predicate.ts, rls.zod.ts, the new entry, registry.ts 6256-6346, the changeset, the 2c precedent changeset and entry on main, the headers and rule text of check-adr-0087-registration.mjs / check-changeset-no-major.mjs / check-empty-changeset.mjs and the workflow's WHICH LEVEL prose, .changeset/config.json; AGENTS.md whole. Ran (detached worktree at the head, pnpm install --frozen-lockfile, turbo build of the plugin-security/lint/plugin-sharing closures, all under os-verify-lock, VERDICT command-exit 0 each): a 70-source matrix on the built formula dist in value, bare (variables: {}) and shape modes; the PR's three new test files plus the #19886 list-comparand and cel-to-filter suites (formula 113/113), the two lint rule suites (149/149), and the PR's 15 plugin-security legs plus 16 legs of my own through the real SecurityPlugin + ObjectQL on driver-sql (31/31); the real bootstrapDeclaredSharingRules (source at head, via tsx) on 32 declared rules; a repo-wide census of bare-root comparands at the head and in hotcrm at 2f7b232. NOT MEASURED: live mongod, PG and MySQL; driver-mongodb translateFilter with an object comparand; the objectui sibling (no checkout here; Console Pin Gate path-skipped); the derived gate families (read from CI only); a merge with origin/main (the PR did not merge it; GitHub reports mergeable).

① Derived judgments

(a) Refusal completeness. On the built dist at the head, 24 root spellings all answer unsupported in value mode, with nothing bound, and in shape mode, and isSupportedRlsExpression is false for each: record.owner_id != current_user, == current_user, both flipped orientations, !(…==…), !(…!=…), !!(…), the root under && on either side, under || on either side, 1 == 1 || <root> and <root> || 1 == 1 (refused whole, never folded to {}), the bare-identifier RLS spelling owner_id != current_user, and the constant branch (current_user != 'guest', 'guest' != current_user, current_user != null, null != current_user, current_user == null, current_user == current_user, !(current_user != null), record.status == 'open' || current_user != 'guest'). The legacy owner_id = current_user bridges to == and is refused; owner_id <> current_user is parse-error (pre-existing, also fail closed). The detail names the written path and prescribes <root>.id; none of six bound values leaks into it (the PR's 25 pins plus my sweep). By reading, the guard sits in variableOperandOf before resolveValue, reached from comparandOf (opposite a field) and from both operands of the constant branch, so no ==/!= operand resolves without it; !, &&, || lower their children through lowerCondition, and a CompileError thrown anywhere aborts lowerCelAst whole. One nuance: current_user.id != current_user with variables: {} answers unresolved-variable (the keyed left operand faults first) rather than unsupported; still refused, shape mode says unsupported.

(b) What newly refuses or changes answer. Three classes: (1) the bare root under ==/!= in every mode, which includes a custom root a caller binds to a SCALAR (variables: { ctx: 'sales' }, row.dept != ctx used to lower to { dept: { $ne: 'sales' } }, now refused as the root) — wider than "an object"; no non-test caller in the tree uses variableRoots at all (census 0), so nothing in-tree meets it; (2) a variable that resolves to a non-array, non-Date object (current_user.profile), per request, shape passing; a Date (current_user.since) and a nested scalar (current_user.nested.deep) lower as before; (3) the constant branch with a root operand, which folded to {} (allow-all) whenever the root was bound and was unresolved-variable when it was not. Hunt for a legitimate casualty: a whole-tree grep at the head for current_user with no key after it beside ==, !=, =, <> or an ordering operator, either side, hits only the PR's own eight files (42 lines; positive control); hotcrm at 2f7b232: 0. Producers cannot supply an object: RLSCompiler.compileFilter binds id/organization_id/email as strings and positions/org_user_ids/accessible_org_ids as arrays and merges only array-valued rlsMembership keys (rls-compiler.ts 453-513); the lint probe binds PROBE_SCALAR/PROBE_ARRAY per key; the seeder binds {}. rowLevelSecurity[].using and .check are z.string() (rls.zod.ts 391/440), so no filter object bypasses the compiler. 15 neighbours lower byte-identically (current_user.id all forms, current_user.?id, email, organization_id, field-to-field $eq/$ne { $field }, $null both ways, 1 == 1, literals, $gt 3, in literal and membership, !(… in …), current_user.id != 'guest' → {}); the #19886 list refusals are intact. No legitimate in-tree predicate changes verdict.

(c) Fail-closed through the real plugin (driver-sql, better-sqlite3, real ObjectQL + SecurityPlugin with the guard set as fallback). The PR's 15 legs are green here, and my own 16 legs beside them: sole check for !=, !(==), == and !(!=) refuses the insert for BOTH reviewer values (self and other) with PERMISSION_DENIED / 403, nothing stored, a loud drop WARN present, and no usr_peer in any WARN; the control record.reviewer_id != current_user.id admits the other owner and refuses the caller. The root inside && (status == 'open' && reviewer_id != current_user), inside || (status != 'archived' || …) and beside 1 == 1 || refuses every insert — never reduced to the compiling arm. OR-sibling check (root beside == current_user.id): self admitted, other 403; OR-sibling using: the sibling's rows only. USING-only current_user != null (the old allow-all): read [], insert 403. Constant check "current_user != 'guest'": 403. Legacy reviewer_id <> current_user: 403. By-id update under a sole root check: 403, row unchanged. record.reviewer_id in current_user as check: 403. The PR's explain pin (denies, allowed: false, record.visible: false, readFilter = RLS_DENY_FILTER, no membership id echoed) and delegator pin (agent for a real sys_user whose set carries the root check: 403, nothing stored) pass here with their controls. Mechanism by reading: the refused shape takes compileFilter's !isSupportedRlsExpression WARN branch and, with applicable > 0 and filters.length === 0, the clause returns RLS_DENY_FILTER (rls-compiler.ts 582-596); the write gate evaluates matchesFilterCondition(image, RLS_DENY_FILTER) (security-plugin.ts 3166-3167), which no image satisfies; a compiling sibling leaves filters.length === 1 and decides alone; explain maps the sentinel to denies (explain-engine.ts 1579-1582). Envelope on every write leg: ADR-0112 PERMISSION_DENIED, status 403.

(d) Lints and skip reasons. Both rule suites green at the head (149). RLS rule: the shape verdict isSupportedRlsExpression is now false, so the explanation branch reads isPushdownableCel → unsupported → rls-predicate-unenforceable on using and on check (5 spellings; the keyed control stays clean). Sharing rule: the seeder's exact call now answers unsupported, which takes the non-unresolved-variable branch → sharing-rule-unlowerable-condition (3 spellings). The seeder itself, measured end to end (real bootstrapDeclaredSharingRules, source at head): for every mappable recipient (user, position, team) the !=, !(==) and current_user != 'guest' rules are skipped with reason=unsupported and defineRule is never called for them; the three controls seed. Intended: both new ids carry the right prescription (a respelling, not a runtime variable). Harmless: no in-tree test pins the old reading (0 unresolved-variable strings in plugin-sharing tests; 0 bare-root predicates in any test outside the PR's), and no lint or plugin-sharing source changed. Nit: current_user.id != current_user under the seeder still reads unresolved-variable (so sharing-rule-runtime-variable-condition), because the keyed operand faults first; the body's "every compile gives the same unsupported whatever it binds" holds only when the root is the first operand faulted. Still skipped, still reported.

(e) Anything else in the published surface. No export added, removed or renamed (formula/src/index.ts untouched; check:api-surface green inside TypeScript Type Check). compileCelToFilter, lowerCelAst, isPushdownableCel and isSupportedRlsExpression narrow exactly as in (b). matchesFilterCondition is unchanged: measured on the dist, a filter passed DIRECTLY with { f: { $ne: <object> } }, { $not: { f: <object> } } or { f: { $gt: <object> } } still answers true (the pre-existing evaluator face the 2c record already noted for the 400 arm); unreachable from a CEL using/check after this PR and not this card's seam. No other package's source changed.

② Semver level

Consistent with the gates' stated rules and the precedent. check-changeset-no-major: no major in the frontmatter (minor ×4, patch ×1; all five in the fixed group); level axis: the body carries Clause-②: no (narrowing) bare at the start of a line → the axis is carried by the arm; moved packages formula, plugin-security and lint graded minor, spec patch → the discharged row, exit 0. check-adr-0087-registration: the changeset declares breaking twice (the **BREAKING** banner and the narrowing arm) and carries exactly one marker, <!-- adr-0087: registered cel-predicate-variable-root-comparand-refused -->; the id resolves at HEAD (registry.ts:6303) and is absent at the merge base 9347c1f23 (0 hits), so it is NEW in the diff — the registered rule's both halves hold; the registry block is byte-equal to entries/semantic/18.cel-predicate-variable-root-comparand-refused.ts with comments stripped, and check:migration-registry / check:spec-changes / check:upgrade-guide ride green in TypeScript Type Check (the 2c landing likewise touched only registry.ts and the entry). check-empty-changeset: one A with a five-line non-empty frontmatter; no M or D of a changeset on the base. Precedent cel-predicate-list-comparand-refused (.changeset/19886-cel-list-comparand-and-mongodb-ne-array-refused.md): same banner sentence, same minor for formula/plugin-security/plugin-sharing/lint and patch for spec, same marker form; the new entry sits beside it under protocol major 18 with the same field set and states no D2 conversion, with the reason (the platform cannot know which key was meant). plugin-sharing has no source in the diff, but its boot-time skip reason changes through its @objectstack/formula dependency, which is the precedent's treatment too; no gate refuses an un-moved bump. Check Changeset is success at this head.

③ Boundary flags

Blocking: none.

Non-blocking:

CI at this head: 35 check runs, 35 names after de-duplication on the latest started_at; none in progress or queued; 32 success, 3 skipped, 0 failure. All seven required contexts success (Lint & Repo Gates, TypeScript Type Check rollup with its four Type Check · jobs, Test Core rollup + 6/6 shards, Dogfood Regression Gate rollup + 3/3, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard); also success: Check Changeset, Check PR Size, Auto Label, Check Documentation Links, Dogfood Verify CLI, Spec property liveness, Flag docs affected by code changes, filter, and the four card/branch guards (The card this PR closes must claim this branch, Part-of PR must not also close its card, No other open PR may claim the same issue, No other open PR may claim the same single-writer path). Skipped, each with its reason: Console Pin Gate and Build Docs (path filter false — the diff touches neither tree), Packed-tarball smoke (opt-in) (needs its opt-in label). No non-success conclusion other than those three skips.

PR body closing keywords: only Fixes #19959; #19886 and #19951 appear without a closing keyword. Commits: 5, each ending with the model-free Claude-Session / Co-authored-by: Claude pair; 0 model identifiers in the diff, body, changeset or commit messages.

Implemented-by: claude/issue-19959-cel-root-comparand-refused
Reviewed-by: session_01CiCTczDo7tGhafXjf61dUJ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 07:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 560b724 Sep 27, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19959-cel-root-comparand-refused branch September 27, 2026 07:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…als the shape check cannot see (objectstack-ai#20210)

Fixes objectstack-ai#19951
Clause-②: no

`validateRlsPredicateEnforceability` judged an RLS predicate's shape
with every `current_user` value replaced by a placeholder, so a
predicate the runtime refuses because of a value passed `os validate`
cleanly and enforced nothing. The rule's reference pass now reports two
such classes as `rls-predicate-unenforceable` (`error`), each with a
pasteable rewrite that uses the predicate's own field and key:

1. **A `current_user` value of the wrong type for its position.**
Examples: a membership set under `==` / `!=` or handed to a string
method, a one-value key on the right of `in`, the bare `current_user`
object under `in` or a string method. The compiler refuses these on
every request and `RLSCompiler` drops the policy.
2. **A lowered comparand the shared filter faces refuse by ruling.** A
`null` list member or a `null` ordering bound. These compile, and the
RLS layer never runs the faces on its own filter, so the backend answers
with semantics the platform left undefined.

Only `packages/lint/src/validate-rls-predicate-enforceability.ts`, its
test and one changeset change. No runtime source is touched (formula
lowering, `RLSCompiler` and the shared comparand faces are unchanged).

## Shape table (Zone 2, item 1)

Lint readings come from a stack that declares the fields. Runtime
readings come from a throwaway probe (never committed) built on the
`rls-list-literal-comparand-fails-closed.test.ts` harness: real
`SecurityPlugin` + ObjectQL + `SqlDriver` on better-sqlite3, caller
`usr_member`, rows `r_open` (status open) and `r_closed` (status closed,
reviewer usr_member), and `check` inserts of one `open` and one `closed`
row. "main" is `560b724c`; "this PR" is `77060e6d`.

| authored predicate | lint on main | lint, this PR | `using` read
(runtime) | `check` write (runtime) |
|:--|:--|:--|:--|:--|
| `record.reviewer_id != current_user.org_user_ids` | 0 | unenforceable
| 0 rows, `DENY (fail closed)` WARN | 403 / 403 |
| `!(record.reviewer_id == current_user.org_user_ids)` | 0 |
unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id == current_user.positions` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id in current_user.id` | 0 | unenforceable | 0 rows,
`DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id in current_user` | 0 | unenforceable | 0 rows,
`DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.startsWith(current_user)` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.contains(current_user)` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.startsWith(current_user.org_user_ids)` | 0 |
unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 |
| `record.status in ['open', null]` | 0 | unenforceable | `r_open` (acts
as `in ['open']`), no WARN | open admitted / closed 403 |
| `record.status in [null]` | 0 | unenforceable | 0 rows, no WARN | 403
/ 403 |
| `!(record.status in ['open', null])` | 0 | unenforceable | **0 rows**
(`r_closed` hidden), no WARN | open 403 / **closed admitted** |
| `record.status > null` | 0 | unenforceable | 0 rows, no WARN | 403 /
403 |
| `record.status <= null` | 0 | unenforceable | 0 rows, no WARN | 403 /
403 |
| `record.reviewer_id != current_user` | unenforceable (since objectstack-ai#20189) |
unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403
/ 403 |
| CONTROL `record.status != ['closed', 'archived']` | unenforceable |
unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403
/ 403 |
| CONTROL `record.reviewer_id == current_user.id` | 0 | 0 | `r_closed` |
per value |
| CONTROL `!(record.reviewer_id in current_user.org_user_ids)` | 0 | 0 |
`r_open` | admitted |
| CONTROL (probe artefact) `current_user.email == 'member@example.com'`
| 0 | 0 | every row for that caller | admitted |
| CONTROL (probe artefact) `current_user.email == 'someone@else.com'` |
0 | 0 | 0 rows, `DENY (fail closed)` | 403 |

Reading of Zone 2's assumption 1:

- It holds for the type family: the runtime refuses those predicates on
every request while the lint reports 0.
- It is **falsified for the null family**. The null shapes are not
refused on the RLS data path: they compile, reach the driver, and are
answered with SQL null semantics. Within one policy, the `using` read
and the `check` evaluator disagree on `!(record.status in ['open',
null])`. The shared faces refuse the same comparands wherever those
faces run: the engine seam on every caller-supplied filter, and
`assertReadScopeComparandsRunnable` in analytics (read from code,
`service-analytics/src/read-scope-sql.ts`; not re-measured here). The
data-path gap is in the Acceptance notes and the report, not addressed
here.
- `f != current_user` was already moved by objectstack-ai#20189 and stays reported by
the shape branch.

## The discriminator (Zone 2, item 2)

**Type family: a one-reference type swap must make the same compile
lower** (`attributeTypeFault`). When the reference pass's probe compile
is refused `unsupported`, the rule rebinds ONE reference to a value of
the other runtime type and compiles again. The candidates are each
kernel key (scalar to list, or list to scalar) and the bare root (as a
list that still carries the keys, then as one string). If that single
swap makes the predicate lower, and the swapped value lands in a lowered
field position, the refusal came from that reference's type. The type is
not the probe's to choose: `ExecutionContextSchema` declares it for the
kernel keys (read via `kernelKeyProbe`), and the root is always the
whole context object. So the runtime refuses the same position on every
request.

A refusal no single swap cures reports nothing. That is the
probe-artefact class. A constant comparison with no field, such as
`current_user.email == 'ops@acme.com'`, folds on the value: it lowers to
"no restriction" for the caller it names and is refused for everyone
else. The swap value begins with the probe string and never equals it,
so ordering folds and equality folds stay refused under every swap. The
pinned control asserts `compileCelToFilter` really answers `{ ok: true,
filter: {} }` for the named caller and `unsupported` for another, then
asserts the lint stays silent on both clauses, alone and inside `||
record.owner_id == current_user.id`.

**Null family: the verdict of the same two faces analytics runs**
(`sharedFaceRefusal`): `assertListComparandShapes` +
`normalizeFilterComparandTypes` from `@objectstack/spec/data`, run on
the probe-compiled filter and graded by the refusal's `code`
(`INVALID_FILTER`), never its prose. It is not a probe artefact, because
the probe binds only strings and string lists, which both faces accept
by construction; a probe carrying a nested record is not judged. The
face-probe control `record.assigned_to_id in current_user.org_user_ids`
stays clean.

Conservative by construction, and recorded in the docblock: a predicate
with two type faults (no single swap cures it) stays silent.

## New author-facing text (quoted verbatim; `KEY` stands where the
shipped string spells an angle-bracketed placeholder)

Type-family message: ``RLS CLAUSE `SOURCE` passes the shape check, but
the compiler refuses it for the value `VARIABLE` holds on every request
(COMPILER_DETAIL). HOLDING_SENTENCE `` + the existing clause consequence
with a new preamble:

> so `RLSCompiler` DROPS the policy on EVERY request (a request that
resolves no value drops it too). The shape check passes, so the
"uncompilable predicate" WARN is never logged; the only signal is a
per-request "DENY (fail closed)" WARN, emitted only when nothing else
applicable compiles, and nothing reports it at authoring time.

Holding sentences:

> `current_user.org_user_ids` is a membership set: `ExecutionContext`
declares it, and the kernel resolves it, as a LIST on every request, so
no request can ever put one value in this position.

> `current_user.id` holds ONE value: `ExecutionContext` declares it, and
the kernel resolves it, as a scalar on every request, so no request can
ever put a list in this position.

> `current_user` alone is the whole caller context object on every
request, never one value and never one set.

Hint leads, one per holding:

> `==` / `!=` compare ONE value and a string method takes ONE string; a
membership set is tested with `in` — "one of these" is `x in set`, "none
of these" is `!(x in set)`.

> `in` tests membership in a SET; one value is compared with `==` /
`!=`.

> `current_user` is the caller context, not a value: name the key that
holds what you mean.

Each hint lead is followed by `In this predicate: ` and the rewrites,
for example:

- ``replace `record.reviewer_id != current_user.org_user_ids` with
`!(record.reviewer_id in current_user.org_user_ids)` ``
- ``replace `record.reviewer_id == current_user.org_user_ids` with
`record.reviewer_id in current_user.org_user_ids` ``
- ``replace `record.reviewer_id in current_user.id` with
`record.reviewer_id == current_user.id` (and `!(record.reviewer_id in
current_user.id)` with `record.reviewer_id != current_user.id`); for a
set, name a membership key: `record.reviewer_id in
current_user.accessible_org_ids` / … `org_user_ids` / … `positions` ``
- ``replace `record.reviewer_id in current_user` with the key that holds
the set: `record.reviewer_id in current_user.accessible_org_ids` / …
`org_user_ids` / … `positions`, or an app-staged §7.3.1 set
`record.reviewer_id in current_user.KEY` ``
- ``replace `record.reviewer_id.startsWith(current_user)` with the key
that holds the string:
`record.reviewer_id.startsWith(current_user.email)` / …
`(current_user.id)` / … `(current_user.organization_id)` ``

The key lists are derived from the declared types (`kernelKeysHolding`)
and are not transcribed.

Null-family message: ``RLS CLAUSE `SOURCE` lowers, but to a comparand
the platform's shared filter check refuses (FACE_FIRST_SENTENCE). `` +
this consequence:

> The RLS layer does not pass its own filter through that check, so the
policy is NOT dropped: the refused comparand reaches the backend, which
answers it with semantics the platform has ruled undefined — backends
disagree, and on the SQL drivers a `null` member or bound matches no
row, so a negated list holding `null` or an ordering against `null` can
admit NOTHING. One policy can even disagree with itself: a `using` read
on a SQL driver hides rows that its `check` evaluator admits on write.
Every analytics query over the object is refused outright, because its
read-scope compiler runs the same check.

Null-family hint:

> `null` has no place inside a list or opposite an ordering operator —
the platform refuses both in every filter it is sent, because no two
backends agree on what they match. Test for no value with `== null` and
for a value with `!= null`: replace `record.status in ["open", null]`
with `(record.status in ["open"] || record.status == null)` to keep
matching a missing value, or drop the member: `record.status in
["open"]`.

Its other rewrites are ``replace `record.status in [null]` with
`record.status == null` `` and ``replace `record.status > null` with
`record.status != null` (has a value) or `record.status == null` (has
none), or compare against a real bound``. Every rewrite is a local
replacement, so it stays correct under any enclosing `!`, `&&` or `||`.
If the face refuses something the locator cannot place, the hint falls
back to the existing lowerable-subset sentence.

The existing `consequence(clause)` text for uncompilable predicates is
byte-identical; it only became parameterised.

## Pins

- **Flipped:** `accepts every kernel-resolved key in BOTH positions`
asserted `[]` for `owner_id == current_user.KEY` and `owner_id in
current_user.KEY` for every kernel key, which wrote the blind spot down
as a pin. It is now `never reports a kernel-resolved key as UNKNOWN — in
its own position it is clean, in the other it is a type fault`. It
asserts, per key and by its runtime type (the table hoisted to
`KERNEL_KEY_RUNTIME_TYPE`), `[]` in the key's own position and exactly
`[rls-predicate-unenforceable]` in the other, and never
`rls-predicate-unknown-user-variable`.
- **Pin sweep, repo-wide:** outside `packages/lint`, no test asserts an
`rls-predicate-*` id. The one hit is prose in
`packages/qa/dogfood/test/authz-conformance.matrix.ts`. The shape-branch
pins (literal list, bare root, `size()`, SQL `AND`, over-budget), the
verdict-parity corpus and the disjointness test are unchanged and green.
A genuinely illegal shape keeps its refusal verbatim.
- **New block** `the refusals the SHAPE check cannot see are reported
(objectstack-ai#19951)`, table-driven over both clauses:
- 8 type-fault rows and 7 null rows; each asserts
`isSupportedRlsExpression(source) === true` (why it was silent), then
exactly `[[rls-predicate-unenforceable,
permissions[0].rowLevelSecurity[0].CLAUSE]]`, and that the hint contains
the pasteable rewrite.
- Message substance for each family: a DROP with `RLS_DENY_FILTER` on
`using` and `PermissionDeniedError` on `check`, versus "NOT dropped".
  - Every site rewritten in one finding.
- Controls: the literal list keeps one finding, and every spelling the
hints point at is clean on both clauses.
  - The probe-artefact control.
  - Reach through `runAuthoringRules('validate', …)`.

## Verification (at `77060e6d`, the merge of `origin/main` `0bd11261`)

- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2
--reporter=verbose src/validate-rls-predicate-enforceability.test.ts`:
`Tests 120 passed (120)`.
- `pnpm --filter @objectstack/lint test`: `Test Files 108 passed (108)`,
`Tests 4204 passed (4204)`. This is the whole package, the rule's only
home.
- `pnpm --filter @objectstack/lint typecheck`: exit 0. `tsc --noEmit`,
then `check:test-typecheck` `OK` against `tsconfig.test.json`.
`--listFiles` includes the test file, and it carries 0 type errors; the
6 ledgered test-layer errors live in two other files.
- `pnpm --filter @objectstack/lint build`: exit 0.
- **Ablations**, one-time and never left in the tree. Each went through
`node scripts/ablation-replace.mjs` in WRAP mode, with the anchor hit
once (1 to 0), a new blob, and the restore proven (blob == HEAD
`8589ec8c`, `git diff HEAD` empty). The subject is imported from `src`,
so no `dist` step applies.
- **A.** `attributeTypeFault(...)` replaced by `?? FAKE_FAULT` (every
refusal reported). Result: 2 failed / 118. Red: the probe-artefact
control, and `§7.3.1 … a key used in BOTH positions takes the membership
answer`. That is the discriminator is load-bearing.
- **B.** `sharedFaceRefusal` made to return `null`. Result: 8 failed /
112, the 7 null rows plus "NOT reported as a drop".
- **C.** The swap may never land (`sites.length > 0` becomes `< 0`).
Result: 12 failed / 108: the 8 type rows, the flipped kernel-key pin,
the message test, the every-site test, and the `os validate` reach.
- **Census (Zone 2, item 4):** the BASE rule (`git show 560b724:…`,
blob `cb850a12`, as a throwaway module) and this PR's rule were run in
one process over every `using` / `check` literal. Outside tests: 126
predicates, 77 of them `current_user` (the note's control of 77
reproduced), **0 changed**. Including every test fixture: 327
predicates, 205 `current_user`, **0 changed**. The 4 template-literal
predicates the extraction skipped (`${ownerField} == current_user.id`
and similar) and the one JSON fixture are all type-correct. No in-repo
producer is newly flagged.
- **Interplay (Zone 2, item 5):** the null-family check calls the shared
face `assertListComparandShapes`
(`packages/spec/src/data/filter-comparand-shape.ts`) as it stands on
`main`. objectstack-ai#19886 stage 2b edits that file's `$ne` / array arm. The lint
only ever hands it filters the compiler already accepted, and the
compiler refuses `==` / `!=` against a list, so a `$ne` array never
reaches the face from here. Measured on `main`; not waited on.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived at `77060e6d` (59 commands, a
superset of the lead's 52 once the changeset exists), all run.
Reconciled with `--ran`: `59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN`;
all 57 exited 0. NOT MEASURED: `pnpm check:dual-build-cjs-loads` and
`pnpm check:type-check-debt`. Both exit 3, PREREQUISITE NOT MET: they
need every package's `dist/` (a whole-repo build), which is CI's.
- **Consumer suites** (`cli`, `mcp`, `metadata-protocol`,
`platform-objects`, `cloud-connection`): not run locally and declared to
CI. The public surface is byte-identical: no export, no wire shape. The
census above measures every in-repo predicate, fixtures included, at 0
flips, and no suite outside `packages/lint` asserts an `rls-predicate-*`
id.
- **Dogfood:** the showcase predicates (`owner == current_user.email`
and similar) are inside the 0-flip census.

## Choices settled here (four axes)

1. **Report the null family although the data path does not refuse it.**
- **Business need:** measured. `!(record.status in ['open', null])`
hides every `closed` row on read while its own `check` admits the write,
and an AI writing a blocklist "neither closed nor missing" lands exactly
here.
- **Long-term soundness:** the contract (the null rulings) already
refuses these comparands at every face that judges them. The lint agrees
with the contract rather than with the one path that skips it.
- **Guarding against AI metadata mistakes:** a loud authoring refusal,
with a rewrite, instead of backend-dependent silence.
   - **Startup scope:** no new capability, no runtime change.
- Not reporting it would leave "validated clean, behaves per backend" in
place.
2. **Same id (`rls-predicate-unenforceable`), not a new one.** Zone 1's
direction names the id for the compile-refusal family, and the null
family's fix is the same kind of edit (rewrite inside the enforceable
subset). An allowlist keyed on the id already means "this predicate
enforces nothing", and a new public id would widen `@objectstack/lint`'s
exported surface for no new decision. The message says which class it
is.
3. **The discriminator** as above: asked of the compiler, the way
`userVariableIsScalarPositioned` already asks it, and never by grading
prose.

## Acceptance notes

- **Runtime gap (reported, not addressed here):** the RLS data path does
not run the shared comparand faces on its own compiled filter. A `null`
list member or `null` ordering bound in a policy reaches the driver and
the `check` evaluator, which disagree.
- Measured through the real plugin on driver-sql: `!(record.status in
['open', null])` as `using` reads 0 rows (the `closed` row is hidden).
As `check`, it admits a `closed` insert and refuses an `open` one.
  - `record.status in ['open', null]` reads `r_open` only.
  - `record.status > null` reads 0 rows and refuses both inserts.
- The contract's own text calls these cells "constructively unreachable
through the compile face" (`driver-memory/src/memory-matcher.ts`), and
they are reachable through RLS.
- Handed to the PM seat for the filing gate; this PR changes no runtime
source.
-
`packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts`
says "The authoring lint reports a list literal as
rls-predicate-unenforceable; a membership set holds its value only per
request, so that form is refused at request time." That is still true,
but no longer complete: the lint now reports the membership-set form
too. Carrier: none.
- An ordering against a membership set (`record.f >
current_user.positions`) lowers to `$gt` over a list. Neither the
compiler nor the faces refuse it, and its runtime answer was not
exercised here. Carrier: none.
- Conservative limits, by design: a predicate with two type faults stays
silent. So does an unknown app key in a scalar position when a later
type fault masks it; that is `userVariableIsScalarPositioned`'s existing
behaviour.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…wering and the write-check evaluator (objectstack-ai#20259)

Part of objectstack-ai#19886
Clause-②: no

Stage 2d of objectstack-ai#19886. It covers the three same-class leaks recorded in
`5806608550`, each measured before any edit at every door, plus two
members of the same family that the same guard carries: (d) and (e),
from seat 4's note `5853764742`. The changeset declares `Clause-②: no
(narrowing)`, BREAKING, and registers
`cel-predicate-one-value-comparand-refused`. This PR does not close the
card: the remainder below stays open.

## What changes

**`packages/formula/src/cel-to-filter.ts`** (the CEL lowering that every
RLS policy, declared sharing rule and the authoring lint compile
through). Each shape below now fails with reason `unsupported`:
- (c) a **list under an ordering operator** (`>`, `>=`, `<`, `<=`),
either side: a list literal, or a `current_user` key that resolves to an
array.
- (b) an **`in` list whose member is itself a list**, written or
resolved.
- (c) and (e) a **list operand on the constant-fold branch**, where no
field is involved. It previously folded to "no restriction".
- (d) the **`current_user` root, or a key resolving to an object, under
an ordering operator**. objectstack-ai#20189's guard now covers all six comparisons.

Literal forms are also refused by the shape check (`isPushdownableCel`,
`isSupportedRlsExpression`), so the lint reports them. Resolved values
are refused per request.

**`packages/formula/src/matches-filter.ts`** (the evaluator the RLS
write check runs):
- An array under `$gt` / `$gte` / `$lt` / `$lte`, and an array member of
`$in` / `$nin`, are refused by the up-front shape walk, before any
record is judged.
- (a) A `{ $field }` comparison (`$eq`, `$ne` or an ordering operator)
whose compared column holds a list or an object on the record being
judged is refused. This covers either side, and the `addDays` form
judged on its base column.

Every refusal gives 2a's envelope (`INVALID_FILTER` / 400) with one
widened sentence.

(a) cannot be refused at the lowering. The lowering sees the predicate's
text, not the object's field types: `RlsFieldGuard` carries column names
only (`getObjectFieldNames` returns a set of strings). So Zone 2 item
2's premise, "the CEL lowering knows the field's schema", is false. The
evaluator is the first face that sees the values. It pulls the write
check onto driver-sql's existing rule (objectstack-ai#5222
`crossFieldComparisonClass`), which refuses a cross-field comparison
against any JSON-stored or `multiple` column on either side.

## Measured cells

A real stack: `SecurityPlugin`, ObjectQL, driver-sql (better-sqlite3)
and driver-memory, a `json` column and a `multiple` lookup. Doors
measured for each cell:
- the write `check` (forbidden insert and allowed insert, with what was
stored);
- the `using` read;
- the CEL compile (`isPushdownableCel` and the lint rule
`validateRlsPredicateEnforceability`);
- the analytics read scope (`getReadFilter` into
`compileScopedFilterToSql`, and into `assertReadScopeComparandsRunnable`
+ `engine.aggregate`).

"Before" is `origin/main` `3cb84d08`, measured before any edit. The
table was written to disk first. "After" is this branch. "Base on merged
main" is this branch with both faces ablated at once, which is the
formula of `origin/main` `0d3ec471`, including objectstack-ai#20212's comparand faces
in the RLS compiler.

| cell | predicate | door | before (3cb84d0) | base on merged main |
after |
|---|---|---|---|---|---|
| a1 a | `record.status != record.tags` | compile | lowers | lowers |
lowers |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 |
INVALID_FILTER/400 |
|  |  | using memory | all rows | all rows | all rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
| a2 a | `!(record.status == record.tags)` | compile | lowers | lowers |
lowers |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 |
INVALID_FILTER/400 |
|  |  | using memory | all rows | all rows | all rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
| a5 a(mirror) | `record.tags != record.status` | compile | lowers |
lowers | lowers |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 400 / 400 |
| | | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 |
INVALID_FILTER/400 |
|  |  | using memory | all rows | all rows | all rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
| b1 b | `!(record.status in [['closed', 'archived']])` | compile |
lowers | lowers | refused (shape + lint) |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
|  |  | using memory | all rows | all rows | no rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | deny scope; oql no rows |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows | deny scope; oql no rows |
| b3 b(var) | `!(record.status in current_user.nested_set)` | compile |
lowers | lowers | refused per request |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
|  |  | using memory | all rows | all rows | no rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | READ_SCOPE_COMPILE_FAILED/500; oql
INVALID_FILTER/400 | deny scope; oql no rows |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows | deny scope; oql no rows |
| c1 c | `record.status > ['m']` | compile | lowers | lowers | refused
(shape + lint) |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | analytics sql | binds [["m"]]; oql INVALID_FILTER/400 | binds
[["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows |
| | | analytics memory | binds [["m"]]; oql INVALID_FILTER/400 | binds
[["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows |
| c3 c | `record.status <= ['m']` | compile | lowers | lowers | refused
(shape + lint) |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | analytics sql | binds [["m"]]; oql INVALID_FILTER/400 | binds
[["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows |
| | | analytics memory | binds [["m"]]; oql INVALID_FILTER/400 | binds
[["m"]]; oql INVALID_FILTER/400 | deny scope; oql no rows |
| c4 c(const) | `current_user.org_user_ids > 'a'` | compile | lowers to
{} (allow-all) | lowers to {} (allow-all) | refused per request |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
|  |  | using sql | all rows | all rows | no rows |
|  |  | using memory | all rows | all rows | no rows |
| | | analytics sql | no WHERE (all rows); oql all rows | no WHERE (all
rows); oql all rows | deny scope; oql no rows |
| | | analytics memory | no WHERE (all rows); oql all rows | no WHERE
(all rows); oql all rows | deny scope; oql no rows |
| c5 c(var) | `record.status > current_user.org_user_ids` | compile |
lowers | lowers | refused per request |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | using memory | INVALID_FILTER/400 | INVALID_FILTER/400 | no rows |
| | | analytics sql | binds [["usr_member"]]; oql INVALID_FILTER/400 |
binds [["usr_member"]]; oql INVALID_FILTER/400 | deny scope; oql no rows
|
| | | analytics memory | binds [["usr_member"]]; oql INVALID_FILTER/400
| binds [["usr_member"]]; oql INVALID_FILTER/400 | deny scope; oql no
rows |
| d1 d(seat4) | `record.reviewer > current_user` | compile | lowers |
lowers | refused (shape + lint) |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored | 403 / 403 |
403 / 403 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored | 403 / 403 |
403 / 403 |
|  |  | using sql | INVALID_FILTER/400 | no rows | no rows |
|  |  | using memory | no rows | no rows | no rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql
READ_SCOPE_COMPILE_FAILED/500 | deny scope; oql no rows | deny scope;
oql no rows |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql
READ_SCOPE_COMPILE_FAILED/500 | deny scope; oql no rows | deny scope;
oql no rows |
| e1 e(seat4) | `current_user.org_user_ids != 'x'` | compile | lowers to
{} (allow-all) | lowers to {} (allow-all) | refused per request |
| | | check sql F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
| | | check memory F/A | ADMITTED+stored / ADMITTED+stored |
ADMITTED+stored / ADMITTED+stored | 403 / 403 |
|  |  | using sql | all rows | all rows | no rows |
|  |  | using memory | all rows | all rows | no rows |
| | | analytics sql | no WHERE (all rows); oql all rows | no WHERE (all
rows); oql all rows | deny scope; oql no rows |
| | | analytics memory | no WHERE (all rows); oql all rows | no WHERE
(all rows); oql all rows | deny scope; oql no rows |
| ctl-a control | `record.status != record.reviewer` | compile | lowers
| lowers | lowers |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
|  |  | using sql | archived,open | archived,open | archived,open |
|  |  | using memory | all rows | all rows | all rows |
| | | analytics sql | READ_SCOPE_COMPILE_FAILED/500; oql archived,open |
READ_SCOPE_COMPILE_FAILED/500; oql archived,open |
READ_SCOPE_COMPILE_FAILED/500; oql archived,open |
| | | analytics memory | READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
READ_SCOPE_COMPILE_FAILED/500; oql all rows |
| ctl-b control | `!(record.status in ['closed', 'archived'])` | compile
| lowers | lowers | lowers |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
|  |  | using sql | open | open | open |
|  |  | using memory | open | open | open |
| | | analytics sql | binds ["closed","archive; oql open | binds
["closed","archive; oql open | binds ["closed","archive; oql open |
| | | analytics memory | binds ["closed","archive; oql open | binds
["closed","archive; oql open | binds ["closed","archive; oql open |
| ctl-c control | `record.status > 'm'` | compile | lowers | lowers |
lowers |
| | | check sql F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
| | | check memory F/A | 403 / ADMITTED+stored | 403 / ADMITTED+stored |
403 / ADMITTED+stored |
|  |  | using sql | open | open | open |
|  |  | using memory | open | open | open |
| | | analytics sql | binds ["m"]; oql open | binds ["m"]; oql open |
binds ["m"]; oql open |
| | | analytics memory | binds ["m"]; oql open | binds ["m"]; oql open |
binds ["m"]; oql open |

## Scope beyond the three recorded leaks, declared

(d) `record.reviewer > current_user` and (e) `current_user.org_user_ids
!= 'x'` are not in `5806608550`. Seat 4's note `5853764742` reported
them on this card, and the claim names only the three leaks.

I folded them in under the bounded in-place rule, because all four
conditions hold:
- ① They are the same defect class: a comparand that is not one value.
- ② The fix is mechanical, already shaped, and pinned. (e) is the
constant-branch half of the guard (c) needs. Leaving `==` / `!=` out of
it would have written a hole into a guard that refuses the same list
under `>`. (d) is objectstack-ai#20189's guard with its `==` / `!=` condition removed,
and its sentence already names the operator.
- ③ `cel-to-filter.ts` is on this claim.
- ④ The gates and pins are the same.

Both were measured live on `3cb84d08`. (e) was allow-all on the write
check, the read and analytics. (d) admitted and stored on the write
check. On the merged main, objectstack-ai#20212's faces already drop (d)'s policy; (e)
still folds to allow-all there.

## Remainder, still open on this card

1. **(a) at the read door on driver-memory.** driver-memory does not
evaluate a `{ $field }` reference at all: neither its source nor its
tests handle `$field`. A `using` read under `record.status !=
record.tags` returns every row, and so does the scalar control
`record.status != record.reviewer`. This is a driver-memory source
change, outside this claim (drivers are test side only). The analytics
ObjectQL strategy on driver-memory inherits it. driver-sql refuses the
read (400, objectstack-ai#5222).
2. **(a) at the compile door (`os validate`).** A field compared with a
`json` / `multiple` field still lowers and is not reported at authoring.
Judging it needs field types: either the lint rule, whose object graph
has them (objectstack-ai#20158 holds that file), or types threaded into the RLS
compile (the engine lane's files). This PR changes neither.

## Pins

New and flipped pins:
- `packages/formula/src/cel-to-filter-one-value-comparand.test.ts`
(new). Every refused literal form, refused per request and by the shape
check. Every resolved form, refused per request with the shape check
passing; the detail names the variable path and no value. The remedy of
each refusal. Neighbours unchanged: one bound, a flat `in`, a scalar
fold, and field-to-field `==` / `!=`, which still lower.
- `packages/formula/src/matches-filter-array-comparand.test.ts`. 2a's
table-driven `SHAPES` gains `$gt` / `$gte` / `$lt` / `$lte` with an
array and `$in` / `$nin` with an array member. Each runs at every depth,
with the empty array, and for every record. A new `describe` covers the
per-record `{ $field }` refusal: both sides, negated, ordering, an
object column, the offset form, and under `$or`. Its control is that the
same filter over one-value columns is compared, not refused, and the
message withholds columns and values.
- **Flipped by the pin-sweep rule:** `a { $field } reference is judged
by what was AUTHORED, not by what it resolves to` asserted `{ tags: {
$eq: { $field: 'tags' } } }` → `true` over a list-holding column. It now
asserts `INVALID_FILTER` / 400. Its two scalar lines stay, moved to a
scalar-control case.
-
`packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts`
(new). Table-driven: leak × door × driver (driver-sql,
driver-sqlite-wasm). Every leak row refuses both inserts and stores
nothing. It reads no rows, or gets the driver's own refusal for (a). A
dropped policy hands `getReadFilter` the deny scope. The one-value
controls are accepted and enforced.

Pin sweep ① (the error code and message swept repo-wide for same-meaning
pins):
- `rls-check-array-comparand-refusal.test.ts` (2a),
`rls-list-literal-comparand-fails-closed.test.ts` (2c),
`rls-variable-root-comparand-fails-closed.test.ts` (objectstack-ai#20189), and
objectstack-ai#20212's `rls-compiled-comparand-faces.test.ts` and
`rls-null-comparand-fails-closed.test.ts` stay green unchanged.
- `cel-to-filter-list-comparand.test.ts` and
`cel-to-filter-variable-root-comparand.test.ts` stay green; none of them
pins an ordering or constant-branch list.
- **No lint pin's verdict moves**: lint's full suite, 109 files / 4232
tests, is green with the change. The lint findings for the new shapes
move from clean to `rls-predicate-unenforceable`, and no pin asserts
them.

## Ablation (one-shot proof that each pin can fail)

Ablations ran from the committed merged head `4ade1b94`. Each mutation
went through `scripts/ablation-replace.mjs` (the anchor must hit;
on-disk counts are verified). `ablation-dist-preflight.mjs` then
confirmed the marker had reached formula's `dist/`, which
plugin-security reads, before any result was read. The script armed
`trap … EXIT INT TERM`, with absolute paths, as its restore.

- **Evaluator face cut.** Three arms were neutralised: the per-record `{
$field }` check, the four ordering operators in
`ARRAY_REFUSED_OPERATORS`, and the `$in` / `$nin` member walk. Readings:
3 markers on disk and 0 anchors left; the marker was in 2 built files.
Result: formula pins **57 failed** / 63 passed, and the plugin-security
pin **8 failed** / 48 passed (exactly the (a) write rows, 4 leaks × 2
drivers).
- **Lowering face cut.** Six arms were neutralised: the list-member
refusal, the list refusal under ordering operators, both constant-branch
list refusals, and objectstack-ai#20189's root and object refusals narrowed back to
`==` / `!=`. Readings: 4 markers on disk (two arms are re-spelled
conditions) and 0 anchors left; the marker was in 2 built files. Result:
formula pins **25 failed** / 95 passed, and the plugin-security pin **28
failed** / 28 passed (every (b), (c) and (e) row, write and read, on
both drivers).
- The (d) rows stayed green, as expected on the merged head: objectstack-ai#20212's
comparand faces in the RLS compiler drop that policy independently. The
(d) formula pins went red, tying the lowering arm to its pin.
- **Both faces cut together** gives main's formula on the merged tree.
That is the "base on merged main" column above, read from the real
stack.
- **Restores.** `git checkout HEAD` on the absolute paths. `git diff
HEAD` was 0 bytes and the blob equals the HEAD blob (`matches-filter.ts`
`1fb6f28c22e6`, `cel-to-filter.ts` `4c6ef215947`). formula was rebuilt,
and the preflight `--absent` found the marker in none of 6 built files,
with a clean tree. The final re-run was green: formula pins 120/120 and
the plugin-security pin 56/56.

## Refusal texts (every changed or new one, quoted)

Lowering, ordering (new branch of `arrayComparandRefusal`):
```text
`OP` orders against one value, but its comparand is a list literal — compare against one bound (`record.f OP 'm'`), write a range as two comparisons joined by `&&`, and test membership with `record.f in [...]`
```
Lowering, list member (new `listMemberRefusal`):
```text
`in` compares the field with each member of its list, one value at a time, but member N of the list literal is itself a list — flatten it: `record.f in ['a', 'b']`, not `record.f in [['a', 'b']]`
```
The resolved form reads "member N of `current_user.KEY` resolves to a
list". The `==` / `!=` sentence of 2c and the object/root sentence of
objectstack-ai#20189 are unchanged; the latter now fires under ordering operators too.

Evaluator (`arrayComparandError`, one sentence for the class, widened
from 2a's):
```text
A single-value comparison in this filter received an array as its comparand: an array under "$ne", or an array in the equality position ({ "field": [ ... ] } or "$eq"), or under an ordering operator ("$gt", "$gte", "$lt", "$lte"), or as a member of an "$in" / "$nin" list, or a column on either side of a { "$field" } comparison that holds a list or an object. A list is not one comparable value. For "one of these values" use "$in", and for "none of these values" use "$nin" — the list operators the filter protocol declares; an ordering comparison takes one bound. It is refused rather than evaluated, because this evaluator compares strictly and no stored value ever equals an array: "$ne" matched EVERY record, and so did a negated equality or a negated "$in", which on a row-level write check admitted every write the check was written to refuse, and an ordering operator compared the array as a string. The field, the operator and the value are withheld from this message because the filter may be an access policy the caller did not write; in a row-level policy, look for a comparison against a list literal, a current_user membership key, or a json or multiple field, and rewrite it with "in" (for example "!(record.status in ['closed', 'archived'])").
```

## Census (Zone 2 item 4)

Zero producers carry any of the shapes:
- objectstack `3cb84d08`: 123 literal `using` / `check` strings in
non-test `packages/**`, 63 lines of them in
`default-permission-sets.ts`, and 3 in `examples/**`.
- cloud `origin/main` `96eb092`: 0 authored predicates.

A shape-wide scan of all non-test strings finds one field-vs-field line,
`examples/app-showcase/src/ui/actions/predicate-matrix.action.ts:188`.
It is an action predicate compared against an element
(`record.f_lookups[0]`) and not an RLS predicate. There are 0 nested
`in` lists and 0 orderings against a list. A positive control confirms
the detector fires on each shape. No D2 conversion is owed.

## Zone 2 answers

1. Measured. Nothing was already closed on `3cb84d08`. On the merged
main, objectstack-ai#20212's faces drop (d) only.
2. **Falsified premise.** The lowering holds no field schema, so (a) is
judged at the evaluator. The CEL route to a list-resolving `{ $field }`
is `record.status != record.tags`; it is reachable and refused with 2a's
sentence, widened.
3. (b) and (c) run through the lowering and the evaluator. The shared
face (`normalizeFilterComparandTypes`) does not judge an array under an
ordering operator ("Arrays outside `$in`/`$nin`/`$between`" are left to
the drivers) and let `$in: [[...]]` through, measured on the analytics
scope. No leak here needed a new shared-face arm, so nothing is serial
after objectstack-ai#20116.
4. See the census: 0 carry any shape.
5. The three `$not` JSON-only shapes remain unreachable from every
authored surface. CEL never lowers a bare `$not` over a field-less
`$ne`, an empty `$or`, or a nested relation value, and the only
production callers of `matchesFilterCondition` are the RLS write check,
the tenant wall (platform-derived filters), explain (RLS-compiled
filters) and objectql `having` (a `{ $field }` probe).

## Tests and gates

All builds and tests ran through `os-verify-lock.sh`, with exit codes
captured before any pipe.

**Merged head `4ade1b94`** (lock verdict: command-exit 0, held 717s on a
shared box). The only later commit, `39379b9b`, edits changeset prose.
- `@objectstack/spec` build, then `check:generated`: all 15 artifacts
current.
- formula: 39 files / 1127 tests passed; `typecheck` exit 0.
- plugin-security full suite: 140 files / 2895 tests passed; `typecheck`
exit 0.
- lint full suite: 109 files / 4232 tests passed.
- plugin-sharing full suite passed.

**Pre-merge head `bfd409ee`**:
- plugin-security 138 files / 2847 passed.
- lint 109 / 4232 passed.
- plugin-sharing 37 / 913 passed.
- objectql `having` pair: 2 files / 176 passed.
- driver-sql cross-field and formula-consumer files: 6 files / 445
passed, 2 skipped (live PG / MySQL).

**Generated artifacts:** `check:migration-registry`,
`check:spec-changes` and `check:upgrade-guide` were current after
regeneration, on both sides of the merge. `registry.ts` was regenerated
after the merge: 265 semantic entries, carrying this entry and main's
`inline-grid-column-currency-scale-refused`.

**eslint** on the 7 touched `.ts` files: 7 files, 0 errors, 0 warnings,
counted from `eslint --no-inline-config --format json`. The population
comes from `eslint.config.mjs`, whose header states it never enables
type-aware linting (no `parserOptions.project`), so this diff cannot
move the verdict on any untouched file. `packages/spec/**` sits in that
config's ignores for the generic block and is linted by its own block.

**Gates:** `dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` was re-derived on the actual change set at
`39379b9b` (90 commands; the same set at the merged head `4ade1b94`).
Every command ran, and its exit code was recorded before any pipe.
`--ran` reads: **90 derived, 87 run at exit 0, 3 NOT MEASURED, 0
unrun**.

The 3 NOT MEASURED are each a recorded exit 3, PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` and `check:type-check-debt` need every
workspace package's `dist/` (45 unbuilt here), and `check:i18n` needs
the built CLI. None of them is a verdict on this diff; CI runs them
after its full build.

The 87 include:
- `check-adr-0087-registration --base origin/main`: 1 declared-breaking
changeset, `registered cel-predicate-one-value-comparand-refused`.
- `check:nul-bytes`: 9704 files, no raw control bytes.
- `check:doc-authoring`.
- `check:keyed-text-bounds`.
- the spec `check:migration-registry`, `check:spec-changes`,
`check:upgrade-guide`, `check:api-surface`, `check:authorable-surface`
and `check:liveness`.

CI convergence is not awaited; it is in progress at report time.

Not measured here: live `mongod`, PostgreSQL and MySQL; the sharing
bootstrap skip, which is read from `celToFilterOutcome` and not driven
live.

## Acceptance notes (not filed)

- 2a's unreleased changeset
`.changeset/19886-formula-array-comparand-refused.md` and its entry
`rls-predicate-array-comparand-refused` still say `{ $field }`
references "evaluate exactly as before". That is now false for a
list-holding column. This PR does not edit them, because editing another
stage's changeset is a deliberate correction the seat rules on; this
changeset states the delta instead.
- objectql `having` with a `{ $field }` against a list-holding groupBy
column now refuses 400 on driver-memory, where the row carries the list.
driver-sql rows carry the stored JSON text and compare as before
(measured, both drivers).
- The analytics native compiler (`compileScopedFilterToSql`) bound an
array as one SQL parameter for `$gt: [...]` before this change. Nothing
reaches it now because the lowering drops the policy, but the compiler
itself still would for a host-supplied scope.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants