Skip to content

fix(lint)!: refuse an RLS predicate that compares a field with a json or multiple field when it is authored - #20346

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-stage-2f-lint-arm
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-19886-stage-2f-lint-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #19886
Clause-②: no

Stage 2f of #19886: remainder item 1 of release 5860028604, the compile-door arm ruled A in 5857706935 ("in the lint rule, serial after #20158"). Claim 5860056842. Base de091b50, head 509728de. The changeset declares Clause-②: no (narrowing), BREAKING, following the 2d / 2e precedent.

What changes

validateRlsPredicateEnforceability (packages/lint) gets one arm. It reports rls-predicate-unenforceable for every lowered field-to-field comparison (==, != and the four ordering operators, on either side, under ! too) in which either column is DECLARED to hold a list or an object.

  • Judged by declared type, from the rule's own ObjectGraph. The class is read from the spec's value-shape classes, not from a list in lint: STRUCTURED_JSON_TYPES (json, composite, repeater, record, location, address, vector) and isMultiValueField (multiselect, checkboxes, tags, and select / radio / lookup / user / file / image with multiple: true). These are the same two that driver-sql builds its JSON-column set and its multi-valued test from, so the lint arm and the driver's cross-field refusal share one definition.
  • Every clause. It covers using on select / all / update / delete / insert and check on insert / update / all (Zone 2 item 3, decided from the runtime measurement below).
  • One defect, one finding. The arm runs before the RLS policies are admitted when they are authored: policy save and objectstack validate / compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158 engine-judge pass. A clause it refuses is never handed to judgeFilter, and the engine judge answers none of these cells anyway (Zone 2 item 1).
  • Both doors. os validate / build / lint and the metadata save door already run this rule, so both refuse with the same sentence. No gate is added, and no runtime seam. The 2d / 2e runtime refusals stay as the backstop.

Files: the rule (+141), a new table-driven pin beside it, six refusal rows plus three controls added to the CLI/runtime parity pin, and the changeset.

The refusal text (new)

Message, for a using clause (a real os validate finding):

RLS using record.status != record.tags lowers, but compares a field with a field that holds a list or an object: record.status != record.tags, where tags is declared type: 'json'. A column that holds a list or an object is not one comparable value, on either side of a field-to-field comparison, so the platform refuses the comparison instead of evaluating it: every read this policy scopes is refused on the SQL drivers (INVALID_FILTER / 400: driver-sql refuses a cross-field comparison against such a column by its declared type), and every by-id update or delete it scopes fails closed (PERMISSION_DENIED / 403). On an insert, update or all policy the same using is also the write check whenever no applicable policy for that operation declares a check (ADR-0058 D4): then every single-record insert and by-id update whose record holds a list or an object in that column is refused (INVALID_FILTER / 400) and stores nothing, because the write check compares one value with one value and will not guess what a list means.

For a check clause, the part after the colon is the write sentence alone, followed by: "The policy reads as a comparison and behaves as a refusal of every write that leaves a list or an object in that column."

Hint:

A field compared with a json or multiple field has no row-filter form: a row filter compares one value with one value, and cannot test membership in a list another column holds. Compare with a single-valued column, or with a literal or a current_user value — "one of these values" is record.status in ['open', 'pending'] or record.owner in current_user.org_user_ids — or move the condition into a validation rule or a hook.

The class sentence ("A column that holds a list or an object is not one comparable value, on either side of a field-to-field comparison") follows the wording of 2d's arrayComparandError. The prescription follows the registered entry cel-predicate-one-value-comparand-refused. At authoring time the field names are the author's own text, so nothing is withheld.

Zone 2, measured first

1. Is (a) still silent after #20265? Yes, at both doors, and the engine judge refuses none of it. The cell table was written to disk before any edit (cells-before.json):

  • 400 cells: ==, !=, !(==), > and less-or-equal; five list-holding columns (json, address, multiselect, multiple lookup, multiple user); both operand orders; eight clause/operation pairs.
  • Doors: the real CLI (node packages/cli/bin/run.js validate --json) and the runtime save door (saveMetaItem('permission') over a real ObjectQL with the sqlite-wasm driver).
