Repository navigation
feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint - #20265
Conversation
… engine's judge at both authoring doors The RLS predicate rule takes the engine's judge-only filter admission (IObjectQLEngine.judgeFilter) as an optional input and judges every read-scope `using` with it. The runtime publish gate hands in the live engine's method; the CLI commands hand in a driverless engine built from the stack's objects. The rule now also runs at the runtime door for `permission` writes. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…n; burn down the prose-id baseline Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…s-policy-authoring-admission
…river-memory Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…roster Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
…s-policy-authoring-admission
📓 Docs Drift CheckThis PR changes 3 package(s): 42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 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 7ce7f5491a88404f418ea13c4ec6c2b7c55cbec6 && git checkout 7ce7f5491a88404f418ea13c4ec6c2b7c55cbec6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a9fb83ef06a079a938c97d1ea5e364705e1bc13b a81029795938139498932f60b317305be065275d && git checkout -B drift-repro a9fb83ef06a079a938c97d1ea5e364705e1bc13b && git merge --no-ff a81029795938139498932f60b317305be065275d
node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ds a list or an object (objectstack-ai#20310) Part of objectstack-ai#19886 Clause-②: no Stage 2e, remainder items 2 and 3 of release `5858069107`, as ruled on the fork report `5858423045` in seat ruling `5858444849` (Q1 A, Q2 A, Q3 A). Claim `5858140051`. Base `17bd3187`, merged with `origin/main` `ae8e3ca0` through `scripts/pm/os-regen-merge.sh`; head `2e54475b`. ## What changes - **`@objectstack/formula` `matchesFilterCondition`.** One arm in `evalOp`: `$gt` / `$gte` / `$lt` / `$lte` and `$between` refuse, per record, a STORED operand (`actual`) that is a list or a plain object, whatever the comparand. It uses the stage 2d predicate `isNonScalarValue` and the stage 2d error `arrayComparandError` (`INVALID_FILTER` / 400). `null` and `Date` do not move, and neither does equality (`$eq`, `$ne`, implicit equality, `$in`, `$nin`) against a stored list. - **The norm it follows** is driver-sql's declared refusal (objectstack-ai#7398, `JSON_COLUMN_INCOMPATIBLE_OPERATORS`): every ordering comparison, and `$between`, on a column it stores as JSON text is refused by declared type with 400. The evaluator has no schema, so it judges the value on the record. - **`@objectstack/spec`.** A new ADR-0087 semantic entry, `rls-predicate-stored-list-ordering-refused`, plus the Q3 correction to the stage 2a entry `rls-predicate-array-comparand-refused`. Only the generated regions of `registry.ts` move, at about :12722 and :12749. - **Changeset** `.changeset/19886-stored-list-ordering-refused.md`: `Clause-②: no (narrowing)`, BREAKING, `registered rls-predicate-stored-list-ordering-refused`. It names the three Q1 moves and explain's four cells. No change to explain, `packages/lint`, `security-plugin.ts`, `cel-to-filter.ts` or the shared face. ## Measured cells The stack is real: SecurityPlugin + ObjectQL on driver-sql (better-sqlite3) and driver-memory. Before is `17bd3187`, cells-before.json, sha256 `a3d2b42d49563cca`. After is head `2e54475b`, cells-head.json, sha256 `21548839aac31220`. The after table is byte-identical to the fork report's temporary measurement of the same route. Each cell reads before → **after**. Every 400 is `INVALID_FILTER`; every 403 is `PERMISSION_DENIED`. "(driver)" marks driver-sql's own refusal. | id | predicate | list on the write | check insert (sql / memory) | by-id update carrying the list | by-id update of another field, stored list | `using` read, driver-sql | `using` read, driver-memory | |---|---|---|---|---|---|---|---| | O1 | `record.tags > 'a'` | `['m']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar | | O2 | `record.tags < 'z'` | `['m']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar | | O3 | `record.tags >= 'a'` | `['m']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar | | O4 | `record.tags <= 'z'` | `['m']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m, r_scalar → rows r_list_az, r_list_m, r_scalar | | O5 | `record.tags > 'n'` | `['m']` | 403 / 403 → **400 / 400** | 403 / 403 → **400 / 400** | 403 / 403 → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az → rows r_list_az | | O6 | `record.meta < 'a'` | `{a:1}` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | no rows → no rows | | O7 | `record.meta > 'a'` | `{a:1}` | 403 / 403 → **400 / 400** | 403 / 403 → **400 / 400** | 403 / 403 → **400 / 400** | 400 (driver) → 400 (driver) | rows r_scalar → rows r_scalar | | O8 | `record.watchers > 'a'` | `['p1']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m → rows r_list_az, r_list_m | | O9 | `record.watchers < 'p2'` | `['p1']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m → rows r_list_az, r_list_m | | C1 | `record.status > 'a'` | `status: ['m']` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | admitted / admitted → **admitted / admitted** | rows r_list_az, r_list_m, r_null, r_obj, r_scalar → rows r_list_az, r_list_m, r_null, r_obj, r_scalar | rows r_list_az, r_list_m, r_null, r_obj, r_scalar → rows r_list_az, r_list_m, r_null, r_obj, r_scalar | | C2 | `record.amount > 10` | `amount: [500]` | admitted / admitted → **400 / 400** | admitted / admitted → **400 / 400** | 403 / admitted → **403 / 400** | no rows → no rows | no rows → no rows | | C3 | `record.tags == 'm'` | `['m']` | 403 / 403 → **403 / 403** | 403 / 403 → **403 / 403** | 403 / 403 → **403 / 403** | 400 (driver) → 400 (driver) | rows r_list_m, r_scalar → rows r_list_m, r_scalar | Notes on the table: - The C1 update edits `status` itself, so the stored list is replaced before the check runs. - On the C2 update, driver-sql had stored `[500]` as the text `'[500]'` (see the REST cell below), so its post-image holds a string and is denied 403. - driver-memory's `using` read is unchanged. It still compares a stored list element by element, so its write and read now part; this is declared on objectstack-ai#15104, as for 2d's `{ $field }` half. - `$between` measured the same coercion on the evaluator: `{ tags: { $between: ['a','z'] } }` on `['m']` gave true before and 400 after. CEL never produces it (`record.tags >= 'a' && record.tags <= 'z'` lowers to `$and` of `$gte` and `$lte`). - objectql `having` is byte-identical before and after on both drivers. The literal path uses objectql's own comparator, and the `{ $field }` path at :1117 was already refused by 2d. ### Explain (Q2 A: named, not changed) `security/explain` evaluates the business RLS layer in-process. Both 400 paths, this stage's (O1) and 2d's `{ $field }` (X1), per operation: | explain operation | driver | enforcement answers | equal | |---|---|---|---| | `read` | driver-sql | 400 (the driver's refusal) | yes | | `update` | driver-memory | 400 (the post-image check) | yes | | `update` | driver-sql | 403: the pre-image gate fails closed on the driver's 400 | no, both deny | | `read` | driver-memory | the rows its element-wise read admits | no, the test driver | ## Premises - **P1 holds.** The census covered `packages/**` and `examples/**` at `17bd3187`, and cloud `96eb092`. 0 shipped using/check/sharing-condition predicates use any ordering operator. - **P2** as restated by the ruling: it holds for every production door, and parts only on the frozen driver-memory (O5, O6). - **P3** has four cells, above. ## Refusal text (the one changed sentence, quoted) Before (the tail of the first clause of `arrayComparandError`): ```text … or as a member of an "$in" / "$nin" list, or a column on either side of a { "$field" } comparison that holds a list or an object. A list is not one comparable value. ``` After: ```text … or as a member of an "$in" / "$nin" list, or a column that holds a list or an object on either side of a { "$field" } comparison or on the compared side of an ordering operator or "$between". A list is not one comparable value. ``` The rest of the message is unchanged. It still withholds the field, the operator and the value, and a pin asserts that for the stored side too. Q3, the stage 2a entry's `replacement` now ends: ```text Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before ``` ## Pins - **`packages/formula/src/matches-filter-array-comparand.test.ts`**, new stage-2e block of 38 tests: - six stored shapes × five operators (a one- and a multi-element list, an empty list, a list of lists, an object, an empty object); - the depths evaluation reaches, including under `$not`; - every kind of comparand (number, Date, null, `{ $field }`); - a list written into a scalar field; - per-record controls (a scalar compared), `null`, a missing field and a `Date`; - equality against a stored list, unchanged; - the message withholding the field and value. - The 2a/2d pins are unchanged, including "a scalar comparand against a STORED array is untouched". - **`packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts`**, 94 tests, real stack on driver-sql (better-sqlite3) and driver-sqlite-wasm: - O1–O9, C1, C2, each on: - the check insert (400, nothing stored), beside its scalar or null control; - the by-id update carrying the list (400, row unchanged); - the by-id update of another field on a stored-list row (400, row unchanged; json and multiple columns); - the `using` read (driver's own 400; C1 and C2 compare). - The `null`, `Date` and C3 controls. - The explain cells for O1 and X1 (read 400 = find 400; update 400 against a 403 by-id update). - driver-memory is not pinned in-tree. Declaring `@objectstack/driver-memory` in plugin-security needs a `driver-memory-census` ledger disposition, which is a maintainer ruling; the in-repo precedent is `bootstrap-platform-admin-promotion-selection.test.ts`. Its cells above are measured out of tree. ## Ablation (one-shot proof that the pins can fail) - **Base.** Taken from committed `18e5a78b`, via `scripts/ablation-replace.mjs`. The arm was disarmed (`op === 'ZZ_ABL_19886E' && …`): anchor 1 → 0, blob `6e061b4c` → `af46be42`. - **Build.** Formula rebuilt, and `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.mjs`. - **Red.** - formula: 34 of 129 tests red — the 30-cell matrix, depth, comparand kinds, list in a scalar field, and message. - plugin-security: 64 of 94 tests red — every list-holding insert and update cell and the O1 explain cell, on both drivers. - The 30 still green are exactly the `using` reads, the controls and 2d's X1 explain. - **Restore.** Back to the HEAD blob `6e061b4c`; `git diff HEAD` empty; rebuilt; `preflight --absent` passes on all 6 built files. ## Tests and gates (at head `2e54475b`) **Build and suites.** Full `turbo run build` exited 0 (72/72). All of these exit 0, each through `scripts/pm/os-verify-lock.sh`: | suite | files | tests | |---|---|---| | formula | 39 | 1165 | | plugin-security | 141 | 2990 | | new pin alone | 1 | 94 | | lint | — | 4295 (no pin moved) | | objectql having (`engine-aggregate-having-comparand-shape`, `having-filter`) | 2 | 176 | | plugin-sharing | 37 | 913 | | driver-memory | 56 | 1374 | | driver-sql consumer files of the evaluator | 8 | 474, 4 skipped | | driver-sqlite-wasm consumer files | 6 | 217 | | platform-objects consumer | — | 18 | **Typechecks.** formula and plugin-security exit 0. Both run `check:test-typecheck` over `tsconfig.test.json`, which includes `src/**/*`, so the test files are covered. **Gates.** `dispatch-gates --commands` at `2e54475b` derives 90 commands. All 90 exit 0, and `--ran` reconciles: "90 run, 0 NOT-MEASURED". Also run, all exit 0: - the dispatch lead's `pnpm check:authz-resolver`; - `check:generated` (15/15 up to date); - the four roster gates whose roster lies under a changed path (`check-changeset-fixed`, `check:meta-url-spelling`, `check:error-code-casing`, `check:filter-alias-parity`); - `check:engine-double-contract`; - `check:doc-authoring`. `check:entry-nameability` prints a pre-existing partial NOT MEASURED for `@objectstack/spec/api-assembled` and `/qa`, unrelated to this diff. **eslint (narrowed, measured).** Covered here: 1. The population is the 6 changed `.ts` files, each resolved by eslint's own config (`--print-config`). 2. `--format json` counts 6 files, 0 errors, 0 warnings, under `--no-inline-config`. 3. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict of an untouched file. Repo-wide `pnpm lint` is CI's. ## Remainder, still open on this card - **Item 1, the lint arm at the compile door** (`os validate`): a field compared with a `json` / `multiple` field, and now an ordering over one. Serial after PR objectstack-ai#20265, which landed on `main` as `1207baf0` during this stage, so item 1 is unblocked. ## Acceptance notes (noted, not filed) - **Explain fidelity.** On driver-sql json/multiple predicates, explain reports `visible=true` on rows where the enforced read answers 400 and the by-id update 403. Measured on `17bd3187` before any change, cells O1–O4, O8, O9, C3. Reach measured at `explain()`; the HTTP door was not driven. - **`having` `$between`.** It keeps a NULL group while `$gte` with `$lte` excludes it, and numeric bounds compare NULL as 0 (both spellings keep NULL for `[-10, 10]`). Reach is `engine.aggregate`; no REST door was driven. - **C2 at the REST data door**, measurement only, for the seat to file. On the CRM dev server with driver-sql, better-sqlite3, `POST /api/v1/data/crm_activity` with `duration_minutes: [500]` into a `number` field, no RLS involved, answered 201. SQLite stored it as TEXT `'[500]'`, and `GET` returns the string `"[500]"`. `[5, 7]` answers 400 `VALIDATION_FAILED`; a scalar `500` is stored as a real. Not fixed here. - **Correction of review `5857896186` ③.** "driver-sql compares the JSON text on the read" is false at `17bd3187`: driver-sql refuses with 400 (objectstack-ai#7398). The seat corrected it in `5858444849`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ Co-authored-by: Claude <noreply@anthropic.com>
… or multiple field when it is authored (objectstack-ai#20346) Part of objectstack-ai#19886 Clause-②: no Stage 2f of objectstack-ai#19886: remainder item 1 of release `5860028604`, the compile-door arm ruled A in `5857706935` ("in the lint rule, serial after objectstack-ai#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 objectstack-ai#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 objectstack-ai#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. - NOT MEASURED: `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 no `dist/`, and the gate needs a whole-tree `pnpm build`. As a proxy, both of lint's `require` entries (`dist/index.cjs`, `dist/runtime.cjs`) load. - ADR-0087 disposition: `not-required (already-registered cel-predicate-one-value-comparand-refused)`. That entry already names this exact class in its `surface` and carries its prescription in its `replacement`. 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-registration` passes. The objectstack-ai#20158 precedent (`no-migration-prescription`) did not fit: this changeset carries a "What to change" line. ## 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 (objectstack-ai#15104, frozen; pointer `5857707647`). Unchanged here. ## What remains on objectstack-ai#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](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20158
Clause-②: yes (narrowing)
yesbecause two published@objectstack/lintsignatures gain an optionaljudgeFilterinput, so@objectstack/lintisminor.(narrowing)because the metadata save door now refuses RLS policies it accepted before, so the release is BREAKING and the changeset carries the ADR-0087 marker. Route A, as ruled in seat answer 5856289438 on the card, following triage 5855733474 and ruling C on #19995 (comment 5852158605).What changed
A row-level-security policy is permission-set metadata (
rowLevelSecurity[]). Its read-scopeusingcould lower cleanly and still be a filter the engine refuses to run: a text operator on a number field, a date field compared against a value its storage cannot read, a filter on a formula field, or a{…}placeholder string. Nothing refused any of these at authoring time. The metadata save door (Studio, REST/meta, MCP) also did not run the RLS predicate rule at all.validateRlsPredicateEnforceability(stack, { judgeFilter })judges the loweredusingof everyselect/allpolicy withIObjectQLEngine.judgeFilter. It runs only when every existing pass left the clause clean, so one defect produces one finding. The finding keeps the existing idrls-predicate-unenforceableand quotes the engine's code, status and sentence verbatim.packages/lintholds no model of the engine's checks. Without the input, the pass is skipped and the rule answers exactly as before.AuthoringRuleContext.judgeFilter(soAuthoringRuleRun.judgeFilterforrunAuthoringRules) and thejudgeFilterargument ofrunRuntimeAuthoringRules. The registry entry passesctx.judgeFilterto the rule.assertRuntimeAuthoringRules(protocol.ts) probestypeof this.engine.judgeFilter === 'function'. It passes the method, bound to the engine, intoevaluateRuntimeAuthoringGate, which is internal to the package and stays pure (a newjudgeFilterargument, the same pattern asorgWallEnforced), and from there torunRuntimeAuthoringRules.packages/cli/src/utils/authoring-filter-judge.tsbuilds anObjectQLwith no driver and no kernel, registers the stack's objects through its own registry (applySystemFieldsincluded), and returns its boundjudgeFilter. It is built on the first judgement only, and objects are copied before registration so the rules that run later see the author's stack untouched. All fiverunAuthoringRulescall sites pass it:commands/validate.ts,commands/compile.ts,commands/lint.ts,utils/artifact-packages.ts(the per-package pass, handed the union's judge) andutils/scaffold-validate.ts.surfaces: CLI_AND_RUNTIME,runtimeTypes: ['permission']. The per-write snapshot has carriedpermissionsandobjectssince [2 of #7891]permission/bookwiring:TYPE_TO_STACK_KEYentries + the cross-collection snapshot the three comparison rules need #8309, and [3 of #7891] Flip the registration:runtimeTypesgainsobject+permission/book— whole rule families cross the publish door #8310 closed with no ruling that kept this rule CLI-only (0 matches for rls / RLS / rowLevelSecurity / validateRlsPredicate / CLI_ONLY across its body and 2 comments; control: the body namesvalidateSecurityPosture).The refusal text (new, quoted)
The message the rule builds for an engine refusal. Both doors carry it byte for byte:
The hint, chosen by the engine's
code(never by its prose):FILTER_TOKEN_UNKNOWN/FILTER_TOKEN_UNRESOLVED: "A{…}placeholder is saved-filter syntax, and an RLS predicate does not use it: the predicate is CEL, and the caller's values are thecurrent_userkeys (… the kernel-resolved keys …). Writeowner == current_user.id, notowner == '{current_user_id}'."INVALID_FIELD: "A row filter reaches only the stored columns of its own object. Denormalise the value the engine names onto this object (a stored field, written when the source changes) and test that column."startsWith/endsWith/contains) only on a field that holds a string, and a date or datetime field only against a value its storage can read." followed by the rule's existing lowerable-subset sentence.At the save door the refusal is the gate's existing envelope,
422 INVALID_METADATA, whoseissues[]entry carries the samerule,messageandhint. The path is keyed by the set's name (permissions.sales.rowLevelSecurity[0].using). The text is the author's own, so nothing is withheld at authoring time. The runtime read-scope withhold (#5367) is untouched.The judge-pass binding (as ruled)
The judge sees the reference pass's probe with one change: a
current_user.*key the kernel does NOT resolve (an app-staged §7.3.1 membership set) is bound to[]for the judge pass only. Kernel keys keep their runtime-typed probe (a user id or an email is never a date, so the probe stands in faithfully). An app-staged set holds app data this rule cannot know. The measured artefact:close_date in current_user.holidayslowered with the probe string reaches the engine as{ close_date: { $in: ['__objectstack_lint_probe__'] } }, and the temporal door refuses it (INVALID_FILTER). Lowered with[], the engine admits it. Pinned at both doors: that clause is accepted, andclose_date in current_user.holidays && amount.startsWith('5')is still refused (the binding does not switch the judge off). At the lint layer, a recording judge shows it is handed{ close_date: { $in: [] } }for the app key and{ owner: { $in: ['__objectstack_lint_probe__'] } }forcurrent_user.org_user_ids.The fail-open edge (named, not closed)
At the runtime door, the judge reads the live engine's registry, not the per-write snapshot. An object that exists only in the same publish batch (
publishPackageDraftspending declarations), or only in an organization overlay row, is one that registry does not hold, so it gets the engine's unknown-object answer: the field-map doors (text operator, temporal, formula, dotted) answer nothing, while the schema-free doors (placeholders, comparand shape and type) still judge. The runtime read-scope withhold (#5367) stays the backstop there. As ruled, no snapshot-based second registry is added to close it. The CLI door gives the same answer for an object the stack does not define (another package's, or a platform object), and for an object the registry itself refuses to register (refused at boot by that same registry).What the judge does not see
It judges only
usingclauses that belong to the read scope:selectandall, the twogetApplicablePoliciesmatches for a read. Acheckis matched in memory against a post-image and never reaches the engine'swhereadmission. Ausingon aninsert/update/delete-only policy is not part of the read scope either. Both are pinned (a recording judge counts 0 calls for them).The fifteen classes, both doors (
packages/cli/test/rls-policy-authoring-admission.test.ts)One table. Each row runs
os validate's own composition in process (normalizeStackInput→lowerCallables→ObjectStackDefinitionSchema→ union fold →runAuthoringRules('validate', …)withstackFilterJudge) andsaveMetaItem({ type: 'permission' })over a realObjectQLhost (in-memory driver, the realsys_metadataobjects from@objectstack/metadata-core) holding the same objects. It asserts one verdict and one sentence (issues[0].message === cliFinding.message), plus the envelopeINVALID_METADATA/422and the path.805af4f2):os validate/ saveregion == ['emea', 'apac']rls-predicate-unenforceable$eqregion == current_user.org_user_idsrls-predicate-unenforceable$inregion in 'emea'rls-predicate-unenforceable$nin!(region in 'emea')rls-predicate-unenforceable$between(no CEL spelling; nearest: a range with a null bound)amount >= 1 && amount <= nullrls-predicate-unenforceable$inregion in ['emea', null]rls-predicate-unenforceable$inregion in [{'a': 1}]rls-predicate-unenforceable$eqregion == {'a': 1}rls-predicate-unenforceableundefinedcomparand (no CEL spelling; nearest: a key nothing resolves)region == current_user.noperls-predicate-unknown-user-variableowner == '{current_usr_id}'rls-predicate-unenforceableFILTER_TOKEN_UNKNOWNowner == '{current_user_id}'rls-predicate-unenforceableFILTER_TOKEN_UNRESOLVEDamount.startsWith('5')rls-predicate-unenforceableINVALID_FILTERclose_date > 'soon'rls-predicate-unenforceableINVALID_FILTERis_open == truerls-predicate-unenforceableINVALID_FIELDaccount.region == 'emea'rls-predicate-unenforceableControls, accepted at both doors:
owner == current_user.id,owner in current_user.org_user_ids(the pre-resolvedINform) andname.startsWith('A')(a text operator on a text field).The "Before" column was measured on
805af4f2with a scratch probe (not committed). The rule was run directly foros validate, andsaveMetaItemfor a permission write on a stub host. Rows 5 and 9 were not in that probe. Theiros validatereading was taken at this head with no judge passed, which runs exactly the passes805af4f2ran: the rule's diff only adds the judge pass, andpackages/formulachanged no production file between the two. Rows 1–4, 7, 8 and 15 are CEL shapes the pushdown compiler refuses, so the existing shape pass already reports them. Rows 5 and 6 are the existing shared-face pass, and row 9 is the existing unknown-variable pass. The five rows the engine alone refuses (10–14) were accepted at both doors. Every row was accepted at the save door, because the rule did not run there.Placeholder semantics are as
5855081099records them: a known placeholder judged with no context answersFILTER_TOKEN_UNRESOLVED, an unknown oneFILTER_TOKEN_UNKNOWN. No authored policy in the census uses a{…}token, so no mode change is needed and none is added.Measurements
cb223b1e). A driverlessObjectQLheld every object definition the tree ships (packages/**/src/**/*.object.ts: 75 files, 75 objects, 0 imports failed, 0 refused by the registry). The run wasvalidateRlsPredicateEnforceability(stack, { judgeFilter }):plugin-securitydefaultPermissionSets: 107 clauses, 104 read-scopeusing, 104 judge calls, 0 findings. 28 of its 29 policy objects are known to that engine; the 29th is the'*'wildcard, which gets the unknown-object answer.packages/metadatahotcrm fixture: 4 clauses, 2 read-scopeusing, 2 judge calls, 0 findings.user_id.startsWith('{current_user_id}')onsys_sessionproduced 1 finding (FILTER_TOKEN_UNRESOLVED).examples/app-showcase(allPermissionSetsover its 24 objects, judged by an engine holding those objects, 0 refused by the registry): 3 clauses, 2 read-scopeusing, 2 judge calls, 0 findings.examples/**andpackages/**(non-test), 63 of 88using:/check:lines namecurrent_user.(CEL variables), and 0 carry a{token}. cloudorigin/main96eb092has 0 authored policies (control:rowLevelSecurity1 comment hit). objectui at pinf8a9d0fbhas 0 authored policies (control:rowLevelSecurity10 hits, the Studio RLS editorPermissionAdvancedFacets.tsx, a real producer of save-door writes).Tests
All heavy runs went through
scripts/pm/os-verify-lock.sh. Each verdict below is the run's own tally.Final head
a8102979(origin/main443b2f4fmerged; the post-merge re-verification ran afterpnpm install --frozen-lockfileand a@objectstack/cli...closure build,Tasks: 56 successful, 56 total):packages/cli/test/rls-policy-authoring-admission.test.ts; the tier file places it inintegration, run locally because this diff adds it):Tests 21 passed (21).packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, the new#20158block on the file's existing pinned stub host):Tests 39 passed (39).packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts, 11 cases) runs inside the lint suite below.pnpm --filter @objectstack/lint test:Test Files 111 passed (111),Tests 4273 passed (4273).pnpm --filter @objectstack/cli exec vitest run --project unit:Test Files 229 passed (229),Tests 3250 passed (3250). The integration tier is left to CI, except the new pin above.pnpm --filter @objectstack/plugin-security test(read-only consumer):Test Files 139 passed (139),Tests 2839 passed (2839).pnpm --filter @objectstack/metadata-protocol testatcb223b1e:Test Files 189 passed | 3 skipped (192),Tests 2707 passed | 19 skipped (2726). The second merge brought nometadata-protocolsource change (one new test file onmain), and the gate file was re-run at the final head (above).pnpm --filter @objectstack/lint typecheck,@objectstack/metadata-protocoland@objectstack/cliall exit 0.9df0a6f5) failedtest/validate-build-gate-parity.test.tswithcompile.ts / validate.ts call 1 name(s) this file has not classified: stackFilterJudge. The name is now classified in that file'sNOT_A_GATEledger as an input to the registry. Twopublished-subpath-*files failed in the same run only because the CLI itself was not built; after the build they passed (Tests 29 passed (29)).Reverse verification (ablations)
The pin reads
@objectstack/lintand@objectstack/metadata-protocolthroughexports, which resolve todist/, and no alias points them at source. So every leg was:scripts/ablation-replace.mjsin wrap mode (the anchor hits exactly once, the blob changes, the restore is proven blob == HEAD with an emptygit diff HEAD); rebuild the package;scripts/ablation-dist-preflight.mjsshows the marker indist/; run the pin; then rebuild after the restore and preflight--absent.dist/protocol.tspasses the gate NO judge (… && ('ABLATION_20158_A1B' in globalThis) ? { judgeFilter } : {})6 failed | 15 passed (21)expected true to be false(the save door ACCEPTED; the CLI door still refused)21 passedconst refused = ('ABLATION_20158_A2B' in globalThis) && …)6 failed | 15 passed (21)expected [] to deeply equal [ { severity: 'error', … } ]21 passedDirection as predicted: red, and red only where the engine alone refuses. The ten rows owned by earlier passes and the three controls stayed green under both legs.
!('…' in globalThis), which is true, so the judge was still handed through. The mutation was semantically a no-op, and the pin stayed21 passed.ablation-replacerefused it (the anchor count moved 1 → 1) and restored the file. Nothing ran.Both were redone as A1B and A2B above. The tree was clean after every leg (
git status --porcelainempty).Gates
Derived at the final head
a8102979withnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 74 families. Every line was run and recorded ascommand :: exit Nbefore any pipe, then reconciled:check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(exit 3:studio,client-react,embedder-openai,knowledge-ragflow,organizationsandservice-cluster-redishad nodist/). After those six were built it exited 0 (104 published require entry point(s) across 66 package(s) load), and the record carries that re-run.check:i18n,check:i18n-coverageandcheck:i18n-walk-parityanswered exit 3 on the first pass (the CLI was not built). After their named closure build they exited 0.check:test-source-aliasasked for an anchored@objectstack/driver-sqlite-wasmsource alias for the new pin.check:driver-memory-censusrefused a newdriver-memorytest consumer, so the pin stores on a:memory:SQLite driver instead.check:doc-authoringasked for the shrink-only prose-id ledger burn-down.pnpm lintis CI's. The population comes fromeslint.config.mjsitself (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED).eslint --no-inline-config --format jsonover the 16 TypeScript files this diff touches read 16 files, with 0 errors and 0 warnings. The resolvedparserOptionsfor these files is{"ecmaVersion":"latest","sourceType":"module"}, with noprojectand noprojectService, so type-aware linting is off and this diff cannot move a verdict on any untouched file.Test Coreshards,Dogfood,Build Core,Temporal Conformance, the workspace type-check lanes). CI runs them on this PR.Acceptance notes
Fences respected. Not touched:
packages/objectql/src/engine.ts,plugin-security/src/rls-compiler.ts,security-plugin.ts(RLS: a policy's compiled filter skips the shared comparand-shape faces, so a null list member or null ordering bound reaches driver-sql, and the read and the write check disagree (the read hides a row its own check admits) #20212);protocol.ts'sconvertStoredItemDetailed/migrateStoredMetadata(decision: should the losslessly mappable subset of the record-formfilterget a real D2 conversion, or is "stored rows keep loading, re-saving is refused" the final answer? #17321; onlyassertRuntimeAuthoringRulesand one type import changed);service-analytics(security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995);packages/spec/src/contracts/objectql-engine.ts(consumed, never reshaped);packages/core/src/artifact-packages.ts(packages: nullon a release artifact: the schema andcomposeStacksrefuse it, while every reader reads it as absent #19926). No pin here asserts the composed-filter null behaviour.File-surface additions, each measured as needed:
packages/lint/src/runtime-gate.ts:runRuntimeAuthoringRulesis the one published path from the runtime gate to the rules.packages/cli/vitest.config.ts: one anchored@objectstack/driver-memorysource alias, whichcheck:test-source-aliasdemanded for the new pin.scripts/doc-authoring-prose-id.baseline.json: shrink-only burn-down. The oldsurfaceReasonciting [2 of #7891]permission/bookwiring:TYPE_TO_STACK_KEYentries + the cross-collection snapshot the three comparison rules need #8309 / [3 of #7891] Flip the registration:runtimeTypesgainsobject+permission/book— whole rule families cross the publish door #8310 is gone, andcheck:doc-authoringasked for the regenerated ledger.Stored rows. A stored permission row carrying a refused predicate keeps being read ([runtime/metadata] 作者时规则只存在于 CLI:Studio/REST/MCP 的运行时授权面是第四扇门,26 条规则一条不跑——#4409 修完后最大的敞口 #4463 D4); a re-save is judged like any publish.
OS_ALLOW_UNLINTED_METADATA_WRITES=1is the existing migration hatch.A throwing judge (corrected by the seat from the at-tier review, 2026-09-27).
judgeFilterre-throws only a fault that carries no envelope (a bug, by its own contract), and the rule lets it propagate. The two doors then answer differently:os validate/os compile, the command fails loudly;runRulescatch inruntime-gate.ts("a rule that throws … must not take the write down") turns the throw into a warning-tierauthoring-rule-threwadvisory on the 2xx, and the write lands unjudged.The runtime behaviour is not silent, but it is not a refusal either. It is gate-wide policy that predates this PR, and it is not pinned here. analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 still backstops the read scope.
ADR-0087 disposition:
not-required (no-migration-prescription). No key, schema, export or stored representation moves; what is refused is a predicate value the engine already refuses whenever it is asked to run it.Generated by Claude Code