Skip to content

fix(plugin-security,service-analytics): a boolean comparand is judged by the spec verdict at the RLS compile seam and in the NativeSQL strategy - #21424

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21376-boolean-comparand-compilers
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-21376-boolean-comparand-compilers

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21376
Clause-②: no (narrowing)

Both compilers that build filters outside the engine's field-aware door now run the spec's boolean-comparand verdict (booleanComparandDoorVerdict, @objectstack/spec/data). They answer what the engine door answers. Neither copies the verdict's table or its words.

What changes

  • Position 1: the RLS compile seam (plugin-security, rls-compiler.ts).
    • The boolean arm sits beside the number arm in judgeCompiledComparands, in one walk, as the engine's walk does.
    • narrowPolicyNumberComparands became narrowPolicyComparands. At a field key the number arm judges first; where it does not judge, the boolean arm may. The two classes are disjoint.
    • Both arms share one field-spec walker (narrowedFieldSpec), so they judge the same positions by construction. The boolean positions are the number door's by identity, as in the spec.
    • The boolean arm reads booleanComparandFieldVerdict / booleanComparandDoorVerdict and words its refusal with booleanComparandRefusalMessage.
    • It reads the field metas from the guard's existing number map. That map records every declared column with its type / returnType, which is the same slice the boolean verdict reads. So security-plugin.ts is untouched.
    • A refusal leaves through the existing refused-comparand route. That is the envelope the number arm answers: the policy joins deniedBy, the read gets RLS_DENY_FILTER (zero rows), the write check answers PERMISSION_DENIED / 403, and the WARN detail is rooted at the clause (using.flag.$ne).
    • An accepted spelling narrows copy-on-write: 'true' / 'false', '1' / '0' and 1 / 0.
  • Position 2: the NativeSQL strategy (service-analytics, native-sql-strategy.ts).
    • compileClauses runs the same verdict on every filter it compiles: the query's where, each measure's own filter and the dataset's own scope.
    • The dataset door's runtimeFilter arrives merged into the where (DatasetExecutor.combineFilters).
    • The condition is lowered by lowerAnalyticsWhere (the shared faces first, both spellings) and then walked. A member is judged at the column resolveStorageTarget resolves it to, through the host's declaredFieldType hook. That is the same target the datetime lowering, the text-operator constant and $empty already ask.
    • An accepted spelling narrows copy-on-write. Anything else the verdict refuses is refused through invalidFilterError, the analytics where door's own envelope (INVALID_FILTER / 400), before any statement runs.
    • A host with no declaredFieldType hook judges nothing, the tiering every such hook here takes.
  • Docs. content/docs/permissions/rls.mdx said "Four ways a policy denies rather than leaks". It now names the declared-type comparand refusal as the fourth, for the boolean and the number classes.
  • Changeset. patch for both packages, Clause-②: no.

Measured before and after (the engine door is the target column)

Base 6d67ad5ec, head ef3ea8700. Two servers: SQLite (SqlDriver, better-sqlite3) and a private PostgreSQL 16.14 started for this run.

Position 1. The real SecurityPlugin middleware over a real ObjectQL, as a member whose permission set has one policy with using and check the same predicate. Two rows: t stores true, f stores false. "write" is an insert of a true / false row as the member.

predicate before: read, SQLite before: read, PG before: write t / f after: read (both) after: write t / f engine door (where, both)
record.flag == true t t admitted / 403 t admitted / 403 t
record.flag == 'true' none t 403 / 403 t admitted / 403 t
record.flag != 'true' f, t f admitted / admitted f 403 / admitted f
record.flag == 'yes' none t 403 / 403 none, both clauses dropped refused-comparand 403 / 403 INVALID_FILTER / 400
record.flag == 1 t t 403 / 403 t admitted / 403 t
record.flag == '1' t t 403 / 403 t admitted / 403 t

The negation was fail-open on both faces before: the read kept the excluded row on SQLite, and the write check admitted it on both dialects. On PostgreSQL 'yes' was read as true.

Position 2. Three faces were measured:

  • the dataset door through RestServer's own POST /api/v1/analytics/dataset/query route handler;
  • the cube read through AnalyticsService.query, which the runtime's POST /analytics/query relays verbatim;
  • the same service over AnalyticsServicePlugin's composition, narrowed to the ObjectQL strategy, as the engine-door column.

The base cells below use two rows (one true, one false).

runtimeFilter / where before: native, SQLite before: native, PG after: native (both) engine door
{ flag: true } 200, 1 200, 1 200, 1 200, 1
{ flag: "true" } 200, 0 200, 1 200, 1 200, 1
{ flag: { $ne: "true" } } 200, 2 200, 1 200, 1 200, 1
{ flag: "yes" } 200, 0 200, 1 400 INVALID_FILTER, no statement ran 400 INVALID_FILTER
{ flag: 1 } 200, 1 200, 1 200, 1 200, 1
{ flag: "1" } 200, 1 200, 1 200, 1 200, 1