tree leak cells (400) scalar controls (48) firing controls (16)
de091b50 (base) 400 clean at the CLI, 400 accepted at the save door 48 clean / accepted 10 refused at both: the list literal on all 8 clauses, the engine-judge text operator on select/all using; 6 clean, the engine judge on other clauses, as designed
509728de (head) 400 refused at both, one finding each, the same sentence at both doors (400 / 400) 48 clean / accepted unchanged

2. Declared type. The judgement uses the graph's declared type and multiple, never a value. An object outside the stack or with no field map is not judged; an undeclared name is the unknown-field finding alone (pinned).

3. Which clauses. The runtime refuses every clause, so the arm covers every clause. Measured at de091b50 through the real SecurityPlugin + ObjectQL + driver-sql (better-sqlite3), for record.status != record.tags, its mirror, == against json, and != against a multiple lookup:

clause / operation action answer (36 of 36 leak cells)
using on select, all find INVALID_FILTER / 400
using on update, delete by-id update / delete PERMISSION_DENIED / 403, nothing changed
using on insert, all (standing in as the check) insert INVALID_FILTER / 400, nothing stored
check on insert, update, all insert / by-id update INVALID_FILTER / 400, nothing stored

The scalar control record.status != record.reviewer was admitted and enforced on all 9 pairs.

4. Producer census (narrowing).

  • objectstack at 509728de: 187 string literals under a using / check / condition key (111 distinct), in packages/ and examples/, non-test. 2 lower to a { $field } comparison. Both are scalar-to-scalar: the showcase hook condition record.spent > record.budget (currency) and a doc-formula script's record.a > record.b. Neither is an RLS or sharing predicate, and 0 compare with a list-holding field.
  • cloud main 96eb092: 3 occurrences, 0 { $field } comparisons.
  • The real os validate over app-crm, app-multi-package, app-showcase and app-todo at head: 0 rls-predicate-* findings. 0 over-refusals.

Tests (head 509728de, every run through os-verify-lock.sh)

  • @objectstack/lint, full package: 112 files / 4640 tests pass. The new pin has 336 tests: 224 cells (8 clause-operation pairs × 7 operators × 2 classes × 2 orders), 16 declared classes, 88 scalar controls on every clause, and the arm's own properties. typecheck: exit 0.
  • @objectstack/cli unit project: 229 files / 3251 pass. integration project, the parity pin (touched by this diff, so run locally): 30 / 30. typecheck: exit 0.
  • @objectstack/metadata-protocol (the save-door gate): 189 files pass, 3 skipped; 2728 tests pass, 19 skipped.
  • @objectstack/plugin-security, the unchanged 2d / 2e backstop pins (read only): 150 / 150.

