fix(lint)!: refuse an RLS predicate that compares a field with a json or multiple field when it is authored - #20346
Conversation
… 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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
📓 Docs Drift Check7 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
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
Contract reviewServed-tier: Read: the PR body, the diff against the merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: 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. |
Part of #19886
Clause-②: no
Stage 2f of #19886: remainder item 1 of release
5860028604, the compile-door arm ruled A in5857706935("in the lint rule, serial after #20158"). Claim5860056842. Basede091b50, head509728de. The changeset declaresClause-②: no (narrowing), BREAKING, following the 2d / 2e precedent.What changes
validateRlsPredicateEnforceability(packages/lint) gets one arm. It reportsrls-predicate-unenforceablefor 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.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) andisMultiValueField(multiselect,checkboxes,tags, andselect/radio/lookup/user/file/imagewithmultiple: 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.usingonselect/all/update/delete/insertandcheckoninsert/update/all(Zone 2 item 3, decided from the runtime measurement below).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 tojudgeFilter, and the engine judge answers none of these cells anyway (Zone 2 item 1).os validate/build/lintand 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
usingclause (a realos validatefinding):For a
checkclause, 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:
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 entrycel-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):==,!=,!(==),>and less-or-equal; five list-holding columns (json,address,multiselect,multiplelookup,multipleuser); both operand orders; eight clause/operation pairs.node packages/cli/bin/run.js validate --json) and the runtime save door (saveMetaItem('permission')over a realObjectQLwith the sqlite-wasm driver).de091b50(base)using; 6 clean, the engine judge on other clauses, as designed509728de(head)2. Declared type. The judgement uses the graph's declared
typeandmultiple, 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
de091b50through the realSecurityPlugin+ObjectQL+ driver-sql (better-sqlite3), forrecord.status != record.tags, its mirror,==againstjson, and!=against amultiplelookup:usingon select, allfindINVALID_FILTER/ 400usingon update, deletePERMISSION_DENIED/ 403, nothing changedusingon insert, all (standing in as the check)INVALID_FILTER/ 400, nothing storedcheckon insert, update, allINVALID_FILTER/ 400, nothing storedThe scalar control
record.status != record.reviewerwas admitted and enforced on all 9 pairs.4. Producer census (narrowing).
509728de: 187 string literals under ausing/check/conditionkey (111 distinct), inpackages/andexamples/, non-test. 2 lower to a{ $field }comparison. Both are scalar-to-scalar: the showcase hook conditionrecord.spent > record.budget(currency) and a doc-formula script'srecord.a > record.b. Neither is an RLS or sharing predicate, and 0 compare with a list-holding field.main96eb092: 3 occurrences, 0{ $field }comparisons.os validateoverapp-crm,app-multi-package,app-showcaseandapp-todoat head: 0rls-predicate-*findings. 0 over-refusals.Tests (head
509728de, every run throughos-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/cliunitproject: 229 files / 3251 pass.integrationproject, 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 throughscripts/ablation-replace.mjs(anchor 1 to 0, marker 0 to 1, blob changed).pnpm --filter @objectstack/lint buildthen emitted JS; its DTS step exited 1 on the now-unused helper (TS6133).ablation-dist-preflight.mjs @objectstack/lintfound the marker in all 4 built entries.git checkout HEAD -- ABS_PATH: blob equal to HEAD,git diff HEADempty. Rebuilt with exit 0, and the preflight--absentwas 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.--ranreconciliation: 61 derived, 60 run, 1 NOT MEASURED, 0 UNRUN. The two families new over the dispatch lead arecheck:cli-test-child-envand barecheck-issue-citations.mjs, both run, both exit 0.check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET. Eight packages outside the built closures (studio, client-react, embedder-openai and five more) have nodist/, and the gate needs a whole-treepnpm build. As a proxy, both of lint'srequireentries (dist/index.cjs,dist/runtime.cjs) load.not-required (already-registered cel-predicate-one-value-comparand-refused). That entry already names this exact class in itssurfaceand carries its prescription in itsreplacement. This PR moves where the registered class is refused (run time to authoring time) and adds no class, no prescription and no stored-metadata rewrite.check-adr-0087-registrationpasses. The RLS policies are admitted when they are authored: policy save andobjectstack validate/ compile judge the lowered read-scope filter with the engine's judge-only method (objectstack#19995 ruling C, second consumer) #20158 precedent (no-migration-prescription) did not fit: this changeset carries a "What to change" line.Acceptance notes
jsonormultiplefield "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.sharingRules[].conditionrecord.status != record.tagsandrecord.reviewers == record.statusboth pass the realos validateat head, while the firing controlrecord.status == ['a', 'b']in the same run is reported. The arm belongs invalidate-sharing-rule-enforceability.ts, which is outside this claim's surface (otherpackages/lintfiles are fenced). Its runtime answer on the sharing path was not measured.crossFieldComparisonClassrefuses these too, and they are a different class from a list. A text column compared with a number column, a single-valuedimagecolumn or aformulafield all passos validate. Measured through the real plugin-security on driver-sql for text against number and text against image: theusingread answersINVALID_FILTER/ 400, and thecheckinsert is admitted and stored. The formula cell was not measured at run time.{ $field }comparand as a literal on a read ([finding] driver-memory's own reference matcher has no$fieldarm — a cross-field comparand (bare or withaddDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104, frozen; pointer5857707647). Unchanged here.What remains on #19886
The release's remainder list held item 1 only, and this PR closes it. It is
Part ofrather thanFixesbecause 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