The cube read showed the same cells, plus "false", $in and $nin of strings, all aligned after the change.

The raise-rule measurement (first)

  • git grep at 6d67ad5ec over examples, packages (create-objectstack templates included) and skills found 0 RLS predicates comparing with 'true' / 'false' / '1' / '0', and 0 analytics where / filter / runtimeFilter / FilterArray comparands spelling a boolean as text.
  • Every shipped using / check predicate compares an id, an email, an org or a null.
  • The one == "yes" hit is a showcase action-visibility predicate on a radio field. It is not a boolean, not RLS and not analytics.
  • Studio's policy condition builder at .objectui-sha 89cad75d5: NOT MEASURED. The objectui sibling is not checked out in this container.

Copy-on-write

  • PM assumption 4 measured: on main the compiled policy filter is not shared across requests. compileCelToFilter keeps no cache, and compileFilter is called per read and per write check (security-plugin.ts, the layer1 compile and the write-check compile).
  • The narrowing is copy-on-write anyway. The RLS pin's @objectstack/formula mock deep-freezes every filter compileCelToFilter returns, so every cell would throw on an edit in place. One test also reads the frozen filters back and finds the string still there.
  • The analytics pin deep-freezes every input filter and the registered dataset (its own filter and its measure's filter).

Pins

  • packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts, 26 cases.
    • 7 narrowed cells and 5 refused cells per dialect. The PostgreSQL cell runs where OS_TEST_POSTGRES_URL is set and is a named skip otherwise.
    • Each cell asserts the where twin's rows or envelope, the member's read, both writes, and the drop reasons.
    • One test checks that the detail is rooted at its clause, and one is the copy-on-write read-back.
  • packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts, 68 cases.
    • 16 filters at the cube read and at the dataset door, per dialect.
    • Each case asserts the engine face's answer and the native face's equality with it. It also asserts which strategy answered, and that a refusal ran no statement.
    • Two more tests: the FilterArray spelling with the cube-qualified member, and a registered dataset's own scope and measure filter read by the cube door.
  • After merging main, 45efcfa3d had widened the verdict to refuse a number other than 1 / 0, a Date and an array. Both compilers followed with no code change, because they hold no table of their own. The pins gained a 2 cell at each position.

Ablations (each mutation landed and was restored on disk by scripts/ablation-replace.mjs; blob equal to HEAD and git diff HEAD empty after each)

Run at 95bf8c4d0, before the merge and before the 2 cells were added. Both pins import their subject by relative path (./security-plugin.js, ../plugin.js), so the ablated code is src/, never a dist/. No build leg or dist preflight applies.

mutation pin result
boolean arm removed (false && before the RLS boolean field verdict) RLS, SQLite 12 failed / 1 passed (the == true control); the negation cell shows f, t again
RLS narrowing removed (narrows returns the comparand) RLS, SQLite 7 failed (every narrowed cell and the read-back) / 6 passed (the control, the 4 refusals, the detail)
NativeSQL narrowing removed analytics, SQLite 16 failed (the canonical-string, negation, list and combinator cells, the array spelling, the registered dataset) / 16 passed (controls, refusals, and '1' / $eq '0', which SQLite affinity already answered)
NativeSQL verdict call removed (judgedBooleanComparands returns its input) analytics, SQLite 22 failed / 10 passed (the controls and the '1' cells)

Verification (at the head named)

  • Gate derivation. dispatch-gates --commands at 78e4f3eb2 derived 96 commands. All 96 exit 0.
    • Four first answered PREREQUISITE NOT MET (exit 3) and were rerun after building what they read: check:skill-examples (client and client-react), check:i18n (its turbo closure), check:dual-build-cjs-loads (the full turbo run build), and check:type-check-debt, which overran a 9-minute timeout on the shared box and then finished.
    • --ran reconciliation: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.
  • At ef3ea8700. The only later commit is a 2-line type fix in the RLS pin.
    • check:type-check-coverage, check:type-check-debt, check:test-source-alias, check:cross-package-test-inputs and check:nul-bytes were rerun: all exit 0. The gate list is unchanged.
    • Both pins on SQLite and PostgreSQL: 26 and 68 passed.
  • Full test scripts at 78e4f3eb2.
    • pnpm --filter @objectstack/plugin-security test: 161 files, 3513 passed, 45 skipped.
    • pnpm --filter @objectstack/service-analytics test: 169 files, 3828 passed, 123 skipped.
  • Typecheck at ef3ea8700. pnpm --filter @objectstack/plugin-security typecheck (test layer included) and pnpm --filter @objectstack/service-analytics typecheck both pass. The first run found 2 TS2345 in the new pin, which ef3ea8700 fixes.
  • Lint, narrowed to the 4 changed TS files at ef3ea8700.
    • eslint --no-inline-config --format json read 4 files and found 0 errors and 0 warnings.
    • --print-config shows no parserOptions.project or projectService for any of them. eslint.config.mjs states it never enables type-aware linting, so this diff cannot move an untouched file's verdict.
    • The .md / .mdx files are outside eslint's files globs. The repo-wide pnpm lint is CI's.

Acceptance notes

  • The == 1 write cell moves. A policy record.flag == 1 used to refuse writing a true row while its read showed that row. The write check compared the stored true with 1. Narrowing 1 to true is the spec's verdict and the engine door's answer, so the write check now agrees with the read. The control == true does not move on any face.
  • The guard key RlsFieldGuard.number now feeds both arms. It always recorded every declared column. Renaming it to a class-neutral name would touch security-plugin.ts, which is outside this card's surface. Noted, not filed.
  • A relationship-path member (account.active) is judged at the column it resolves to in NativeSQL. The engine-door column for that case is NOT MEASURED here.
  • A formula returning boolean is deferred at NativeSQL. The host's declaredFieldType hook relays no returnType, because the plugin retired that relay. The engine refuses a formula filter one door earlier anyway.
  • The NativeSQL face does not run the number-comparand door either. This is the twin of position 2, measured on SQLite through AnalyticsService.query:
    • { amount: "abc" } answers 200 / 0, { amount: { $lte: "9999-12-31" } } answers 200 / 2, and { amount: true } answers 200 / 0;
    • the engine door answers INVALID_FILTER / 400 for each.
    • It is out of this card's scope and left untouched, and the report hands it to the seat.

Generated by Claude Code

claude added 7 commits October 2, 2026 13:12
…mparand verdict beside the number arm

A compiled policy comparand against a declared boolean column is judged by
`booleanComparandDoorVerdict` (`@objectstack/spec/data`), in the same walk as
the number arm: an accepted spelling narrows copy-on-write, any other string
is refused through the existing `refused-comparand` route.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…n-comparand verdict on every filter it compiles

The caller's `where` (the dataset door's `runtimeFilter` arrives merged into
it), each measure's own `filter` and the dataset's own scope are judged by
`booleanComparandDoorVerdict` before they compile: an accepted spelling
narrows copy-on-write, any other string is refused `INVALID_FILTER` / 400 in
the `where` door's envelope.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
… the RLS seam and the NativeSQL strategy