Ablation of the committed arm (fdda49e5; the rule's blob is the same at head). The mutation replaced the arm's call with an empty list through scripts/ablation-replace.mjs (anchor 1 to 0, marker 0 to 1, blob changed). pnpm --filter @objectstack/lint build then emitted JS; its DTS step exited 1 on the now-unused helper (TS6133). ablation-dist-preflight.mjs @objectstack/lint found the marker in all 4 built entries.

  • The new lint pin: 244 red, 92 green. Red: every refusal cell and property. Green: the controls, the table-size check, and the not-judged and unknown-field cases.
  • The CLI parity pin: 6 red, exactly the six new rows; 24 green.
  • Restored with git checkout HEAD -- ABS_PATH: blob equal to HEAD, git diff HEAD empty. Rebuilt with exit 0, and the preflight --absent was clean with a clean tree.

Gates

Re-derived on the actual paths with dispatch-gates.mjs --repo objectstack-ai/objectstack, all run at head, exit codes recorded before reading. --ran reconciliation: 61 derived, 60 run, 1 NOT MEASURED, 0 UNRUN. The two families new over the dispatch lead are check:cli-test-child-env and bare check-issue-citations.mjs, both run, both exit 0.

Acceptance notes

  • The stage 2d changeset's "Not changed" sentence. It says a field compared with a json or multiple field "is not reported at authoring time". That no longer holds. This PR's changeset states the delta rather than editing an unreleased changeset, as 2d did for 2a.
  • The sharing-rule twin (same class, another surface). sharingRules[].condition record.status != record.tags and record.reviewers == record.status both pass the real os validate at head, while the firing control record.status == ['a', 'b'] in the same run is reported. The arm belongs in validate-sharing-rule-enforceability.ts, which is outside this claim's surface (other packages/lint files are fenced). Its runtime answer on the sharing path was not measured.
  • Other cross-field classes driver-sql refuses by declared type. driver-sql's crossFieldComparisonClass refuses these too, and they are a different class from a list. A text column compared with a number column, a single-valued image column or a formula field all pass os validate. Measured through the real plugin-security on driver-sql for text against number and text against image: the using read answers INVALID_FILTER / 400, and the check insert is admitted and stored. The formula cell was not measured at run time.
  • driver-memory still reads a { $field } comparand as a literal on a read ([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, frozen; pointer 5857707647). Unchanged here.

What remains on #19886

The release's remainder list held item 1 only, and this PR closes it. It is Part of rather than Fixes because the sharing-rule twin above is the same authoring-door gap on a sibling surface. Whether it stays on this card or goes to its own card is the seat's call. If it moves off the card, the first line can take the closing keyword instead.


Generated by Claude Code

… multiple field at authoring time

WIP: the rule arm; tests and changeset follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…iven and at both doors

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
…ck-clause wording

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 2 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 — 4 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 d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json d3958bac6b41f128ac269e0dbe8f9cb49f9bc17f

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 509728de8fc9f3c47c25421daa82dbe35e2ad8a7
Local-runs: probe — the dispatch ordered a measured review on this p1 security card, so one detached worktree was opened at the head and one at the merge-base de091b50 as the control, pnpm install --frozen-lockfile in each, the cli closure built through the verify lock, and through the lock: a 1,992-cell table through both authoring doors in both trees, a 165-cell runtime probe on driver-sql through the real plugin-security, a FieldType-wide class-parity probe, the predicate census, the real os validate over the four example apps, the rule's three pin files and the cli parity pin, one ablation with its restore, and a test-by-test comparison of the pre-existing pins across the two trees; both worktrees removed afterwards

Read: the PR body, the diff against the merge-base de091b50 (4 files, +500/−7, 4 commits e366da3c, cfb95d48, fdda49e5, 509728de) and the 38 check runs on the head; card #19886 (body and all 44 comments, in particular ruling A 5857706935, the release 5860028604, the claim 5860056842, the dev report 5861308252 and the seat ACCEPT 5861343773), the 2d record 5857896186 on PR #20259 and the 2e record 5859846398 on PR #20310; validate-rls-predicate-enforceability.ts in full at the head (loweredSites, referenceFindings, the arm, the engine-judge pass and validateRlsPredicateEnforceability), authoring-rules.ts 1850–1866 (the rule row: commands: ALL, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['permission']), runtime-gate.ts, runtime-authoring-gate.ts 45–91 and 774; field.zod.ts 40–107 (the FieldType enum) and field-value.zod.ts 120–360 (MULTI_OPTION_TYPES, MULTI_CAPABLE_TYPES, FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, isMultiValueField); sql-driver.ts 250–275 (JSON_COLUMN_TYPES), 1810–1835 (isMultiValuedColumn), 2660–2760 (crossFieldComparisonClass) and 16440–16630 (the cross-field refusal site); the entry 18.cel-predicate-one-value-comparand-refused.ts; the 2d changeset 19886-one-value-comparand-refused.md and this PR's changeset; check-changeset-no-major.mjs, check-adr-0087-registration.mjs, ablation-replace.mjs, ablation-dist-preflight.mjs, os-verify-lock.sh; AGENTS.md 1–320 and 1060–1100; the cli admission test's two doors; the dev's probe scripts under issue-19886f/ as leads only. NOT MEASURED: live PostgreSQL, MySQL and mongod; driver-memory and driver-sqlite-wasm at run time (the runtime probe is driver-sql on better-sqlite3, the driver whose declared refusal the arm mirrors; the save door runs on driver-sqlite-wasm as the cli pin does).

① Derived judgments

  • 1. The arm refuses exactly the declared class, at both authoring doors — RIGHT. Cell table, generated once and run through the REAL cli (node packages/cli/bin/run.js validate --json, the built entry) and the save door the cli admission pin uses (saveMetaItem('permission') over a real ObjectQL on driver-sqlite-wasm with the real sys_metadata objects), in both trees. Cells: ==, != and the four ordering operators (less-than, less-or-equal, greater-than, greater-or-equal), each plain and under !, in both operand orders, against six list/object column kinds (json holding a list, json holding an object, a multiple lookup, a multiselect, a multiple user, an address), on using for select / all / update / delete / insert and check for insert / update / all: 1,152 cells. Controls: the same twelve operator spellings in both orders over text vs text, number vs number, date vs date and a single lookup vs text, every clause: 768 cells; 40 neighbours (a json column against a literal, json != null, multiple lookup == null, a flat literal list, a membership set); 16 firing controls (the 2a list literal, the engine-judge text operator); 16 combined cells. Head: 1,152 of 1,152 leak cells refused at the cli with exactly one rls-predicate-unenforceable finding each, 1,152 of 1,152 refused at the save door with 422 INVALID_METADATA and exactly one issue each, the cli message byte-equal to the save-door issue in every cell, and every message carrying the class sentence; per column kind 192/192 for each of the six; per operator 96/96 for each of the twelve spellings; per clause 144/144 for each of the eight; per order 576/576 for each. 768 of 768 scalar controls clean at both doors; 40 of 40 neighbours clean. Merge-base control: 1,152 of 1,152 leak cells clean at the cli and accepted at the save door; controls, neighbours and the firing controls identical to the head (10 firing cells refused at both doors in both trees, 6 clean in both, by design: the engine judge is a read-scope judge). Movement head vs base: 1,166 cells toward refusal (the 1,152 leak cells and 14 combined cells), 0 toward acceptance. Under-refusal: none found. Over-refusal at the authoring doors: none found in the 808 control and neighbour cells, and 0 findings over the example apps (item 5). The finding names the comparison as written (a negated less-than on reviewers and status is quoted as the inner comparison, without the !) and the declaration (`reviewers` is declared `type: 'lookup'`, `multiple: true`); the driver-only aliases object / array cannot reach the arm: os validate refuses them at the schema (invalid_value on fields.*.type, measured), so driver-sql's two extra JSON_COLUMN_TYPES members are not an authorable gap.
  • 2. One classification, not a copy — RIGHT. For every member of the spec's FieldType enum (50, read from FieldType.options, not by hand) plus each MULTI_CAPABLE_TYPES member with multiple: true (6), the lint's verdict on record.probe != record.subject (probe: text) was compared with a real SqlDriver (better-sqlite3) through ObjectQL.find on { probe: { $eq: { $field: 'subject' } } } and, to separate the null class from a cross-class refusal, on { twin: { $eq: { $field: 'subject' } } } with twin declared identically to subject. Lint refuses exactly 16 declarations: json, composite, repeater, record, location, address, vector (the seven STRUCTURED_JSON_TYPES), multiselect, checkboxes, tags (MULTI_OPTION_TYPES), and select / radio / lookup / user / file / image with multiple: true; each finding quotes the declaration. driver-sql's same-declaration comparison is refused with the null-class diagnostic has no scalar stored column a comparison can read (read through withheldFilterDiagnosticOf) for exactly those 16 plus formula (a virtual field, refused INVALID_FIELD before the class check) and the single-valued file family image / file / avatar / video / audio (refused by name, deployment-independent); every other declaration is admitted by the driver and clean at lint. So lint's list/object set = driver-sql's null class minus {formula, single-valued file family}, and those two are a different class (no list, no object), filed as [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: 0 rows where the two disagree on the list/object question, 0 declarations lint refuses that the driver admits. The mechanism is shared: listHoldingDeclaration calls STRUCTURED_JSON_TYPES.has(type) then isMultiValueField({ type, multiple: meta.multiple === true }); crossFieldComparisonClass calls isMultiValuedColumn (isMultiValueField on the same pair) then JSON_COLUMN_TYPES.has(type) where JSON_COLUMN_TYPES = STRUCTURED_JSON_TYPES ∪ MULTI_OPTION_TYPES ∪ {object, array}, and MULTI_OPTION_TYPES ⊂ isMultiValueField. A type added to either spec set moves both consumers at once; a type in one set and not the other is impossible by construction, and the 56-row table confirms it.
  • 3. The runtime still agrees — RIGHT. Through the real SecurityPlugin + ObjectQL + SqlDriver (better-sqlite3), 15 refused spellings (equality family 5: == json list / json object / multiple lookup list-first / multiselect, !(==) json list; inequality family 5: != on each column kind and list-first; ordering family 5: greater-than, less-or-equal list-first, greater-or-equal, less-than, greater-than list-first across the four kinds) × 9 clause/operation/action cells (using select and all → find; using update → by-id update; using delete → by-id delete; using insert and all → insert; check insert / update / all → insert or by-id update): 135 of 135 refused, 0 rows changed or stored. Which door answered, read off the error: find under a select / all using → INVALID_FILTER / 400 from driver-sql (the withheld diagnostic "tags" (type "json") has no scalar stored column a comparison can read is on the error; the first frame is packages/drivers/driver-sql), so on find the DRIVER judges, not the in-process evaluator; by-id update and delete under using → PERMISSION_DENIED / 403 from plugin-security (the pre-image gate fails closed on the driver's refusal); every insert and by-id update judged by a check, or by a using standing in as the check on insert / all → INVALID_FILTER / 400 from @objectstack/formula (the 2d { $field } evaluator arm, per record). Controls on the same stack: record.status != record.reviewer admitted on all 9 cells and the read returns both rows; amount greater-than budget admitted on all 9 and the read returns the one row where it holds; record.status == record.reviewer admitted on 2 and denied 403 on 7 by its own truth value, never refused. Every policy the lint refuses is refused or fails closed on the driver-sql stack, so no over-refusal against this runtime. One measured edge, not an over-refusal of the declared class, in ③.
  • 4. No double finding, no finding moved — RIGHT. The arm runs before the engine-judge pass and the pass is guarded by findings.length === 0, so a clause the arm refused is never handed to judgeFilter (the new pin records the judge's calls: none for the refused clause, one { object: 'deal', where: { status: { $ne: { $field: 'owner' } } }, options: { operation: 'find' } } for the scalar control). Measured at both doors: the combined cell record.amount.startsWith('5') && record.status != record.tags earns exactly one finding on every clause in both trees — the engine's sentence at base on select / all (2 cells), the arm's at head, clean at base on the six non-read-scope clauses and the arm's at head; two offending comparisons in one clause (record.status != record.tags || record.reviewers == record.status) earn one finding naming both. The pre-existing pin files validate-rls-predicate-enforceability.test.ts (blob da61febd) and .engine-judge.test.ts (6e728dc1) are byte-identical at base and head, as are authoring-rules.ts, runtime-gate.ts, validate-sharing-rule-enforceability.ts, cel-to-filter.ts, matches-filter.ts, sql-driver.ts and field-value.zod.ts. At head the three rule pin files pass 467 / 467 (the two pre-existing files 131: validate-rls-predicate-enforceability.test.ts 120, .engine-judge.test.ts 11; the new file 336) and the cli parity pin 30 / 30. The two pre-existing files were then run in both trees with vitest's JSON reporter and compared test by test: 131 / 131 passed at base, 131 / 131 at head, and the sorted lists of test name plus status are identical (0 only at head, 0 only at base). No pre-existing pin moved.
  • 5. Census (narrowing) — RIGHT. objectstack at 509728de, packages/** + examples/**, non-test (3,232 files; test, spec, fixture, .d.ts, CHANGELOG and dist paths excluded): 187 string literals under a using / check / condition key (84 / 42 / 61), 111 distinct, 105 lower through sqlPredicateToCel + compileCelToFilter, 2 lower to a { $field } comparison: the showcase hook condition on project (spent and budget both non-null and spent greater-than budget; both fields are Field.currency on project.object.ts, hooks/index.ts:88) and a fixture string inside packages/lint/scripts/check-doc-formula-expressions.mjs (record.a greater-than record.b, at line 1396, a script self-test case, not metadata). Neither is an RLS or sharing predicate and 0 compare with a json / multiple column. Cloud origin/main 96eb092: 3 occurrences, all prose or type declarations (verify.ts:190, metadata-authoring-skill.ts:288, graph-lint.ts:1615), 0 { $field }, and the one rowLevelSecurity mention (ee-group-showcase/src/security/index.ts:32) declares none. The real os validate --json at head over app-crm (0 errors / 9 warnings), app-multi-package (0 / 3) and app-todo (0 / 7): 0 rls-predicate-* findings and exit 0; app-showcase, after its four connector packages were built (they sit outside the cli closure; see ③), valid: true, 0 errors / 84 warnings, 0 rls-predicate-* findings, exit 0. So 0 new findings over the four example apps at head, and 0 shipped predicates against a json / multiple column in either repository: no D2 conversion is owed, and the narrowing moves nobody.
  • 6. Pins bite — RIGHT. From the committed head (rule blob 2c4be428 on disk = HEAD), scripts/ablation-replace.mjs disarmed the arm's push (the if on listComparisons.length gained the conjunct String('ABL_REVIEW_20346') === '', so the helper stays referenced and the DTS step cannot fail on an unused symbol): anchor 1 → 0, marker 0 → 1, blob 2c4be428 → f2bb9f63. pnpm --filter @objectstack/lint build exit 0; ablation-dist-preflight.mjs @objectstack/lint ABL_REVIEW_20346 found the marker in all 4 built entries (index.js, index.cjs, runtime.js, runtime.cjs). Red: the new lint pin 244 failed / 92 passed of 336 (every refusal cell, every declared-class row and the arm's properties; green are the controls, the table-size check, the not-judged and unknown-field cases and the door-reach case); the cli parity pin 6 failed / 24 passed of 30, the six failures being exactly the six new refusal rows (test/rls-policy-authoring-admission.test.ts:275). Identical to the dev's numbers. Restore: git checkout HEAD -- on the file, blob 2c4be428 = HEAD, git diff HEAD empty, lint rebuilt exit 0, preflight --absent dist reading passed (marker absent from all 14 built files) while its tree reading flagged only my untracked probe directory; with that directory deleted, preflight --absent exit 0 on both readings ("working tree clean against HEAD"), the rule blob on disk 2c4be428 = HEAD, and the whole-tree git status --porcelain empty in both worktrees before they were removed.

② Semver level

  • @objectstack/lint minor + **BREAKING** + Clause-②: no (narrowing) — RIGHT. The diff narrows a published accept set at an authoring door (validateRlsPredicateEnforceability, run by os validate / build / lint and the save door) and moves no runtime source. AGENTS.md's post-task rule makes (narrowing) BREAKING, and check-changeset-no-major.mjs records the launch-window convention under which a breaking change ships as minor with the **BREAKING** banner and the ADR-0087 disposition as carriers; the changeset has all three, and Clause-②: no in the PR body matches the claim. Packages: @objectstack/lint alone is right — the only source file is in packages/lint, packages/cli changes a test only, and metadata-protocol picks the arm up through its lint dependency as plugin-security did through formula in 2d/2e. check-changeset-no-major --base origin/main: exit 0 (no major; the level axis has no PR payload locally, and Check Changeset is success in CI at this head).
  • not-required (already-registered cel-predicate-one-value-comparand-refused) — RIGHT. The entry's surface names this class in the same words the arm refuses by: "a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does"; its replacement carries the prescription the new hint gives: "A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook"; its acceptanceCriteria already asks the author to grep for "a field compared with a json or multiple field". This PR adds no class, no prescription and no stored-metadata rewrite; it moves where the registered class is refused. check-adr-0087-registration --base origin/main: exit 0, reading the changeset as [BREAKING+clause-②-narrowing] not-required (already-registered).
  • Every changeset sentence — TRUE on my measurements. The 400-cell "before" table (a subset of my 1,152 cells: 5 operators × 5 columns × 2 orders × 8 clauses) is clean at both doors at de091b50; the runtime sentence (read 400, by-id update or delete 403, every judged insert or by-id update 400 with nothing stored) is item 3; the seven structured JSON types and the multi-valued set are item 2; "judges using and check on every operation" and "a clause it refuses is not also handed to the engine's filter judge" are items 1 and 4; "both doors" with 422 INVALID_METADATA and the same sentence in issues[] is item 1; the "Not changed" list (single-valued field-to-field, a json / multiple field against a literal or null, an undeclared column) is the 808 clean control and neighbour cells plus the new pin's not-judged cases; the census numbers 187 and 3 are item 5; and the stated delta against the 2d changeset is true: 2d's line "a field compared with a json or multiple field still lowers and is not reported at authoring time" no longer holds at this head, and this changeset says so in one sentence rather than editing the unreleased 2d file, the 2d-for-2a precedent. The override sentence: the same refused permission set (using record.status != record.tags, select) is refused 422 INVALID_METADATA / rls-predicate-unenforceable at the save door without the variable and ACCEPTED with OS_ALLOW_UNLINTED_METADATA_WRITES=1 (measured; the "ALLOWING a publish" warning is the gate's own text at runtime-authoring-gate.ts:774, read, not captured by my logger hook). One wording note, non-blocking: the finding's using sentence says "every read this policy scopes is refused on the SQL drivers", and the SQL driver measured is driver-sql on better-sqlite3 (driver-sqlite-wasm and PostgreSQL / MySQL not measured here); the 2e record measured the wasm driver agreeing with driver-sql on this class.

③ Boundary flags

  • The measured edge between declared type and stored value. The arm judges the DECLARATION, the 2d write-check arm judges the VALUE. Under check on insert with record.status != record.tags (tags: json), an insert whose tags holds the scalar string 'x', or null, is ADMITTED and stored by the runtime (measured; the multiselect holding [] is refused 400). So a check-only policy on a json column that only ever holds scalars was enforced per record before and is refused at authoring now. This is the class ruling A defined ("judges it from the rule's own field-type index"), the class the registered entry names ("as a json column or a multiple lookup does"), and driver-sql's read refusal for the same declaration is value-independent; the finding's check sentence is precisely conditional ("whose record holds a list or an object in that column"), so it does not overstate. Not an over-refusal of the ruled class; named so the seat sees the one value-dependent cell.
  • The finding's author moves on one constructed cell. record.amount.startsWith('5') && record.status != record.tags on a select / all using was refused by the engine judge at base and is refused by the arm at head, one finding either way; on the six other clauses it was clean at base. No pre-existing pin carries that spelling; the engine-judge pins' own cells are unchanged.
  • Out of scope, named only (③), neither this PR's regression. (i) The sharing-rule twin: sharingRules[].condition comparing two fields where one is json / multiple passes os validate at head and at base alike; validate-sharing-rule-enforceability.ts is byte-identical in both trees; stays on [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 as stage 2g per the seat ACCEPT. (ii) The cross-class family: text vs number, text vs a single-valued image, formula — driver-sql's null and cross-class arms that are not a list or an object; the class-parity table above shows exactly formula and the single-valued file family as the driver-refused, lint-clean declarations; filed as [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, open.
  • driver-memory is not measured here; [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 (frozen) covers its { $field } read, unchanged by a lint-only diff.
  • Landing state. Head unchanged at report time (509728de, 4 commits, draft, needs:contract-review, mergeable_state: clean). CI at the head: 38 check runs, 32 success, 6 skipped (Auto Label and Check PR Size, each the second run on the later workflow run while the first run of each is success; Packed-tarball smoke (opt-in) twice; Build Docs; Console Pin Gate), 0 failing, 0 in progress, read at 01:00Z after every run had settled; the Vercel status success. Every red-capable check is green; no red to name. No governed path; 507 changed lines.
  • A worktree note, not a finding. The first os validate over app-showcase in my worktree failed to load the config (ERR_MODULE_NOT_FOUND on @objectstack/connector-mcp/dist), because the four connector packages the showcase imports sit outside the @objectstack/cli... closure I had built; they were built through the lock and the run repeated (item 5). CI's Dogfood Verify CLI is green at this head.

Implemented-by: claude/issue-19886-stage-2f-lint-arm
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

Blocking items: none. Every judgment in ① is RIGHT on my own measurements; the semver level, the ADR-0087 disposition and every changeset sentence in ② are right; ③ carries one measured value-dependent edge and the two out-of-scope siblings, none this PR's regression.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 01:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit d498113 Sep 28, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19886-stage-2f-lint-arm branch September 28, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant