Skip to content

fix(security)!: the RLS write check refuses a field-to-field comparison the read refuses — one comparison class, one answer per policy (#20355) - #20427

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20355-rls-write-check-cross-class
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-20355-rls-write-check-cross-class

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20355
Clause-②: yes (narrowing)

What this does

One RLS policy that compares two fields of no shared comparison class used to get two answers: driver-sql refused the read it scopes (INVALID_FILTER / 400), and the in-process write check compared the two raw values and admitted and stored the write. Both evaluators now read #20347's classification (crossFieldComparisonVerdict / crossFieldColumnVerdict, @objectstack/spec/data), and the write check refuses where the read refuses.

Measured, before and after

Through the real plugin-security + ObjectQL, policy operation: 'all', a member caller. Before = base 789b2ae, after = this branch. The same answers on better-sqlite3, sqlite-wasm and PostgreSQL 16:

policy read (using) by-id update / delete (using) insert with using as the check check insert check by-id update
record.status != record.amount (text vs number) 400 → 400 403 → 403 admitted, stored → 400, nothing stored admitted, stored → 400 admitted → 400
record.status != record.photo (text vs image) 400 → 400 403 → 403 admitted, stored → 400 admitted, stored → 400 admitted → 400
record.status != record.is_open (text vs formula; the card's formula cell) 400 → 400 403 → 403 admitted, stored → 400 admitted, stored → 400 admitted → 400
record.status != record.meta (text vs json holding one value) 400 → 400 403 → 403 admitted, stored → 400 admitted, stored → 400 admitted → 400
record.amount > record.status (number vs text) 400 → 400 403 → 403 403 → 400 403 → 400 403 → 400
record.status != record.title (text vs text, control) rows → rows admitted → admitted admitted → admitted admitted → admitted admitted → admitted

The formula cell answers exactly like the other two: the classification gives a formula field no class (no-class, reason formula), so it is refused on both sides.

driver-memory, measured out of tree (this package cannot declare @objectstack/driver-memory without a driver-memory-census disposition): the write answers as in the table (400 on every write cell, nothing stored), because the check runs in-process before any driver. Its read is unchanged and still admits (rows=1 for every cross-class cell): driver-memory has no { $field } arm at all and compares the marker object as a literal (#15104, closed not planned). So "refused on read and write" holds on SQLite, sqlite-wasm and PostgreSQL, and on memory for the write only.

A json or multiple column is a no-class column (list-or-object), so a comparison against one is now refused by its declared type, for every record. #19886 stage 2d judged it by the value each record held (a json column holding one scalar compared). driver-sql's read has always refused it by declared type, so this moves the write onto the read's answer too.

Compile faces (.claude/skills/pm-dispatch/references/compile-surfaces.md, re-verified at c80202c)

# face verdict
1 driver-sql applyFilterCondition (sql-driver.ts:16192), and by inheritance driver-sqlite-wasm and local-mode driver-turso changed: crossFieldComparisonClass reads crossFieldColumnVerdict. The answers are unchanged: parity 56/56 on the rewired driver before it retired, the cross-field conformance and reference suites green on SQLite and PostgreSQL.
2 turso RemoteTransport.buildWhereSQL (remote-transport.ts:2695) already compliant: refuses every { $field } comparand in remote mode, whatever the classes (uncompilableComparand, remote-transport.ts:4392).
3 service-analytics compileScopedFilterToSql (read-scope-sql.ts:696) already compliant: a read scope carrying a { $field } is declined by NativeSQLStrategy.canHandle and served on the engine path, where face 1 compiles or refuses it (#7598 ruling, read-scope-sql.ts:284). The /analytics/sql echo refuses the reference outright.
4 service-analytics lowerAnalyticsWhere (filter-normalizer.ts:2171) already compliant: same routing: a { $field } comparand reaches face 1 (filter-normalizer.ts:1453).
5 formula matchesFilterCondition (matches-filter.ts:305) changed: judges every { $field } comparison by crossFieldComparisonVerdict when the caller supplies the declared columns.
half objectql having-filter (applyHaving / matchesHaving, having-filter.ts:1131 / :1154) out of scope: it calls face 5 without declared columns, so its answers are unchanged. A having reference compares columns of the AGGREGATED row (group keys, aggregate aliases), not declared FieldType columns, so this classification does not cover them (#20127 classifies them separately). HAVING is evaluated in-process on every driver, so there is no read-side twin that could disagree.
unfrozen driver-memory checkCondition (memory-matcher.ts:361) out of scope: no { $field } arm to attach a class rule to (#15104, closed not planned). Measured above: its read compares the marker as a literal.
unfrozen driver-mongodb translateFieldOperators (mongodb-filter.ts:962) already compliant: refuses every { $field } reference (#19949, mongodb-filter.ts:264).

Tests (measured head c80202c)

  • formula: new matches-filter-cross-field-class.test.ts: every declared class against every other, all six operators, expectations written from the table's labels and not from the verdict function; record independence; $and / $or / $not nesting; the addDays form; undeclared, dotted and unjudged columns left alone; without fields unchanged; the withheld message and the carried diagnostic. 22/22 at c80202c. Full package (at 0ee6f4c; the merge of main brought no change to formula, driver-sql, lint or plugin-security): 41 files, 1213 passed; typecheck exit 0 (check:test-typecheck OK, debt unchanged).
  • plugin-security: new rls-check-cross-class-field-refused.test.ts, through the real engine on better-sqlite3, sqlite-wasm and PostgreSQL (opt-in, OS_TEST_POSTGRES_URL). Cells: the read, by-id update and delete, the using-as-check insert, the check insert, array insert and by-id update. Each refusal asserts code + status and that nothing was stored or changed; the 400 names neither column; exactly one WARN names the policy and both columns; the same-class control is admitted. 48/48 at c80202c with PostgreSQL 16. Full package: 143 files, 3046 passed, 16 skipped (the PostgreSQL cells, no URL); typecheck exit 0.
  • driver-sql: new alias pin, 8/8; cross-field-reference + cross-field-conformance + alias pin with PostgreSQL: 290 passed, 1 skipped at c80202c. Full package: 194 files passed, 11 skipped; 3172 tests passed, 178 skipped; typecheck exit 0.
  • lint: 115 files, 5314 passed; typecheck exit 0. validate-rls-predicate-enforceability.cross-class-field.test.ts: 569/569 at c80202c.
  • Ablations, each through scripts/ablation-replace.mjs with a restore trap:
    • A: the evaluator's if (refusal) throw crossFieldClassError(refusal); was replaced by void refusal;. Anchor 1 → 0, blob 8e920434 → 58b1e295. formula was rebuilt (exit 0). ablation-dist-preflight read the marker in 2 built files on the pristine build and absent from all 6 on the mutated one. The formula pin went 19 failed / 3 passed and the plugin-security pin 45 failed / 3 passed (the three survivors are the same-class controls). Restored: blob == HEAD 8e920434, git diff HEAD empty, rebuilt, marker present, tree clean; 22/22 and 48/48 again.
    • B: the gate's matchesFilterCondition(image as any, f as any, checkFieldOptions) was stripped of its third argument (blob af0a9563 → 8f254f2f). plugin-security went 45 failed / 3 passed. Restored blob == HEAD, 48/48.
    • C (reverse, predicted green): driver-sql's pre-rewire body was put back in place (blob 4760990e → 77031c84). The alias pin and cross-field-reference went 56/56 green, so the rewire did not move an alias. Restored blob == HEAD, tree clean.
    • Directions observed: red, red, green, as predicted.
  • Lint (narrowed, proved): eslint --no-inline-config --format json over the 10 .ts files this diff touches plus the 36 the main merge brought in reported 46 files, 0 errors, 0 warnings (no file ignored). Type-aware linting is not enabled (eslint.config.mjs:328: no parserOptions.project, no typed rules), so this diff cannot move the verdict of any file it does not touch. The full pnpm lint is CI's.

Gates (c80202c, after merging origin/main 50e273f)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 families. All 68 ran, each exit code captured before any pipe, and all are 0. Three first answered exit 3, PREREQUISITE NOT MET: check:i18n, check:dual-build-cjs-loads and check:type-check-debt. They were re-run after building their stated prerequisites, and all three are 0: i18n "OK (9 packages)", cjs "104 entry points across 66 packages load", type-check-debt "4 ledger entries re-measured, none above its recorded number". --ran reconciles: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates: check-adr-0087-registration ✓ (1 declared-breaking changeset with a disposition), check-changeset-no-major ✓, check-empty-changeset ✓.

Patch rounds

  • Round 1 (e72518a). Clause-②: yes (narrowing) in the changeset and in this body, because formula's root entry grows.
  • Round 2 (cc0bf6e). The D3 entry, the changeset's registered marker and '@objectstack/spec': patch, the two spec comments, and the one lint header sentence. origin/main was merged twice with true merge commits: dbddf02, then e01d347, which carries driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106's reclaimSpace. Everything below was measured at cc0bf6e.
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 96 families, the spec families now among them. All 96 ran, each exit code captured before any pipe, and all 96 exit 0 on the first run. --ran reconciles: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.
    • pnpm --filter @objectstack/spec check:generated: all 15 artifacts are up to date, including check:migration-registry, check:spec-changes and check:upgrade-guide. pnpm check:adr-0087-registration: 0.
    • spec --project local src/migrations plus the classification test: 4 files, 178 passed.
    • On the merged code, driver-sql's full suite passes: 195 files passed and 11 skipped, 3176 tests passed and 178 skipped. Its typecheck exits 0.
    • The formula pin passes 22/22 and the lint cross-class test 569/569.
    • The plugin-security pin passes 32 with 16 skipped. PostgreSQL was not provisioned this round; its cells passed 48/48 at c80202c, and this round changed no code.
  • Round 3 (2698fa1). Prose only; no logic or test change. origin/main was merged twice more with true merge commits: 87c37ae (4e430ba), then 0fcb101 (2698fa1). Everything below was measured at 2698fa1.

Deviations from the claim's file surface

  • packages/plugins/plugin-security/src/security-plugin.ts (step 3.6 and writeCheckFieldOptions) and rls-compiler.ts (compiledPolicyNameOf) are source, where the claim named plugin-security for tests only. H2 held: the comparison is evaluated in matches-filter.ts. That evaluator has no schema, though, and the declared columns exist only at its caller, the write gate. Naming the policy needs the compile seam, the one place that still knows each policy's filter.
  • packages/lint/src/validate-rls-predicate-enforceability.cross-class-field.test.ts: one assertion line, because it pinned the sentence this PR changes. In crossClassConsequence, the changed text is the shared write constant plus the check branch's closing sentence. The using branch interpolates the same constant, so the using finding's last clause reads the new answer too.
  • packages/drivers/driver-sql/src/sql-driver-20355-cross-field-class-driver-aliases.test.ts: new, to pin the alias layer after the parity test retired.
  • All three deviations above were accepted by the seat's amended claim 5868635246. Round 2's packages/spec files (the D3 entry, its regenerated registry.ts and the two comments) and the lint header sentence are in the claim re-posted as 5868966379.

Acceptance notes


Generated by Claude Code

…ison the read refuses

The write check's evaluator (formula matchesFilterCondition) now takes the
object's declared columns and judges every { $field } comparison by the spec's
crossFieldComparisonVerdict, refusing a non-comparable one with INVALID_FILTER
/ 400 before any record is read. plugin-security's write gate hands it the
declared columns and logs the refused policy and columns server-side.
driver-sql's crossFieldComparisonClass delegates to crossFieldColumnVerdict,
layering only its own aliases. lint's check-clause consequence sentence states
the new write answer.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…d and write; retire the lift parity test

formula: every declared class against every other, all six operators, record
independence, nesting, the offset form, the withheld message and the carried
diagnostic. plugin-security: through the real engine on better-sqlite3,
sqlite-wasm and (opt-in) PostgreSQL, the read, by-id update/delete, the using-
as-check insert, the check insert and by-id update, the named server log, and
the same-class control. driver-sql: the alias layer crossFieldComparisonClass
keeps above the spec classification. The #20347 parity test retires with the
private copy it held equal to the export.

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

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/driver-sql, @objectstack/formula, @objectstack/lint, @objectstack/plugin-security, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/src/data/filter-cross-field-comparison-class.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))

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
  • 2 changed file(s) yielded no anchor (packages/formula/src/index.ts, packages/spec/src/data/filter-cross-field-comparison-class.ts) — pages documenting those are invisible to this run
  • 9 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 — 142 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 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 35515256da406a9199f3fa1011305a702099e49f — the merge of head 2698fa15bdd83e3392e752b16e28172469180d7c into base 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5, 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 35515256da406a9199f3fa1011305a702099e49f && git checkout 35515256da406a9199f3fa1011305a702099e49f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 2698fa15bdd83e3392e752b16e28172469180d7c && git checkout -B drift-repro 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 && git merge --no-ff 2698fa15bdd83e3392e752b16e28172469180d7c

node scripts/docs-audit/affected-docs.mjs --json 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5

⚠️ 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 0fcb10184ce5bba6d5538b555b3a898e5ecfc5a5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… surface grows

@objectstack/formula's root entry gains findCrossFieldClassRefusal,
crossFieldClassRefusalCarriedBy, their two types and an optional third
argument on matchesFilterCondition, so the widening question answers yes;
the narrowing arm stays for the write check's new refusal.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c80202c5bad581d7ed7bf67b03f9aad1246499cb
Local-runs: none

① Derived judgments

Read from the net diff against main at 50e273fd7 (12 files, +954/−153), the head's source of the four packages, card #20355 with every comment, and #20347's ACCEPT notes 1 and 3. Each accept-set or public-surface change the diff implies, judged:

  1. @objectstack/formula — the write check narrows. matchesFilterCondition(record, filter, options?) gains options.fields; given it, findCrossFieldClassRefusal walks the filter before any record is read and throws INVALID_FILTER / 400 on the first { $field } comparison under $eq / $ne / $gt / $gte / $lt / $lte whose two DECLARED columns answer cross-class or no-class from crossFieldComparisonVerdict. RIGHT. It is the read's rule: driver-sql's applyCrossFieldComparison asks the class of both columns before it reads an addDays offset, for the same six operators (CROSS_FIELD_COMPARISON_OPERATORS), and the CEL lowering emits { $field } only under those six. comparable and unjudged never throw; an undeclared or dotted column is skipped (driver-sql refuses those by rulings 1–2 of [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222, a different arm). Without fields the only new statement is behind options?.fields, so the evaluator is the old one — the "without the declared columns" pin says so.
  2. Public surface. The root entry @objectstack/formula gains findCrossFieldClassRefusal, crossFieldClassRefusalCarriedBy, the types MatchesFilterOptions and CrossFieldClassRefusal, and the optional third parameter — a widening, needed: the gate must read the refused comparison off the error to log it. RIGHT. @objectstack/plugin-security's compiledPolicyNameOf is exported from rls-compiler.ts but the package index re-exports only RLSCompiler and RLS_DENY_FILTER, so plugin-security's public surface does not move. driver-sql and lint move no surface.
  3. Every write path the check judges refuses what the read refuses — traced, not taken from the report. Single and array insert: the engine runs postHookWriteImageCheck.evaluate(live) over every live row after beforeInsert and ahead of any statement (engine.ts about :12488), and satisfiesCheck throws on the first image. By-id update: the middleware judges the payload-merged image itself (security-plugin.ts about :3257) and throws before next(); the engine's by-id seam is a second judgement of the stored row. Predicate update: the same seam runs over every matched row merged with the final payload ahead of updateMany (engine.ts about :14373). using standing in as the check: writeCheckPolicies hands using-only policies to compileFilter(…, 'check'), whose predicate is policy.using — the using-insert cell pins it 400. Delete: the check never judges a delete (post-image rule, ADR-0058 D4); a by-id delete fails closed 403 at the pre-image gate and a predicate delete carries using into the composed AST, where driver-sql refuses 400 — nothing is admitted, and this PR is RIGHT to leave delete alone. The refusal is record-independent, so a by-id update on a missing row still answers 400. One asymmetry, not an admission: a predicate update that matches ZERO rows judges no image (evaluate([])) and completes as a no-op where the read answers 400 — nothing is stored; recorded in ③.
  4. The reverse — nothing same-class that the check admitted is refused now. evalNode / evalOp are untouched; the formula pin drives 14 declared columns over all six operators with expectations from its own label table, and the plugin-security control (text vs text) is admitted on read and every write. The cells that moved are exactly the changeset's: a json or multiple column (was judged per record by [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2d's assertComparableReference, which compared when the record's value happened to be a scalar; now no-class by declared type — driver-sql always refused it by declared type) and file-vs-file or formula-vs-formula (were compared by raw value; now no-class). Every moved cell is one the read refused. RIGHT.
  5. No column name reaches the wire. The 400's text is a constant that names no field, operator or type; the refusal rides under a non-enumerable Symbol.for key, so JSON.stringify, a spread and the structured-clone boundary drop it (pinned). Paths: the by-id middleware throw (the same error object), the two engine seams (propagate; the insert pin reads the message prefix and asserts neither column), the pre-image gate (turns driver-sql's 400 into a 403 that names nothing), and the predicate paths (driver-sql's withheld finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 form). The only carriers naming columns are ctx.logger.warn (server) and lint's finding (the author's own text). RIGHT.
  6. The WeakMap cannot name the wrong policy. compileFilter builds every policy's filter fresh per call (compileCelToFilter memoizes nothing but a WARN-dedupe set of sources); judgeCompiledComparands returns the faces' own reference or a copy, and whichever it returns is the object pushed and mapped; the composed answer is filters[0] itself or { $or: filters } of those very objects; computeWriteCheckFilter returns it unwrapped and checkParts holds it by identity inside a closure the engine calls (no serialization). A single policy whose predicate is itself a disjunction is mapped as a whole and correctly not unpacked. Shared constants (RLS_DENY_FILTER, an empty filter) carry no { $field } and are never attributed. The one '(unnamed)' shape is a policy declared without a name. RIGHT.
  7. driver-sql answers every read as before. Base order: multi-valued → formula → JSON/file → numeric → boolean/toggle → date/datetime/time → text. Head: crossFieldColumnVerdict({ type, multiple: decl.multiple === true }) for every FieldType member — the partition was held equal to the base function on all 55 × 55 declared pairs plus every multi-capable member flagged multiple by the parity test at 4605cc7 (56/56 before it retired), and multiple is read through the same predicate isMultiValuedColumn is (isMultiValueField with multiple === true); then, for a type the spec answers undefined for, object / array → null off JSON_COLUMN_TYPES, integer / int / float → numeric off NUMERIC_SCALAR_TYPES, everything else including the absent-type string default → text — the base's answer for every non-FieldType input (an alias cannot be multi-valued under either predicate, since MULTI_CAPABLE_TYPES holds FieldType members only). The six class names in the withheld diagnostic are the same strings. RIGHT; retiring the parity test is sound because the driver's FieldType half IS the export by construction, and the new alias pin covers the layer that remains.
  8. @objectstack/lint's sentence. "the in-process write check refuses the comparison by the same classification (INVALID_FILTER / 400), so every insert or update it judges is refused and nothing is stored" — TRUE for every write the check judges (item 3); the using branch interpolates the same constant, and "admits no write at all" is TRUE for the check clause. The one moved assertion pins the new text. RIGHT.

② Semver level

③ Boundary flags

open_questions on report 5868561524 is empty. Every deviation, every out-of-scope finding and every execution note answered:

  • Deviation 1 — plugin-security source beyond the claim (security-plugin.ts step 3.6 and writeCheckFieldOptions; rls-compiler.ts compiledPolicyNameOf): accepted by amended claim 5868635246; judged correct here (① 3, 5, 6). writeCheckFieldOptions reads the same two sources in the same order as loadObjectFieldNames and drops a field with no string type; when neither source answers it returns undefined and the evaluator judges values only — the field guard's existing rule, and an object the engine cannot load a schema for is one it cannot write to either. Answered, no action.
  • Deviation 2 — the lint constant moves the using finding's last clause and one assertion: accepted; TRUE (① 8). No action.
  • Deviation 3 — the alias pin: accepted; it pins exactly the layer the rewire kept; the absent-type default is unpinnable because createColumn refuses a field with no type, and its answer is text on both sides by the same String(type || 'string') expression. No action.
  • Deviation 4 / finding 5 — execution note 3 on PostgreSQL and driver-memory. PostgreSQL: the plugin-security pin skips without OS_TEST_POSTGRES_URL, and CI's live-dialect job does not run this package, so the only PostgreSQL reading of the write cells is the dev's local PostgreSQL 16 — recorded as a report, not a gate verdict. driver-memory: the note's "refused on read" cell is UNMET on memory, by ruling — [finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104 (closed, not planned) gives driver-memory no { $field } arm, so its read evaluates the marker as a literal and returns rows; after this PR memory gives two answers (read admits, write 400) where it gave one permissive answer, the refusing side being the safe one. The amended claim is silent on it. ESCALATED: the seat records the disposition on the card — accept under [finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104, or reopen the memory arm — so execution note 3 is closed in writing rather than by omission.
  • Deviation 5 — json / multiple by declared type: answered TRUE and aligned with the read (② sentences); stated in the changeset. No action beyond the ② marker question.
  • Deviation 6 — "naming the policy and fields" met server-side: answered. Execution note 2 asks for the read's envelope AND the names; the read's envelope withholds them (finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929), so both halves can only hold with the names in the WARN and off the wire, which is what ships (pinned: one WARN naming the policy and both columns, a 400 naming neither). Accepted.
  • Deviation 7 / finding 2 — two packages/spec comments name the retired parity test. This PR creates the stale pointer: filter-cross-field-comparison-class.ts (about :73–76) still says the driver "is held to this table by a pairwise parity test … so the two cannot disagree on a single pair while both exist", and its test header names the deleted file. No gate catches a comment pointing at a deleted path; harmless at run time. Spec-lane follow-up, foldable into the same card as the ② semantic-entry item.
  • Deviations 8–9 — attribution trailer; the PostgreSQL data dir outside the scratchpad: process notes; no action.
  • Finding 1 — security.explain (class a). Confirmed: explain-engine.ts about :872 calls matchesFilterCondition(record, filter) with no declared columns, so it answers visible: true for a policy both enforcement faces now refuse. Its own answer is unchanged by this PR, so the defect is not made worse; the fix shape is this PR's options.fields. Filed next fire by the seat, as the brief says.
  • Finding 3 — the lint header's [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347 paragraph still says, in the present tense, "The in-process write check has no class rule and compares the two raw values", while crossClassConsequence's doc in the same file now says the opposite. A comment-only contradiction this PR introduces; carrier none; one sentence in the next lint touch. Not blocking.
  • Finding 4 — listHoldingComparisons' second spelling: untouched and unchanged by this PR; [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347 ACCEPT carrier 3 stands. No action here.
  • Finding 6 — the addDays arm and [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 rulings 1–3. The PR's acceptance note reads "the write check answers that comparison false (NO_OFFSET_BASE), so the write fails closed with 403 rather than being refused" — not universally TRUE. addWholeDays parses any string with Date.parse, so a TEXT base column holding a date-shaped string shifts and compares (admitted when the comparison holds); resolveDayOffset coerces a numeric string, so a TEXT offset column holding "3" is read as three days. driver-sql refuses both 400 (the base must be date or datetime; the offset column must be numeric). Same read-refuses / write-admits family, a different rule (the An ADR-0021 dataset measure cannot express a deadline that is another column plus an offset held in a third column — the grace-aware on-time rate has no spelling #14104 offset arm on same-class text columns), pre-existing, and NOT made worse here — the class rule runs first and refuses every cross-class pair; the residual is same-class text with an offset. ESCALATED as a sibling finding for the seat to file (the dotted-path and tenant-column arms of rulings 1–3 are unmeasured on the write as the PR says); with it, the zero-matched-rows predicate update of ① 3 (a no-op where the read answers 400; nothing stored). Not blocking.
  • A declared type outside FieldType (string, integer, …) is unjudged on the write and classified by alias on the read — a divergence reachable only through a registration the metadata schema never validated; noted, no action.
  • Execution notes. 1 (no second copy): met — driver-sql delegates, the write check reads the export, lint reads the export. 2: met as above. 3: met on SQLite and sqlite-wasm in CI, on PostgreSQL by local measurement, on driver-memory for the write only (escalated). 4: minor met; the Clause-② arm moved by the amendment (②).
  • Serial constraints. reclaimSpace (driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106) is untouched: the sql-driver.ts hunks are at :26 (one import) and :2729–2790 only. The merge of origin/main (50e273fd7) is the PR's own second parent and brought no change to the four packages.
  • Gate verdicts on this head — final read after every run completed (34 check-runs). 24 success, including Check Changeset, Governed Surface Queue Guard, both single-writer guards, the card-claims-branch guard, Build Core, Test Core (2/6), (4/6), (6/6), Type Check · source gates, · consumer gates, · debt ledger, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI and the three Dogfood Regression Gate shards; 3 skipped on the roster (Build Docs, Console Pin Gate, Packed-tarball smoke); 5 cancelled — Test Core (1/6), (3/6), (5/6), Lint & Repo Gates, Type Check · workspace — at 11:10:29–11:11:06Z, after the branch moved to e72518ad7 at 11:09:12Z; and 2 failure — the rollups Test Core and TypeScript Type Check, whose "verify every shard / lane succeeded" steps fail on cancelled lanes (no lane failed on the code). The derived families those five lanes carry are therefore UNMEASURED at this head — not green — and the head is superseded.

Implemented-by: claude/issue-20355-rls-write-check-cross-class
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: FAIL — on ② and on the gates of this head, not on the contract. ① carries no defect: the write check refuses, on every path it judges, exactly the comparisons the read refuses, by the one classification, and admits every same-class comparison it admitted before; driver-sql answers every read as before. What fails at c80202c5b: the changeset's and the PR body's Clause-②: no (narrowing) is false for a diff that widens @objectstack/formula's root entry, and five gate lanes on this head were cancelled when the branch moved, leaving their families unmeasured. The head e72518ad7 that carries the corrected line is where the record is re-rendered once its check-runs conclude, with two dispositions recorded on the card first: the ADR-0087 semantic-entry question (②) and execution note 3's driver-memory read cell (③).

…under protocol major 18

One ADR-0087 D3 semantic entry, rls-predicate-cross-class-field-comparison-refused,
names both arms: the authoring arm os validate gained and this write-check
arm; registry.ts regenerated by gen:migration-registry. The changeset moves
to `registered` and adds @objectstack/spec at patch. The two spec comments
and the one lint header sentence this PR had made stale now say what holds.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cc0bf6e5dfc4cfbf4d6fb6969943c531754bf795
Local-runs: none

① Derived judgments

Delta review on the head that answers record 5868954092 (FAIL at c80202c5b on ② and on that head's cancelled lanes). Inputs added: amended claim 5868966379, patch-round reports 5868712815 and 5869809165, the edited PR body, git diff c80202c5b cc0bf6e5d separated into the PR's own commits and the two main merges, the net diff against main at e01d34730, and the check-runs on this head.

  1. The PR's own delta since c80202c5b moves no logic, so ① of record 5868954092 carries over. The branch's first-parent line is e72518ad7 (one changeset line: no → yes (narrowing)), the merge 364c150e3 (main dbddf02c1), 7b0c82e2b (six files: the changeset, one lint header paragraph, two packages/spec header comments, the new semantic entry, the regenerated registry.ts) and the merge cc0bf6e5d (main e01d34730). The added and removed lines of the net diff for packages/formula, packages/plugins/plugin-security and packages/drivers/driver-sql at this head are byte-identical to those at c80202c5b; the four test files and lint's crossClassConsequence are untouched; lint's round-2 change is a doc comment. Every judgment of ① at c80202c5b — the write check refuses on single and array insert, by-id update, predicate update and using-as-check exactly what driver-sql's read refuses; delete is the read's; no same-class comparison moves; no column name reaches the wire; the WeakMap cannot mis-attribute; driver-sql answers every read as before; the lint sentence is TRUE — stands unchanged. RIGHT.
  2. The two merges brought nothing by hand. Merge 1: no file is touched by both the PR and 50e273fd7..dbddf02c1, and every PR file is identical on both sides of it. Merge 2: the only overlap is packages/drivers/driver-sql/src/sql-driver.ts, where driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106's reclaimSpace (e01d34730) landed; the PR's own delta to that file (the import at :26 and crossFieldComparisonClass at about :2729–2790) is identical before and after the merge, and every other PR file is identical across it. The net diff against main e01d34730 is 16 files, +1105/−165, the same list the API reports for the PR. The reclaimSpace region is not touched. RIGHT.
  3. The new D3 semantic entry covers the family, both arms. 18.rls-predicate-cross-class-field-comparison-refused.ts (protocol major 18, the step the three precedents use) names in surface the surfaces rowLevelSecurity[].using, .check and sharingRules[].condition, the six operators, the cross-class pairings, the file family by every member of FILE_REFERENCE_TYPES (image, file, avatar, video, audio — the set exactly), the formula field, and the write-check face (matchesFilterCondition with options.fields, the six operator spellings, a json or multiple field). Its reason names the authoring arm ([finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347: rls-predicate-unenforceable on using and check for every operation at os validate, build, lint and the permission save door; sharing-rule-unlowerable-condition at validate, build and lint — the reach [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347's ACCEPT recorded, the sharing arm CLI-only) and the write-check arm (RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355). Both classification arms are inside it: cross-class and no-class (file, formula), with the list-or-object family named the way the precedents name it. Judged against the three precedent entries' shape: the same five fields in the same order, the no-backtick note on surface, the "Metadata AT REST is not rewritten and this entry adds no D2 conversion" clause, the ADR trailer, and an acceptanceCriteria that names a mechanical search plus a re-check of intent. RIGHT.
  4. registry.ts is the generator's output and nothing else. The hunk is 66 added lines, 0 removed: the entry's nine comment lines (equal line for line) and its object literal (51 string literals, equal one for one, the five keys in the entry's order), inserted between rls-predicate-array-comparand-refused and rls-predicate-stored-list-ordering-refused — id order, which the registry header states is the generated regions' rule. No other line of registry.ts moves. The precedent commits (c1641868a, 4d7e740d3, de091b50e) touched the same pair of files and nothing else in packages/spec, so no spec-changes.json or upgrade-guide regeneration is owed in-PR; check:migration-registry --check runs inside Lint & Repo Gates, success on this head. RIGHT.

② Semver level

③ Boundary flags

open_questions is empty on both patch-round reports. Every deviation, finding and ruling answered:

  • Round 1, deviations 1–2 (the worktree re-added and removed; the commit made before the gate run): process; closed.
  • Round 2, deviation 1 — the marker carries no "why" beside the id. Closed: the gate's reader splits everything after registered into ids; the precedents spell it the same way; the why is the BREAKING paragraph's sentence.
  • Round 2, deviation 2 — origin/main merged twice, driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106 in the second. Closed: both merges verified clean (① 2); the PR-side sql-driver.ts delta is identical across the merge and reclaimSpace is not touched.
  • Round 2, deviation 3 / finding 1 — the stale lint phrase beside the fixed sentence. "held to it by a pairwise parity test there" is FALSE at this head and this PR made it so; the claim allowed one sentence and the dev kept to it. ESCALATED to the seat: allow the five-word phrase in a patch round (the cheapest moment, the same paragraph), or carry it to the next lint touch as the dev proposes. Not blocking.
  • Round 2, deviation 4 (worktree): process; closed.
  • Round 2, finding 2 — the addDays arm, carrier domain:engine seat ([PM seat] domain:engine — 🟢 os-litant · session_01EUBvqtauTDmHi2ZgY759p2 #6367). Recorded; the measurement should include the numeric-base case as well as the text-base and text-offset cases (② above), since all three are read-refused, write-admitted shapes.
  • Seat ruling 1 (claim 5868966379) — the family's D3 entry ships in this PR. Met at this head (① 3–4, ② marker). Closed. Three prose items in the entry, a forever artifact that the release's upgrade guide renders: the FALSE file clause in reason, the missing SQL-driver qualifier in acceptanceCriteria, and tree missing from the text-class list in replacement. ESCALATED to the seat: a patch round while the PR is open is the cheapest fix; otherwise a spec-lane edit to the entry (precedent de091b50e edited an existing entry). The prescription an upgrading agent acts on is right in every case, so this does not block.
  • Seat ruling 2 (claim 5868966379) — execution note 3's driver-memory read cell accepted under [finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104. Recorded on the card and in the PR body; consistent with the code (driver-memory has no { $field } arm; the write refuses in-process). Closed.
  • Docs (the docs-drift bot's four pages). content/docs/permissions/field-level-security.mdx, content/docs/permissions/index.mdx, content/docs/plugins/packages.mdx and content/docs/ui/forms.mdx hold no passage on an RLS check or using field-to-field comparison: their hits are the system-context bypass ("skips the check entirely"), a generic RLS mention, a feature bullet and a hook's ctx.previous check. This PR makes none of them false. Beyond the four: content/docs/permissions/rls.mdx (the check row: every row an insert or update writes, hooks applied, one failing row refuses the whole write) and content/docs/permissions/authorization.mdx (layer 5: using read filter, check write post-image, fail-closed) stay TRUE. Three hand-written or generated passages describe { $field } without the comparison-class rule that driver-sql has applied since [spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222 and the write check applies now — content/docs/protocol/objectql/query-syntax.mdx "Comparing Two Fields" ("the two return the same rows"), skills/objectstack-query/rules/filters.md "Field References" ("Same rows either way"), and the spec-generated content/docs/references/security/rls.mdx using / check descriptions, which mention no field-to-field comparison at all. None is made false by this PR (each was already untrue for a cross-class pair on the SQL read, and this PR narrows the divergence they overlook); recorded as a pre-existing docs gap for the docs lane and, for the Zod descriptions, the spec lane. skills/** is Tier H and not this PR's.
  • Serial constraints. driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106 merged and untouched (① 2). No other open PR claims the paths (claim 5868966379's census).
  • Gate verdicts on this head — read last, 12:34:20Z, 42 check-runs, all completed. 37 success: Test Core (1/6) through (6/6) and the Test Core rollup, Lint & Repo Gates, Type Check · source gates, · consumer gates, · debt ledger, · workspace and the TypeScript Type Check rollup, Build Core, Check Changeset (both events), Governed Surface Queue Guard, Spec property liveness, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI, the three Dogfood Regression Gate shards and their rollup, Check Documentation Links, Flag docs affected by code changes, Check PR Size and Auto Label (the 11:41Z push runs), and the four card / single-writer / part-of guards on both events. 5 skipped: Build Docs, Console Pin Gate and Packed-tarball smoke on the roster, plus the Auto Label and Check PR Size re-runs on the 12:25Z body-edit event after their push runs had succeeded. No failure; nothing in progress. The cancelled lanes of c80202c5b are green here.

Implemented-by: claude/issue-20355-rls-write-check-cross-class
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS — the contract holds and the two grounds of the FAIL at c80202c5b are cleared at this head: Clause-②: yes (narrowing) stands in both the changeset and the PR body, and every gate lane is green. The PR's own delta since c80202c5b moves no write-check, evaluator, driver-sql or test logic, so ① carries over whole; the family's D3 entry is registered, new, generated into registry.ts untouched by hand, and names both arms; @objectstack/spec: patch is right. Carried, not blocking: three prose corrections inside the new entry (the file clause in reason, the SQL-driver qualifier in acceptanceCriteria, tree in replacement), the stale five-word lint phrase, the numeric-base case for the #6367 addDays measurement, and a pre-existing docs gap on the comparison-class rule.

…t header names the delegation

The entry's reason no longer says a file field has no stored column: the
file family is refused by name for the ADR-0104 dual-encoding reason the
spec module gives. acceptanceCriteria qualifies the 400 read with "on the
SQL drivers", and the replacement's text class lists all four reference
types (tree included). registry.ts regenerated by gen:migration-registry.
The lint header's #20347 paragraph says driver-sql now delegates through
crossFieldColumnVerdict instead of naming the retired parity test.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2698fa15bdd83e3392e752b16e28172469180d7c
Local-runs: none

① Derived judgments

Second delta review, on the head that answers record 5869972995 (PASS at cc0bf6e5d, with carried prose items). Inputs added: patch-round-3 report 5870735297, the edited PR body, git diff cc0bf6e5d 2698fa15b separated into the PR's own commit and the two main merges, the net diff against main at 0fcb10184, and the check-runs on this head.

  1. The PR's own delta since cc0bf6e5d moves no logic, so ① and ② of record 5869972995 carry over whole. The branch's first-parent line is the merge 4e430baf8 (main 87c37aec1), the commit 859dfa7e9 (three files, +22/−12: the semantic entry, registry.ts, and one phrase of lint's header paragraph) and the merge 2698fa15b (main 0fcb10184, docs only). The net diff against main 0fcb10184 is 16 files, +1117/−167, the list the API reports. Its added and removed lines for packages/formula, packages/plugins/plugin-security, packages/drivers/driver-sql, the four test files, the changeset and the two packages/spec/src/data header comments are byte-identical to the net diff at cc0bf6e5d; validate-rls-predicate-enforceability.ts differs from the prior head by the one two-line phrase only. RIGHT.
  2. The two merges brought nothing by hand. Neither main range (e01d34730..87c37aec1, 87c37aec1..0fcb10184) touches any of the PR's 16 files, and every PR file is identical on both sides of each merge (cc0bf6e5d versus 4e430baf8; 859dfa7e9 versus 2698fa15b). The reclaimSpace region of sql-driver.ts is untouched. RIGHT.
  3. The three entry corrections, judged against the spec source. reason: "A formula field is virtual, with no stored column to reference. The file family is refused by name, whatever the deployment stores: during the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path." — TRUE, and it is filter-cross-field-comparison-class.ts's own rationale for the two families word for word (formula: "virtual: there is no stored column to reference"; file: "refused BY NAME and independent of the deployment. During the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path"). acceptanceCriteria: "every read it scopes answers 400 on the SQL drivers" — TRUE (driver-sql, and by inheritance driver-sqlite-wasm and local-mode driver-turso, PostgreSQL measured; driver-memory admits, and the entry's reason says so). replacement: "text with text (the string types, autonumber, a single select or radio, a single lookup or user, a master_detail or a tree)" — TRUE: REFERENCE_VALUE_TYPES is exactly lookup, master_detail, user, tree, every member text class by the table; "single" qualifies exactly the MULTI_CAPABLE_TYPES members among them (select, radio, lookup, user), and master_detail and tree are not multi-capable. RIGHT.
  4. registry.ts moves only in the entry's lines, as generator output. The round-3 commit's three hunks in registry.ts sit at the same three statements inside the entry's block and nowhere else; against main the file is +71/−0 (the entry grew by five lines in round 3), the added block's 56 string literals and 9 comment lines are equal one for one to the entry file's, and the block still sits in id order between rls-predicate-array-comparand-refused and rls-predicate-stored-list-ordering-refused. check:migration-registry --check runs inside Lint & Repo Gates, success on this head. RIGHT.
  5. The lint phrase. "the classification lifted from driver-sql, which now delegates to it through crossFieldColumnVerdict" — TRUE: driver-sql's crossFieldComparisonClass calls crossFieldColumnVerdict for every declared FieldType and keeps only its aliases above it (① 7 of record 5868954092). That phrase alone moved. RIGHT.

② Semver level

  • Carried over unchanged from cc0bf6e5d: Clause-②: yes (narrowing) in the changeset (line 10) and the PR body (line 2); the levels, @objectstack/spec: patch included; the marker registered rls-predicate-cross-class-field-comparison-refused, whose id resolves in registry.ts at HEAD and is new in this diff; the BREAKING banner; every changeset sentence (the changeset is byte-identical to the prior head). Check Changeset is success on both events of this head (the push at 12:57Z, the body edit at 13:21Z).
  • The entry's sentences. The three corrected statements are TRUE (① 3); every other sentence is unchanged and TRUE as judged at cc0bf6e5d. The entry names both arms of the family and keeps the precedents' shape.
  • The PR body's addDays note. The three shapes are TRUE against matches-filter.ts: (1) a text base holding a date-shaped string — addWholeDays falls through to Date.parse on any string and shifts it; (2) a numeric base — addWholeDays adds the offset in milliseconds to any finite number; (3) a text offset column holding a numeric string — resolveDayOffset reads a non-blank string with Number. driver-sql's read refuses each with 400 (applyCrossFieldComparison: the base class must be date or datetime, the offset column numeric). The residual sentence — "a same-class pair with an offset on a non-temporal base (text or numeric), or with a text offset column" — TRUE: a boolean or a time-of-day base resolves to NO_OFFSET_BASE and fails closed, and a date or datetime base is what the read allows. "Pre-existing and not made worse here: the class rule runs first" — TRUE. The carrier line is byte-identical to the round-2 body's line and to the carrier prefix on the round-2 and round-3 reports' finding; the seat's own source line is not on the card (it lives in the patch-round dispatch, outside this review's inputs), so verbatim-ness is verified against those three copies. The dropped Acceptance note: the mechanical diff of the two bodies removes exactly one note — the lint-phrase note ("The next sentence still says … 'by a pairwise parity test there' … waits for the next lint touch") — and keeps its listHoldingComparisons sentence as a note of its own; the note dropped is the one round 3 fixed. TRUE.
  • Still FALSE in the PR's text — one number. The @objectstack/spec (patch, round 2) bullet says registry.ts "is regenerated by pnpm --filter @objectstack/spec gen:migration-registry (66 lines inserted, none removed)". At this head the net diff against main is 71 lines inserted, none removed, because round 3 grew the entry by five lines; the number is round 2's, stale at this head, and harmless (nothing reads it). A relay edit of the body corrects it without moving the head. Everything else in the body re-read at this head — the Round 3 bullets ("changes only in the entry's lines", the two merges named, the corrected statements), the driver-sql: reclaimSpace() frees ONE freelist page per call, not the freelist — PRAGMA incremental_vacuum measured 300 → 299 pages on SQLite, so the lifecycle sweep never returns bulk-deleted space (ADR-0057 §3.4) #20106 note, the Deviations section, the seat rulings, the zero-row note — is TRUE.

③ Boundary flags

open_questions is empty on the round-3 report. Every deviation, finding and carried item answered:

Implemented-by: claude/issue-20355-rls-write-check-cross-class
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS — the contract holds and every gate lane is green at this head. The PR's own delta since cc0bf6e5d is prose only, the two merges brought nothing by hand, and ① and ② of record 5869972995 carry over whole. The three entry corrections are TRUE against the spec module's own rationale and its type sets; registry.ts moves only in the entry's lines as generator output; the lint phrase is TRUE; the addDays note's three shapes are TRUE, its carrier line is unchanged, and the note it dropped was the one it fixed. Carried, not blocking: one stale number in the PR body ("66 lines inserted", now 71), and the card's claim, which is one lint phrase behind the diff.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 13:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit aeb0557 Sep 28, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20355-rls-write-check-cross-class branch September 28, 2026 14:08
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…te -wal sidecar too, never waiting on another connection (objectstack-ai#20426) (objectstack-ai#20463)

Fixes objectstack-ai#20426
Clause-②: no

`reclaimSpace()` on better-sqlite3 now returns the freed bytes from the
`-wal` sidecar as well as the freelist, and it never waits on another
connection. Every size below is the database file plus its `-wal` file,
read from the file system while the driver is still open. Every freelist
and page count is read from a second connection. Measured head:
`effb34a8a` (the branch after merging `origin/main` at `b28550818`,
which carries PR objectstack-ai#20427).

## What was wrong

With PR objectstack-ai#20425, one `Database.exec('PRAGMA incremental_vacuum')` returns
the whole freelist in one transaction. In WAL mode, the file-backed
default, that transaction's dirty pages outgrow the page cache, so
SQLite spills them into the WAL before the commit truncates them away.
Nothing afterwards truncates the WAL, so the sidecar keeps its
high-water size until the last connection closes.

## What changed

- `packages/drivers/driver-sql/src/sql-driver.ts`: the better-sqlite3
arm of `SqlDriver.reclaimSpace` calls a module-local
`reclaimBetterSqlite3(connection)`. It is module-local, like
`formatDuplicateGroups`, because `SqlDriver`'s `.d.ts` carries its
non-public members and this helper is no entry point. The published
types are unchanged; the `.d.ts` gains one doc-comment sentence on
`reclaimSpace`. The helper:
1. reads `PRAGMA freelist_count`, and sends nothing more when it is `0`;
2. runs `PRAGMA incremental_vacuum(N)` in chunks, N being a quarter of
this connection's page cache (1,000 pages at better-sqlite3's default
`cache_size = -16000` and 4 KiB pages), with a `PASSIVE` checkpoint
after each chunk;
3. stops when the freelist is empty or a chunk frees nothing (an
`auto_vacuum = NONE` file never shrinks its freelist);
4. ends with one `PRAGMA wal_checkpoint(TRUNCATE)` under a busy timeout
of `0`, and puts the connection's own busy timeout back in a `finally`.
Every statement goes through the binding's `exec()` / `pragma()`, which
step to completion. The loop is synchronous, so nothing else runs on the
connection between chunks. Every other SQLite client stays on
`knex.raw`, as before.
- Tests in `driver-sql` and `driver-turso` (below), and
`.changeset/20426-reclaim-space-wal-sidecar.md`
(`@objectstack/driver-sql`: `patch`).
- `.changeset/20106-reclaim-space-full-freelist.md`: one paragraph
removed. It said the freed pages pass through the `-wal` file, "which
keeps its size until the last connection closes". This PR makes that
false, and that note is still pending release. **This keeps
`check-empty-changeset` red on purpose** — see "The one red gate" below.

## The dispatch's hypotheses

- **H1 — confirmed** on `origin/main` `8cdbe0c6e`, through `SqlDriver`
(25,754 free pages):

  | step | database file | `-wal` | freelist / pages |
  |:--|--:|--:|:--|
  | after the delete | 103,149,568 | 4,255,992 | 25,754 / 25,789 |
  | after `reclaimSpace()` (351 ms) | 16,384 | 94,430,432 | 0 / 4 |
  | after one more write | 16,384 | 94,430,432 | 0 / 4 |
  | after `disconnect()` | 16,384 | 0 | 0 / 4 |

The DELETE-journal control on the same tree: 105,631,744 → 16,384 while
open, with no `-wal` file.

- **H2 — re-measured on this tree, and the picked variant is a fourth
one.** Each variant ran on `SqlDriver`'s own pooled connection after the
real fill-and-delete path (25,754 free pages, chunk 1,000). The rows
show database file + `-wal` after the call, driver open. This is one run
per cell on a shared box, so read the ratios, not the absolute times.

| variant | no reader | reader in this process (read transaction open) |
reader in another process (open for 1.5 s) |
  |:--|:--|:--|:--|
| `exec` alone (PR objectstack-ai#20425) | 16,384 + 94,430,432 · 315 ms | 103,149,568
+ 94,430,432 · 715 ms | 103,149,568 + 94,430,432 · 273 ms |
| + `wal_checkpoint(TRUNCATE)` | 16,384 + 0 · 476 ms | 103,149,568 +
94,430,432, busy · **5,333 ms** | 16,384 + 0 · **1,526 ms** (waited out
the reader) |
| chunked + `PASSIVE` | 16,384 + 4,255,992 · 157 ms | 103,149,568 +
4,255,992 · 47 ms | 103,149,568 + 4,255,992 · 62 ms |
| **chunked + `PASSIVE` + `TRUNCATE` at busy timeout 0 (this PR)** |
**16,384 + 0** · 276 ms, 108 ms on a rerun | 103,149,568 + 4,255,992,
busy · 48 ms | 103,149,568 + 4,255,992, busy · 61 ms |

- The objectstack-ai#20106 reading of about 210 KB for chunked + `PASSIVE` does not
hold through `SqlDriver`. `PASSIVE` never shrinks the sidecar: it stays
at whatever high-water size the sweep's own deletes left (4,255,992
here). Only a `TRUNCATE` checkpoint returns it.
- A waiting `TRUNCATE` checkpoint blocks the whole process on this
synchronous binding, for up to the connection's busy timeout (5,000 ms;
knex's better-sqlite3 client passes no `timeout`, so it is always
better-sqlite3's default). The lifecycle sweep runs in the server
process, so the triage's never-wait direction holds.
- So this PR takes the triage's chunked, never-waiting variant, plus one
`TRUNCATE` checkpoint that cannot wait. It is the only row that both
returns the space with no reader and never waits with one.
- With a reader present, no variant can shrink the database file. The
chunked rows keep the pair at its size before the call (107,405,560).
The one-statement rows grow it to 197,580,000.

**The chunk size, and why.** A chunk that outgrows the page cache spills
its pages into the WAL, just as one statement does. Frames left in the
WAL by the call, with a reader pinning every frame so none is reused:

| chunk (pages) | 100 | 250 | 500 | 1,000 | 2,000 | 4,000 | 8,000 | one
statement |
  |:--|--:|--:|--:|--:|--:|--:|--:|--:|
| default cache (`-16000`) | 1,437 | 1,121 | 1,003 | 928 | 883 | 3,779 |
14,216 | 22,920 |
  | 2 MB cache (`-2000`) | | 1,121 | 4,554 | 15,491 | | | | |

- The spill starts where the chunk reaches the page cache: between 2,000
and 4,000 pages at the default (`PRAGMA cache_spill` reads 3,871), and
between 250 and 500 at `-2000`.
  - Below that point, larger chunks mean fewer commits and fewer frames.
- A fixed 1,000 would spill on a connection with a smaller cache or
larger pages. So N is derived from the connection's own `cache_size` and
`page_size`, and the quarter leaves room for the per-page overhead and
the b-tree pages each chunk rewrites. At the default that is 1,000 pages
(4 MB).

- **H3 — confirmed.** `resolveSqliteJournalMode()` answers `wal` for a
file-backed database unless configured otherwise, and the probe's second
connection reads `journal_mode = wal`. The DELETE-journal control is
unchanged by the fix. Before and after, the file shrinks while the
driver is open and no `-wal` file exists: 105,631,744 → 16,384, 216 ms
before and 127 ms after.

- **H4 — confirmed.** The local `TursoDriver` face uses knex's
`better-sqlite3` client, so it takes this arm through
`super.reclaimSpace()`. Its suite reached the method, but it read only
the freelist and the page count. It now has a WAL-size case. The remote
route is untouched.

- **H5 — nothing new is thrown, so the sweep logs nothing new.** Both
checkpoints report "busy" as a result row, not as an error. So a busy
checkpoint degrades to "vacuumed, not checkpointed": the call resolves,
the pages are off the freelist, and `LifecycleService.sweep()` lists the
datasource as reclaimed, as before.
- Their bytes leave the files at a later checkpoint: the next reclaim
with free pages, SQLite's auto-checkpoint at 1,000 frames, or the last
connection closing. The reader case of the new test measures the next
reclaim.
- What can still throw is unchanged. Another connection holding the
write lock (`BEGIN IMMEDIATE`) makes the vacuum statement itself wait
out the busy timeout and throw `SQLITE_BUSY`. Measured: `main` 5,021 ms
and this PR 5,014 ms, both freelist unchanged, busy timeout 5,000
afterwards.
- In that case the sweep logs its existing warning (`space reclaim on
datasource 'X' failed (database is locked)`) and does not list the
datasource.
- The busy-timeout swap comes after the loop, so a throw inside the loop
never reaches it.

## The fix through `SqlDriver`

Same 25,754-page fixture:

| condition | database file + `-wal` after the call | call | busy
timeout after |
|:--|:--|--:|--:|
| WAL, no reader (was 103,149,568 + 4,255,992) | 16,384 + 0 | 101 ms,
109 ms | 5,000 |
| DELETE journal | 16,384, no `-wal` | 127 ms | 5,000 |
| WAL, reader in this process | 103,149,568 + 4,255,992 (unchanged;
freelist 0) | 47 ms | 5,000 |
| WAL, reader in another process | 103,149,568 + 4,255,992 (unchanged;
freelist 0) | 66 ms | 5,000 |

## Tests

`driver-sql/src/sql-driver-sqlite-reclaim-space.test.ts`, 7 cases (4
before). Each size is the database file plus the `-wal` file, read while
the driver is open. Each freelist and page count is read from a second
connection.

- **WAL:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open
and again after close.
- **WAL with a reader holding a read transaction.** The reopened file
has no WAL, the cache is set to about 100 pages, and 600 pages are free.
The case asserts:
  - the call resolves in under half the busy timeout;
  - the busy timeout reads 5,000 afterwards;
  - freelist 0;
- WAL growth under a quarter of the freed bytes. Measured: 0.08 for this
PR, and 0.87 for both one statement and a fixed 1,000-page chunk.
- Once the reader commits, the next reclaim returns everything: `{ file:
pages × 4096, wal: 0 }`.
- **The `auto_vacuum = NONE` control:**
  - the call resolves, so the loop stopped;
  - freelist and pages are unchanged;
  - the database file equals pages × 4096;
  - file + `-wal` is no larger than before.
- **DELETE journal:** freelist 0, and `{ file: pages × 4096, wal: 0 }`
while open.
- **The empty-freelist control, for both journal modes:** nothing
changes, the sizes included.
- **Unchanged:** the pooled connection is handed back.

`driver-turso/src/turso-remote-inherited-members.test.ts`: new case
"local face: in WAL mode the freed bytes leave the -wal sidecar too,
while the driver is still open".

Suites on the merged head `effb34a8a`, all through
`scripts/pm/os-verify-lock.sh`, each exit code recorded:
- `pnpm --filter @objectstack/driver-sql test`: exit 0, 195 files passed
and 11 skipped; 3,179 tests passed and 178 skipped. The count before the
merge was 3,227; the merge brought in PR objectstack-ai#20427, which removed tests of
its own.
- `pnpm --filter @objectstack/driver-turso test`: exit 0, 74 files;
1,982 passed and 16 skipped.
- `typecheck` for `driver-sql` and `driver-turso`: exit 0 each. `tsc
--listFilesOnly` shows both changed test files are in each package's
program.

## Ablations

Every leg ran on the committed state through
`scripts/ablation-replace.mjs`. In each, the anchor went from 1 hit to
0, and the restore was proven blob-equal to HEAD with an empty `git diff
HEAD`. The `driver-sql` suite imports `./sql-driver.js` (source), so
those legs needed no build.

| leg | mutation | result |
|:--|:--|:--|
| A | final `TRUNCATE` checkpoint removed | 3 red: WAL `{16,384 +
1,334,912}` vs `{16,384 + 0}`; the reader case's follow-up `{16,384 +
296,672}`; the NONE control's pair grew 1,318,384 → 2,555,376. 4 green.
|
| B | one statement instead of chunks | 1 red: the reader case, WAL
growth 2,142,400 vs a bound of 618,496. 6 green. |
| C | fixed 1,000-page chunk instead of the derived one | 1 red: the
reader case, 2,142,400 vs 618,496. 6 green. |
| D | busy timeout not zeroed for the `TRUNCATE` | 1 red: the reader
case, elapsed 5,034.99 ms vs under 2,500. 6 green. |
| E | busy timeout not restored | 1 red: the reader case, busy timeout 0
vs 5,000. 6 green. |
| F | the no-progress stop removed | the NONE control hung in the
synchronous loop and was killed after 60 s (SIGKILL). |
| A, dist | leg A built into `driver-sql`'s `dist/`, which
`driver-turso` resolves | `ablation-dist-preflight` found the marker in
2 built files. `driver-turso`: 1 red (local face `{32,768 + 280,192}` vs
`{32,768 + 0}`), 82 green. After the restore and a rebuild, `--absent`
found the marker in none of the 6 built files, and the tree was clean. |

In the first B–E runs, the red reader case also timed out its cleanup
hook: the failed assertion left the reader's transaction open. The fixed
case rolls the transaction back first. A rerun of leg B went red in 91
ms with no hook timeout.

## Gates

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `effb34a8a` derived 63 commands. All 63
ran, and every exit code was recorded before any pipe. 62 exited 0;
`check-empty-changeset --base origin/main` exited 1 (next section).
- `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
- The `--ran` pass printed a STALE TREE warning: `origin/main` moved 6
commits after the merge, and `scripts/cross-package-test-inputs.mjs`
changed in that range. Of those 6 commits, only PR objectstack-ai#20447 touches a
driver: it changes the `driver-turso` constructor, and none of this PR's
files. CI reads the merge ref.
- `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt, both before
(`8cdbe0c6e`) and after (`effb34a8a`).
- `pnpm lint` is CI's run. The narrowed run: `eslint --no-inline-config
--format json` over the 3 changed `.ts` files reports 3 files, 0 errors
and 0 warnings. `ESLint.isPathIgnored` answers false for each, so all
three are in `pnpm lint`'s population. `eslint.config.mjs` sets no
`parserOptions.project` and no typed rule, so this diff cannot move the
verdict of an untouched file.

## The one red gate: `check-empty-changeset` (a deliberate correction,
for confirmation)

This PR edits `.changeset/20106-reclaim-space-full-freelist.md`, which
exists on the merge base. The gate refuses that by name, and its own
text sets out two classes. This is the **deliberate correction** class,
not a collision.

- The removed paragraph says the `-wal` file "keeps its size until the
last connection closes". After this PR it is truncated at the end of the
call unless another connection is reading.
- That note has not been released, so restoring it from the base would
publish the false sentence.
- The gate's prescription for this class is to leave it red and get the
correction confirmed on the PR. `skip-changeset` is not applied and must
not be: this PR publishes a `patch`.

**For the seat: please confirm, or choose the other route.** The other
route is to restore the 20106 file from the merge base. The gate then
goes green, but the release would carry that sentence beside this PR's
own changeset, which describes the new behaviour.

## Acceptance notes

- **The file surface is widened by one file.** The claim names
`.changeset/20426-*.md`, and this PR also edits
`.changeset/20106-reclaim-space-full-freelist.md` (one paragraph
removed). It is the same defect, a mechanical removal, a card that has
already landed, and the same changeset gate family.
- **Behind a long reader, the bytes wait.** When a reader holds a
snapshot during the call, the database file keeps its size until a later
checkpoint. `LifecycleService.sweep()` still lists the datasource as
reclaimed. The next sweep that deletes rows returns it, and SQLite's
auto-checkpoint or the last close returns it sooner. No producer is left
worse off than on `main`, where the same reader left 197,580,000 bytes
instead of 107,405,560.
- **Partial progress is possible.** Chunks commit one by one. Another
process can take the write lock between two chunks, and then the next
chunk waits up to the busy timeout and may throw with the earlier chunks
already committed. This was not measured. A one-statement vacuum waited
and threw the same way, all or nothing.
- **Blocking is shorter, not gone.** The call still blocks the event
loop while it runs: 101 to 276 ms at 25,754 pages on this shared box,
against 315 to 351 ms for PR objectstack-ai#20425's single statement.
- The remote `TursoDriver` route, `SqliteWasmDriver`, `LifecycleService`
and `packages/spec` are untouched.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…staged $empty operator (objectstack-ai#20444) (objectstack-ai#20523)

Fixes objectstack-ai#20444
Clause-②: yes (widening)

The `domain:engine` lane's arms for the staged `$empty` operator, under
ruling A on objectstack-ai#20399 (`5865693155`): 「**One sibling card per
compile-surface lane**, each `Blocked-by:` objectstack-ai#20311's spec PR:
`domain:engine` — driver-sql and its heirs, turso `RemoteTransport`,
driver-memory, driver-mongodb, formula, objectql `having`;
`domain:services` — service-analytics' two faces. The two faces with no
field declarations (the formula matcher, objectql `having`) judge by
value, diverging only on a non-text column holding `''` (the write-door
class objectstack-ai#20308 closed).」

Every arm calls the spec's one expansion from PR objectstack-ai#20442
(`expandEmptyOperator` / `isEmptyFilterValue` in
`@objectstack/spec/data`); no face keeps a copy of the table. The
staging does not move (the maintainer's 「照 $like 先例分阶段」, `5868169573`):
`$empty` is **not** added to `FILTER_OPERATORS`, the `is_empty` /
`is_not_empty` lowering still emits `$null`, and the engine's front door
still refuses the operator. A driver or evaluator called directly now
answers it.

## What each face does now

| face | reads | `$empty: true` | undeclared field |
|---|---|---|---|
| `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm`,
`driver-turso` local, which inherit it) | declared row | null-only: `col
IS NULL`; text: `(col IS NULL OR col = '')`; multi-value: `(col IS NULL
OR L)` | refused |
| `driver-turso` `RemoteTransport.buildWhereSQL` | declared row, via a
resolver `TursoDriver` wires from the same registry | same SQL, SQLite
dialect | refused (also when used standalone with no resolver) |
| `driver-memory` live path (`find` / `count` / `update` / `delete`
through mingo) | declared row | null-only `{ f: { $eq: null } }`; text
`{ f: { $in: [null, ''] } }`; multi-value `{ $or: [{ f: { $eq: null } },
{ f: { $size: 0 } }] }` | refused |
| `driver-mongodb` `translateFilter` (and the aggregate `$match`) |
declared row, via a new optional `valueShape` resolver | the same three
documents | refused (also standalone with no resolver) |
| `driver-memory` reference matcher (`match`) | by value |
`isEmptyFilterValue(value)` | answered by value (it holds no
declarations) |
| `formula` `matchesFilterCondition` | by value |
`isEmptyFilterValue(actual)` | answered by value |
| objectql `having` and per-aggregation `filter` | by value |
`isEmptyFilterValue(value)` | answered by value |
| `driver-memory` analytics (cube) face | — | refused `INVALID_FILTER` /
400 as a declared operator it cannot compile, as it refuses `$null` | —
|

`$empty: false` is the exact complement on every face: `(col IS NOT NULL
AND NOT L)` / a non-null value other than `''` (the not-equal operator
against a bound `''`) / `IS NOT NULL` on SQL, `$nin` / `$nor` / `$ne` on
the document faces, `!isEmptyFilterValue` on the value faces. A
non-boolean flag is refused on every query face (`INVALID_FILTER` / 400,
on each driver's validating walk, so an identity that settles the node
first cannot skip it); formula answers it `false`, its standing posture
for an unevaluable `check`.

`L`, the empty-list test on a multi-value column (a JSON column: TEXT on
SQLite, `json` on PostgreSQL and MySQL):

- SQLite (and libSQL): `(CASE WHEN json_valid(col) THEN json_type(col) =
'array' AND json_array_length(col) = 0 ELSE 0 END)` — a malformed legacy
cell answers FALSE instead of failing the statement; a non-array JSON
value is not an empty list;
- PostgreSQL: `(CAST(col AS jsonb) = CAST('[]' AS jsonb))`;
- MySQL: `(JSON_TYPE(col) = 'ARRAY' AND JSON_LENGTH(col) = 0)`;
- any other knex dialect: the multi-value row is refused (the text and
null-only rows need no dialect).

An empty list is always tested as a stored value, never bound as a `$eq:
[]` comparand (ruling 乙 on objectstack-ai#19757 stands). Every SQL predicate is TOTAL
(never UNKNOWN), so `$not` over `$empty` needs no NULL guard: both SQL
compilers' polarity tables gain the row (`operatorIsNullTotal` → true,
`nullValueSatisfiesOperator` → `value === true`).

## PM hypotheses, measured

- **H1 — held, with the sources named.** Measured on base `4a1df1965` by
driving each face directly (a scratch probe, not committed) with `{ f: {
$empty: true } }`, `$empty: false` and `{ $and: [{ g: 'x' }, { f: {
$empty: true } }] }`, beside a `$null` control (answered on every face)
and a `$bogus` control. Refusal sources: driver-sql the emitter's
`default:` arm (`unsupportedFilterOperatorError`); turso remote its own
vocabulary refusal (`unsupportedOperator`); driver-memory live path and
matcher both at the shared shape gate (`assertFilterConditionShape`,
`filter-refusal.ts`); driver-mongodb `translateFieldOperators`'
`default:`; objectql `having` `unknownOperator`. All `INVALID_FILTER` /
400. formula answered `[]` for all three shapes (the silent `false`),
exactly as `$bogus`. After this PR, the same probe answers `['2','3']` /
`['1']` / `['2','3']` on every face that holds the declaration or judges
by value, and refuses on the two standalone entry points given no
declaration.
- **H2 — each declared-type face's declaration.** `driver-sql`: a new
per-table registry `valueShapeFields` (`{ type, multiple }` per field),
filled beside `jsonFields` at `registerManagedObjectMetadata` (so
`initObjects` and `registerObjectMetadata`), `registerExternalObject`,
and the shard alias. turso remote: `registerRemoteFieldMetadata` →
`registerExternalObject` fills the same registry, and `TursoDriver`
hands the transport `setDeclaredValueShapeResolver`. driver-memory and
driver-mongodb: a map filled by `syncSchema` beside the temporal-kind
map. The engine's registry injects the audit / tenant / owner fields
into the object's field map before it is synced (per `registry.ts`' own
docblock; not re-measured end to end here), so those are declared too.
**A field with no declaration (a knex-built table, the builtin `id`, a
field with no `type`) is a refusal, never a row guessed from a value:**
the spec's by-value reading has no SQL form without the type (`amount =
''` is a type error on PostgreSQL). A declared non-member type
(`string`, `object`, `array` from an introspected or test object) takes
the row the spec's expansion gives it, null-only.
- **H3 — SQL arms**, above. Pinned on SQLite locally;
`sql-driver-20444-empty-operator.test.ts` runs on every cell of the live
dialect matrix, so PostgreSQL and MySQL are measured by the `Temporal
Conformance (live PG + MySQL)` job. **Locally NOT MEASURED** on PG /
MySQL: no server is reachable in this container. The MySQL `' '` row
relies on the NO PAD default collation of the job's `mysql:8.0`.
- **H4 — the conformance table.** `FILTER_LOGIC_CASES` gains seven
`$empty` cases on the fixture's nullable column `d` (true, false, both
under `$not`, inside `$or`, inside `$and`, beside `$ne` on the same
field). The fixture stores neither `''` nor `[]`, so on it every row of
the table agrees; the rows pin that every face HAS an arm, that `$not`
over it is total and that it composes. The per-type discrimination is
each face's own suite (below). Census of every consumer that iterates
the table:
- driver-sql `sql-driver-or-filter.test.ts` — built its table through
knex, so the harness now registers the fixture's declaration
(`registerObjectMetadata`);
- driver-sqlite-wasm, driver-turso local and remote, driver-memory live
path and matcher, driver-mongodb live suite — already declared the
fixture (`initObjects` / `syncSchema`), pass unchanged;
- driver-memory analytics face — the harness's rule is "agree or refuse
loudly", and it refuses;
- driver-mongodb `mongodb-filter-logic-translation.test.ts` — calls
`translateFilter` standalone, so it now passes a declaration resolver;
- formula `matches-filter-or-semantics.test.ts` — by value, passes
unchanged;
- spec `filter-verdict.test.ts` — the rows reduce to `clause`, passes
unchanged; lint `validate-empty-combinators.test.ts` reads only the
`objectstack-ai#5322` rows;
- service-analytics `read-scope-sql-conformance.test.ts` and
`native-sql-filter-logic-conformance.test.ts` — outside this lane. Since
PR objectstack-ai#20498 (merged) both faces answer `$empty`, but only when handed the
field's declaration; each harness now passes a `text` declaration for
the fixture (test-only, no service-analytics source touched), so they
pass the rows rather than partition them. Declared as a deviation below.
- **H5 — `having`'s conclusion.** By value over the aggregated row:
null, a column the row lacks, `''` and `[]` are empty. A numeric
aggregate holding `0` (a `count` over nothing, a `sum` netting to zero)
is **not** empty. A `groupBy` text column holding `''` **is** empty —
the row a declared text field takes too. The per-aggregation `filter`
shares the walker and the reading. Pinned in
`having-empty-operator.test.ts`, including the row-independent refusal
of a non-boolean flag.
- **H6 — formula's docblock.** Its header claimed a DECLARED operator
never gets the silent `false`; that was false from objectstack-ai#20311's declaration
until this arm. The header now records that, names the
declared-but-staged set (`$like`, `$ilike`, `$empty`) as answered, and
says the next declared name is owed an arm by the PR that lets an author
write it or by its staging's lane card.

## Tests (head measured: `436a10a3e`)

- New per-face pins, each over a text, a multi-value and a scalar field
with null, `''`, `[]` and value rows, `$empty: false`, nesting under
`$and` / `$or` / `$not`, a sibling operator on the same field, and
refusals asserted by `code` + `status`:
`sql-driver-20444-empty-operator.test.ts` (dialect matrix),
`turso-20444-empty-operator.test.ts` (local and remote held to one row
set, plus `count()`), `memory-20444-empty-operator.test.ts` (live,
matcher, analytics face, and the one pinned cell where the declared row
and the by-value reading part), `mongodb-20444-empty-operator.test.ts`
(emitted documents and their rows; a live-`mongod` half runs when the
opt-in server is available), `matches-filter-empty-operator.test.ts`,
`having-empty-operator.test.ts`.
- Extended: the withheld-refusal seam tests of driver-sql (three new
builders, one needing the `'unknown'` dialect) and of the turso remote
transport (two methods, and the local / remote one-sentence table), and
driver-memory's operator-key clobber sweep (now declares its column and
covers `$empty`).
- Full package suites on the pre-merge head `ea3d95994`, each run
through the verify lock: driver-sql 197 files passed, 1 failed, 11
skipped — the failure was the withheld-refusal seam enumeration, which
the new refusal builders owed rows; they are added in this PR and that
file re-ran green (107 tests); driver-turso 77 files, 2080 passed;
driver-sqlite-wasm 36 files, 665 passed; driver-memory 59 files, 1419
passed; driver-mongodb 29 passed / 5 skipped, 656 passed; formula 42
files, 1227 passed; objectql `--project local` 332 files, 6636 passed;
service-analytics 134 files, 3165 passed.
- On the merged head `436a10a3e`: `typecheck` exit 0 for all seven
packages above (spec's own `typecheck` ran green on the pre-merge head);
the `$empty` suites and every `FILTER_LOGIC_CASES` harness re-run green
(driver-sql 154 passed / 4 skipped, turso 240, sqlite-wasm 37, memory
194, mongodb 65 / 50 skipped, formula 43, objectql 36, service-analytics
72, spec 73).
- **Ablations**, each through `scripts/ablation-replace.mjs` on the
committed tree with a restore trap; every leg restored to blob == HEAD
with `git diff HEAD` empty:
- A1 — driver-sql's text arm drops its `''` limb: 4 red in
`sql-driver-20444-empty-operator.test.ts`; the `FILTER_LOGIC_CASES`
sweep stayed green, which is the measured proof the shared rows do not
discriminate the text row.
- A2 — driver-memory's multi-value lowering written as `$in: [null,
[]]`: 8 red (mingo does not match a stored `[]` that way).
- A3 — formula's arm removed (the silent `false` back): 13 red, 6 in the
new pins and all 7 `$empty` rows of the shared table. The first A3
attempt did not run: its replacement text already occurred in the
anchor, the tool refused the non-rising count, and the file was
restored; it was re-run with a distinct replacement.
- `check:driver-conformance` read before and after: 50 covered cells, 0
DEBT, 0 exempt on both sides.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `436a10a3e` (after merging `origin/main`
with a merge commit) derived 91 commands; all 91 ran, each exit code
recorded before any pipe. `--ran` reconciliation: "91 derived famil(ies)
accounted for — 89 run, 2 NOT-MEASURED". NOT MEASURED:
`check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3
(`PREREQUISITE NOT MET`: they need the whole workspace built). Narrowed
probe instead: the built CJS entry of each changed package loads under
`require` (driver-sql 51 exports, driver-turso 14, driver-memory 23,
driver-mongodb 11, formula 47, objectql 178).

Lint, narrowed: `eslint.config.mjs` lints
`packages/**/*.{ts,tsx,mts,cts}` with no type-aware parsing (no
`parserOptions.project`), so no verdict on an untouched file can move
with this diff. `pnpm exec eslint --no-inline-config --format json` over
the 26 changed `.ts` files: 26 file entries, 0 errors, 0 warnings.

## Deviations

- **service-analytics test files**
(`read-scope-sql-conformance.test.ts`,
`native-sql-filter-logic-conformance.test.ts`) are edited, although the
order bars service-analytics. The edit is test-only: it hands each
harness the fixture's declaration so the new shared rows pass (H4). No
service-analytics source moves.
- **`packages/spec/src/data/filter-logic-conformance.ts`** gains the
seven rows and a header paragraph, a declared cross-lane test-data edit
(the claim names it).

## Acceptance notes (observations, not filed)

- The `FILTER_OPERATORS` TSDoc table in
`packages/spec/src/data/filter.zod.ts` still says no face answers
`$empty` and lists each face as refusing it; `filter-empty-operator.ts`'
header still says nothing in the repository calls the expansion. Both
were already stale after PR objectstack-ai#20498 and are staler now. Carrier: the flip
card, which rewrites that paragraph when it adds the operator.
- `@objectstack/formula`'s `matchesFilterCondition` has accepted the
object's declared columns (`options.fields`, type and `multiple`) since
PR objectstack-ai#20427, after ruling A was taken. With them it could answer `$empty`
by the declared row, as the read side of the same RLS policy does. This
PR keeps the by-value reading the ruling and the card assign; the two
part only on a stored state the declaration does not predict. Carrier:
none named.
- For the flip card: the engine's front door is the one remaining
refusal on the ObjectQL execute path PR objectstack-ai#20498 names. `driver-memory`'s
analytics face refuses `$empty` exactly as it refuses `$null` today, so
the flip moves nothing there.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…for a row-level policy comparing two fields of no shared comparison class (objectstack-ai#20598)

Fixes objectstack-ai#20431

Clause-②: no

## What was wrong

A row-level policy can compare two fields that share no comparison
class, for example a text field against a number field. Enforcement
refuses every request such a policy scopes. The find answers
`INVALID_FILTER` / 400. A by-id update or delete fails closed at its
row-level gate (403), because that gate's pre-image read is the same
refused read.

The record-grained explanation judged the same predicate in-process
without the object's declared columns. It compared the two raw values
and reported a record verdict: `visible: true` for one ordering of a
pair, and `visible: false` (rls `excluded`) for the other. Both answers
covered a request that enforcement refuses.

## What changed

The landing point is
`packages/plugins/plugin-security/src/explain-engine.ts`, as the
dispatch expected; the other files are the pin file and the changeset.
There are no changes to `security-plugin.ts`, `packages/formula`,
`packages/spec`, the REST layer, or enforcement.

- The record matcher (`matchesFilterCondition`) now receives the
object's declared columns (`options.fields`), as the RLS write check
does. They are read from `ql.getSchema(object)`: the schema the engine
already reads for the OWD, and the ObjectQL registry that the find's
driver compiles against. A schema that cannot be read hands over no
columns, and the matcher judges values only, as before.
- With the columns, the matcher refuses the comparison. Explain answers
with that refusal: the explanation fails with `INVALID_FILTER` / 400
(the matcher's code and status, the envelope the find answers with), and
no record verdict is reported. The message names the policy and both
fields with their declared types. The matcher's own error rides as
`cause`.
- Naming the fields discloses nothing new. The report explain gives the
same caller for the same object already publishes that predicate
(`readFilter`, or the `rls` layer's `rowFilter`).

## Why a refusal and not a fail-closed report: dispatch assumption A3
did not hold

A3 said to reuse PR objectstack-ai#20030's shape (layer `not_evaluated`,
`record.visible: false`) for "enforcement refuses this read".
Measurement on `main` says otherwise:

- Explain already answers the matcher's other `INVALID_FILTER` refusals
as a refusal.
- `rls-stored-list-ordering-fails-closed.test.ts` (landed in `de091b50`,
PR objectstack-ai#20310) pins it: "explain read 400 = find 400; explain update 400,
the by-id update 403". One of its cells is a field-to-field comparison
against a list-holding field.
- PR objectstack-ai#20030's shape covers a dependency call that fails, not a predicate
the matcher refuses.

My first commit used the report shape. The full `plugin-security` suite
then turned 2 cells of that landed pin red, because its field-to-field
cell is now caught first by the comparison-class rule. Keeping the
report shape would have added the second refusal dialect the dispatch
forbids. So this PR follows the ruling's intent: "the read is refused …
both orderings answer the same refusal as find".

## Measurement: before and after (better-sqlite3, the same stack as the
pins)

| policy class | find | by-id update / delete | explain read / update /
delete, before | after |
|---|---|---|---|---|
| text vs number | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` |
`visible: true`, `decidedBy: 'rls'`, rls `admitted` | refused, 400
`INVALID_FILTER` |
| number vs text (the other ordering) | 400 `INVALID_FILTER` | 403
`PERMISSION_DENIED` | `visible: false`, `decidedBy: 'rls'`, rls
`excluded` | refused, 400 `INVALID_FILTER` |
| text vs text (control) | the row | admitted | `visible: true`,
`decidedBy: 'rls'` | unchanged |

## Tests

New file:
`packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts`.
It uses the real `SecurityPlugin`, `ObjectQL` and SQL drivers
(better-sqlite3 and sqlite-wasm; PostgreSQL when `OS_TEST_POSTGRES_URL`
is set), on PR objectstack-ai#20427's harness. Every refused cell asserts both halves
with their envelope `code` and `status`: explain's answer, and the
caller's real request.

- Five cells: text vs number, text vs image, text vs formula, text vs
json, and number vs text. Each checks read, update and delete. Explain
answers `{ code: 'INVALID_FILTER', status: 400 }` and its message names
the policy and both fields. Find answers `INVALID_FILTER` / 400, update
and delete answer `PERMISSION_DENIED` / 403, and nothing is stored.
- Both orderings of one pair get `{ find: INVALID, explain: INVALID }`.
- Control, same class: find returns only the matching row. Explain
reports `visible: true` / `admitted` for it and `visible: false` /
`excluded` for the other row. The update is admitted and matches
explain.

Pre-fix run: `main`'s `explain-engine.ts` restored from the base blob
`92716c91`, under a trap whose restore is proven by the HEAD blob and an
empty `git diff HEAD`. Result: `Tests 12 failed | 2 passed | 7 skipped
(21)`. The 2 passes are the controls.

**Ablation:** only the declared-columns argument was removed, through
`scripts/ablation-replace.mjs`. The anchor hit 1 → 0 and the blob went
`a46456db` → `5a314958`. Result: `Tests 12 failed | 2 passed | 7 skipped
(21)`. Every refused cell on both drivers failed:

```text
AssertionError: expected 'answered' not to be 'answered' // Object.is equality
AssertionError: expected { find: { …(2) }, explain: 'admitted' } to deeply equal { find: { …(2) }, explain: { …(2) } }
```

Restore: `ok restored: blob == HEAD (a46456d) and git diff HEAD is
empty`.

All figures below were measured at `5e48f52c`, the head after merging
`origin/main` `c876a742`:

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: `Test Files 144 passed (144)`, `Tests 3066 passed | 23
skipped (3089)`.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, with
the test layer OK. `tsc -p tsconfig.test.json --listFiles` counts the
new file once.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 64
commands, and all 64 ran with exit 0. Three first answered exit 3
`PREREQUISITE NOT MET` (`check:dual-build-cjs-loads`, `check:i18n`,
`check:type-check-debt`). I rebuilt with `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks) and
re-ran them; all three answered exit 0. `dispatch-gates --ran`: `64
derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`.
- Lint, narrowed: `eslint --no-inline-config --format json` over the two
touched `.ts` files gives 2 files, 0 errors, 0 warnings. `eslint
--print-config` shows no `parserOptions.project` / `projectService`.
Linting is not type-aware, so this diff cannot move any untouched file's
verdict.

## Acceptance notes

- **The REST door answers 500 for this refusal.** The explain route's
catch maps only `PERMISSION_DENIED` → 403 and `OBJECT_NOT_FOUND` → 404;
every other throw becomes `500 EXPLAIN_FAILED`. I measured it through
the real handler (`security-explain-envelope.test.ts` harness): a
service refusal carrying `INVALID_FILTER` / 400 comes back as `{ status:
500, error: { code: 'EXPLAIN_FAILED', message: … } }`. The refusal's
message survives. PR objectstack-ai#20310's refusals were already answered this way.
It lives in `packages/rest/src/rest-server.ts`, outside this card's
surface, so it is reported, not fixed here.
- **The object-level answer is unchanged.** An explanation without a
`recordId` runs no record matcher. For a read under such a policy, it
still reports `allowed: true` and rls `narrows`, where the find answers
400. This PR does not change that; it is reported separately.
- **Missing record, not measured.** When the record does not exist, the
matcher never runs, so explain keeps its missing-record answer
(`visible: false`, no `decidedBy`) for a policy the find would refuse.
- **Duplicated attribution.** The policy-name attribution
(`refusedPolicyNamesOf`) copies the RLS write check's attribution in
`security-plugin.ts`. That file is held by objectstack-ai#20555, so one shared helper
is left to whoever next touches both files.

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

---------

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 protocol:data size/xl tests tooling

Projects

None yet

2 participants