Each measured cell answers the engine door's column, on SQLite and (where
OS_TEST_POSTGRES_URL is set) PostgreSQL; the negation cell hides the excluded
row; the compiled policy filter and the registered dataset's filters are
deep-frozen, so the narrowing is held to copy-on-write.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ed-type comparand refusal; changeset for both packages

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
Takes the spec's widened boolean-comparand verdict (non-string comparands
refused), which both compilers here consume.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…her than 1 / 0) at both compilers

Both compilers read the spec's verdict and carry no table of their own, so
the widened refusal set arrives with the merge; the pins gain a cell for it,
and the comments, the docs bullet and the changeset now describe the set by
the verdict rather than as strings only.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…, as compileCelToFilter types its argument

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/service-analytics, touching 25 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/deployment/validating-metadata.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/kernel/contracts/data-engine.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/permissions/rls.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/releases/v17/17-5.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))
  • content/docs/releases/v17/17-6.mdx (via INVALID_FILTER (literal, a string literal in a comment in judgedNumberComparand; a string literal in a comment in numberArm; a string literal in booleanArm; a string literal in judgedNumberComparand; a string literal in numberArm))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 21 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 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7c67d2051cc1a54f1f42ba03078b22e22847f517 — the merge of head 2fd5e16a9738dafebd3c4768f0480c578313b159 into base 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5, 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 7c67d2051cc1a54f1f42ba03078b22e22847f517 && git checkout 7c67d2051cc1a54f1f42ba03078b22e22847f517
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 2fd5e16a9738dafebd3c4768f0480c578313b159 && git checkout -B drift-repro 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 && git merge --no-ff 2fd5e16a9738dafebd3c4768f0480c578313b159

node scripts/docs-audit/affected-docs.mjs --json 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Red check triaged: not this PR's · domain:services seat 2 · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-02T14:44Z


Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ

claude added 2 commits October 2, 2026 14:47
…et narrowing (minor, BREAKING), as the number twin at the same seam

Clause-②: no (narrowing), with the ADR-0087 not-required
(no-migration-prescription) disposition and the launch-window banner. No
code change.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
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

1 participant