Skip to content

Commit 1207baf

Browse files
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)
Fixes #20158 Clause-②: yes (narrowing) `yes` because two published `@objectstack/lint` signatures gain an optional `judgeFilter` input, so `@objectstack/lint` is `minor`. `(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-scope `using` could 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. - **The rule takes the engine's judge as an input.** `validateRlsPredicateEnforceability(stack, { judgeFilter })` judges the lowered `using` of every `select` / `all` policy with `IObjectQLEngine.judgeFilter`. It runs only when every existing pass left the clause clean, so one defect produces one finding. The finding keeps the existing id `rls-predicate-unenforceable` and quotes the engine's code, status and sentence verbatim. `packages/lint` holds no model of the engine's checks. Without the input, the pass is skipped and the rule answers exactly as before. - **The input is threaded through the run signatures:** `AuthoringRuleContext.judgeFilter` (so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`) and the `judgeFilter` argument of `runRuntimeAuthoringRules`. The registry entry passes `ctx.judgeFilter` to the rule. - **Runtime door.** `assertRuntimeAuthoringRules` (`protocol.ts`) probes `typeof this.engine.judgeFilter === 'function'`. It passes the method, bound to the engine, into `evaluateRuntimeAuthoringGate`, which is internal to the package and stays pure (a new `judgeFilter` argument, the same pattern as `orgWallEnforced`), and from there to `runRuntimeAuthoringRules`. - **CLI door.** The new `packages/cli/src/utils/authoring-filter-judge.ts` builds an `ObjectQL` with no driver and no kernel, registers the stack's objects through its own registry (`applySystemFields` included), and returns its bound `judgeFilter`. 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 five `runAuthoringRules` call 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) and `utils/scaffold-validate.ts`. - **The surfaces flip.** The registry entry is now `surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['permission']`. The per-write snapshot has carried `permissions` and `objects` since #8309, and #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 names `validateSecurityPosture`). - No spec change, no new error code, no new rule id. ### The refusal text (new, quoted) The message the rule builds for an engine refusal. Both doors carry it byte for byte: ```text RLS using `SOURCE` lowers, but the engine refuses to run the lowered filter on "OBJECT" (CODE / STATUS): ENGINE_MESSAGE This clause is part of the object's row-level READ SCOPE, and the analytics face hands the read scope to this same engine admission before it runs a query: every analytics query over the object that this policy scopes is refused. ``` 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 the `current_user` keys (… the kernel-resolved keys …). Write `owner == current_user.id`, not `owner == '{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." - any other code: "Rewrite the comparison the engine names so it can run: a text operator (`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`, whose `issues[]` entry carries the same `rule`, `message` and `hint`. 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.holidays` lowered 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, and `close_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__'] } }` for `current_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 (`publishPackageDrafts` pending 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 `using` clauses that belong to the read scope: `select` and `all`, the two `getApplicablePolicies` matches for a read. A `check` is matched in memory against a post-image and never reaches the engine's `where` admission. A `using` on an `insert` / `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', …)` with `stackFilterJudge`) and `saveMetaItem({ type: 'permission' })` over a real `ObjectQL` host (in-memory driver, the real `sys_metadata` objects from `@objectstack/metadata-core`) holding the same objects. It asserts one verdict and one sentence (`issues[0].message === cliFinding.message`), plus the envelope `INVALID_METADATA` / `422` and the path. | # | Class (#19995) | Family | CEL as written | Rule at both doors | Engine code quoted | Before (`805af4f2`): `os validate` / save | |---|---|---|---|---|---|---| | 1 | list in the implicit equality slot | comparand shape | `region == ['emea', 'apac']` | `rls-predicate-unenforceable` | — | refused / accepted | | 2 | list under `$eq` | comparand shape | `region == current_user.org_user_ids` | `rls-predicate-unenforceable` | — | refused / accepted | | 3 | scalar under `$in` | comparand shape | `region in 'emea'` | `rls-predicate-unenforceable` | — | refused / accepted | | 4 | scalar under `$nin` | comparand shape | `!(region in 'emea')` | `rls-predicate-unenforceable` | — | refused / accepted | | 5 | one-bound `$between` (no CEL spelling; nearest: a range with a null bound) | comparand shape | `amount >= 1 && amount <= null` | `rls-predicate-unenforceable` | — | refused / accepted | | 6 | null member in `$in` | comparand shape | `region in ['emea', null]` | `rls-predicate-unenforceable` | — | refused / accepted | | 7 | plain-object member in `$in` | comparand type | `region in [{'a': 1}]` | `rls-predicate-unenforceable` | — | refused / accepted | | 8 | plain-object comparand under `$eq` | comparand type | `region == {'a': 1}` | `rls-predicate-unenforceable` | — | refused / accepted | | 9 | `undefined` comparand (no CEL spelling; nearest: a key nothing resolves) | comparand type | `region == current_user.nope` | `rls-predicate-unknown-user-variable` | — | refused / accepted | | 10 | unknown placeholder | placeholder | `owner == '{current_usr_id}'` | `rls-predicate-unenforceable` | `FILTER_TOKEN_UNKNOWN` | accepted / accepted | | 11 | known placeholder, no context | placeholder | `owner == '{current_user_id}'` | `rls-predicate-unenforceable` | `FILTER_TOKEN_UNRESOLVED` | accepted / accepted | | 12 | text operator on a non-text field | schema door | `amount.startsWith('5')` | `rls-predicate-unenforceable` | `INVALID_FILTER` | accepted / accepted | | 13 | temporal comparand the platform cannot read | schema door | `close_date > 'soon'` | `rls-predicate-unenforceable` | `INVALID_FILTER` | accepted / accepted | | 14 | filter on a virtual (formula) field | schema door | `is_open == true` | `rls-predicate-unenforceable` | `INVALID_FIELD` | accepted / accepted | | 15 | dotted path through a lookup | schema door | `account.region == 'emea'` | `rls-predicate-unenforceable` | — | refused / accepted | Controls, accepted at both doors: `owner == current_user.id`, `owner in current_user.org_user_ids` (the pre-resolved `IN` form) and `name.startsWith('A')` (a text operator on a text field). The "Before" column was measured on `805af4f2` with a scratch probe (not committed). The rule was run directly for `os validate`, and `saveMetaItem` for a permission write on a stub host. Rows 5 and 9 were not in that probe. Their `os validate` reading was taken at this head with no judge passed, which runs exactly the passes `805af4f2` ran: the rule's diff only adds the judge pass, and `packages/formula` changed 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 `5855081099` records them: a known placeholder judged with no context answers `FILTER_TOKEN_UNRESOLVED`, an unknown one `FILTER_TOKEN_UNKNOWN`. No authored policy in the census uses a `{…}` token, so no mode change is needed and none is added. ## Measurements - **Producer census, with the real rule and a real judge** (scratch probe, not committed; tree `cb223b1e`). A driverless `ObjectQL` held every object definition the tree ships (`packages/**/src/**/*.object.ts`: 75 files, 75 objects, 0 imports failed, 0 refused by the registry). The run was `validateRlsPredicateEnforceability(stack, { judgeFilter })`: - `plugin-security` `defaultPermissionSets`: 107 clauses, 104 read-scope `using`, 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/metadata` hotcrm fixture: 4 clauses, 2 read-scope `using`, 2 judge calls, **0 findings**. - Positive control, same run: a planted `user_id.startsWith('{current_user_id}')` on `sys_session` produced 1 finding (`FILTER_TOKEN_UNRESOLVED`). - `examples/app-showcase` (`allPermissionSets` over its 24 objects, judged by an engine holding those objects, 0 refused by the registry): 3 clauses, 2 read-scope `using`, 2 judge calls, **0 findings**. - **Placeholder spelling:** in `examples/**` and `packages/**` (non-test), 63 of 88 `using:` / `check:` lines name `current_user.` (CEL variables), and 0 carry a `{token}`. cloud `origin/main` `96eb092` has 0 authored policies (control: `rowLevelSecurity` 1 comment hit). objectui at pin `f8a9d0fb` has 0 authored policies (control: `rowLevelSecurity` 10 hits, the Studio RLS editor `PermissionAdvancedFacets.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/main` `443b2f4f` merged; the post-merge re-verification ran after `pnpm install --frozen-lockfile` and a `@objectstack/cli...` closure build, `Tasks: 56 successful, 56 total`): - **The two-door pin** (`packages/cli/test/rls-policy-authoring-admission.test.ts`; the tier file places it in `integration`, run locally because this diff adds it): `Tests 21 passed (21)`. - **Runtime door** (`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`, the new `#20158` block on the file's existing pinned stub host): `Tests 39 passed (39)`. - **Lint contract with the judge** (`packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts`, 11 cases) runs inside the lint suite below. - **Consumer suites** (each package's whole suite): - `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 test` at `cb223b1e`: `Test Files 189 passed | 3 skipped (192)`, `Tests 2707 passed | 19 skipped (2726)`. The second merge brought no `metadata-protocol` source change (one new test file on `main`), and the gate file was re-run at the final head (above). - **Typecheck at the final head:** `pnpm --filter @objectstack/lint typecheck`, `@objectstack/metadata-protocol` and `@objectstack/cli` all exit 0. - **An earlier red, fixed in this PR:** the first CLI unit run (`9df0a6f5`) failed `test/validate-build-gate-parity.test.ts` with `compile.ts / validate.ts call 1 name(s) this file has not classified: stackFilterJudge`. The name is now classified in that file's `NOT_A_GATE` ledger as an input to the registry. Two `published-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/lint` and `@objectstack/metadata-protocol` through `exports`, which resolve to `dist/`, and no alias points them at source. So every leg was: `scripts/ablation-replace.mjs` in wrap mode (the anchor hits exactly once, the blob changes, the restore is proven blob == HEAD with an empty `git diff HEAD`); rebuild the package; `scripts/ablation-dist-preflight.mjs` shows the marker in `dist/`; run the pin; then rebuild after the restore and preflight `--absent`. | Leg | Mutation | Marker in `dist/` | Pin under mutation | Failing cases | Restore | |---|---|---|---|---|---| | A1B | `protocol.ts` passes the gate NO judge (`… && ('ABLATION_20158_A1B' in globalThis) ? { judgeFilter } : {}`) | present in 2 built files | `6 failed \| 15 passed (21)` | rows 10–14 and the binding-refusal case: `expected true to be false` (the save door ACCEPTED; the CLI door still refused) | rebuilt, marker absent, pin `21 passed` | | A2B | the rule never asks the judge (`const refused = ('ABLATION_20158_A2B' in globalThis) && …`) | present | `6 failed \| 15 passed (21)` | the same six, failing first at the CLI door: `expected [] to deeply equal [ { severity: 'error', … } ]` | rebuilt, marker absent, pin `21 passed` | Direction 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. ⚠️ The first pair of legs did not measure anything, and their readings are discarded: - A1's replacement read `!('…' in globalThis)`, which is true, so the judge was still handed through. The mutation was semantically a no-op, and the pin stayed `21 passed`. - A2's replacement still contained its own anchor, so `ablation-replace` refused 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 --porcelain` empty). ## Gates Derived at the final head `a8102979` with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 74 families. Every line was run and recorded as `command :: exit N` before any pipe, then reconciled: ```text Run reconciliation — 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN. dispatch-gates --ran: 74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3). ``` - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3: `studio`, `client-react`, `embedder-openai`, `knowledge-ragflow`, `organizations` and `service-cluster-redis` had no `dist/`). 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-coverage` and `check:i18n-walk-parity` answered exit 3 on the first pass (the CLI was not built). After their named closure build they exited 0. - Fixed by this PR on the way: - `check:test-source-alias` asked for an anchored `@objectstack/driver-sqlite-wasm` source alias for the new pin. - `check:driver-memory-census` refused a new `driver-memory` test consumer, so the pin stores on a `:memory:` SQLite driver instead. - `check:doc-authoring` asked for the shrink-only prose-id ledger burn-down. - **Lint, narrowed and proven.** `pnpm lint` is CI's. The population comes from `eslint.config.mjs` itself (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`). `eslint --no-inline-config --format json` over the 16 TypeScript files this diff touches read 16 files, with 0 errors and 0 warnings. The resolved `parserOptions` for these files is `{"ecmaVersion":"latest","sourceType":"module"}`, with no `project` and no `projectService`, so type-aware linting is off and this diff cannot move a verdict on any untouched file. - **Not local, by the dispatch tooling's own accounting:** the CI job steps and the wide-population and roster families the derivation lists outside its 74 (`Test Core` shards, `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` (#20212); `protocol.ts`'s `convertStoredItemDetailed` / `migrateStoredMetadata` (#17321; only `assertRuntimeAuthoringRules` and one type import changed); `service-analytics` (#19995); `packages/spec/src/contracts/objectql-engine.ts` (consumed, never reshaped); `packages/core/src/artifact-packages.ts` (#19926). No pin here asserts the composed-filter null behaviour. - **File-surface additions, each measured as needed:** - `packages/lint/src/runtime-gate.ts`: `runRuntimeAuthoringRules` is the one published path from the runtime gate to the rules. - `packages/cli/vitest.config.ts`: one anchored `@objectstack/driver-memory` source alias, which `check:test-source-alias` demanded for the new pin. - `scripts/doc-authoring-prose-id.baseline.json`: shrink-only burn-down. The old `surfaceReason` citing #8309 / #8310 is gone, and `check:doc-authoring` asked for the regenerated ledger. - **Stored rows.** A stored permission row carrying a refused predicate keeps being read (#4463 D4); a re-save is judged like any publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` is the existing migration hatch. - **A throwing judge (corrected by the seat from the at-tier review, 2026-09-27).** `judgeFilter` re-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: - at `os validate` / `os compile`, the command fails loudly; - at the runtime save door, the pre-existing `runRules` catch in `runtime-gate.ts` ("a rule that throws … must not take the write down") turns the throw into a warning-tier `authoring-rule-threw` advisory 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. #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](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 17bd318 commit 1207baf

18 files changed

Lines changed: 1025 additions & 18 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint`
8+
9+
A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there.
10+
11+
- **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before.
12+
- **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method.
13+
- **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence.
14+
- **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts.
15+
16+
**BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused.
17+
18+
Two edges are not closed here, both deliberately:
19+
20+
- The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission.
21+
- At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer.
22+
23+
Clause-②: yes (narrowing)
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves: no `packages/spec` key, schema, export or config field is removed or renamed, and no stored metadata representation changes, so `objectstack migrate meta` has nothing to rewrite. What is refused is a predicate VALUE the engine's own filter admission already refuses whenever it is asked to run it; the refusal now arrives when the policy is authored. The new lint inputs are optional additions. -->

‎packages/cli/src/commands/compile.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1717
import { lowerCallables } from '../utils/lower-callables.js';
1818
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1919
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
20+
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
2021
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2122
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2223
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
@@ -417,10 +418,16 @@ export default class Compile extends Command {
417418
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
418419
jsxGateNotices = [...jsxGate.notices];
419420
if (!flags.json) printJsxGateNotices(jsxGateNotices);
421+
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
422+
// [#20158] The engine's own filter admission over this stack's objects —
423+
// see `validate.ts` step 3 and `utils/authoring-filter-judge.ts`. One
424+
// judge for the union run and the per-package pass.
425+
const judgeFilter = stackFilterJudge(parsedUnion);
420426
const findings = runAuthoringRules('build', {
421427
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
422-
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
428+
parsed: parsedUnion,
423429
sduiManifest: jsxGate.sduiManifest,
430+
judgeFilter,
424431
// [#16546] Ref strings, not hook indices — survive the union fold and
425432
// the per-package re-slice below unchanged (see `LoweringResult.
426433
// loweredHookRefs`'s header for why an index would not).
@@ -520,6 +527,7 @@ export default class Compile extends Command {
520527
unionFindings: findings,
521528
sduiManifest: jsxGate.sduiManifest,
522529
loweredHookRefs: lowering.loweredHookRefs,
530+
judgeFilter,
523531
});
524532
const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> =
525533
perPackage.errors;

‎packages/cli/src/commands/lint.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
packageBodyAsStack,
2121
runPerPackageAuthoringRules,
2222
} from '../utils/artifact-packages.js';
23+
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
2324
import { runMetadataEval } from '../lint/metadata-eval.js';
2425
import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js';
2526
import {
@@ -670,10 +671,16 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
670671
// so `lowered` already carries the folded collections and re-folding it here
671672
// would be a second call that could only ever return by identity.
672673
const { lowered, loweredHookRefs } = lowerCallables(stack as Record<string, unknown>);
674+
// [#20158] The engine's own filter admission over this stack's objects — the
675+
// same judge `os build` / `os validate` hand the table (see
676+
// `utils/authoring-filter-judge.ts`), for the union run and the per-package
677+
// pass below.
678+
const judgeFilter = stackFilterJudge(lowered as Record<string, unknown>);
673679
const unionFindings = runAuthoringRules('lint', {
674680
normalized: stack,
675681
parsed: lowered,
676682
sduiManifest: opts.sduiManifest,
683+
judgeFilter,
677684
// [#16546] Same ref set `os build` computes from the same normalized
678685
// input — what lets `validateReadonlyHookWrites` / `validateHookBodyWrites`
679686
// report `hooks[i].handler` here byte-identically to `os build`.
@@ -743,6 +750,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
743750
unionFindings,
744751
sduiManifest: opts.sduiManifest,
745752
loweredHookRefs,
753+
judgeFilter,
746754
}).findings;
747755

748756
// ⛔ ONE mapping for both halves. A second copy of this expression is how one

‎packages/cli/src/commands/validate.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1818
// [#18677] The per-package half of the author-time rule run, shared with
1919
// `os compile` — ⛔ the loop is not re-written here; see that module's header.
2020
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
21+
import { stackFilterJudge } from '../utils/authoring-filter-judge.js';
2122
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2223
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
2324
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
@@ -374,13 +375,20 @@ export default class Validate extends Command {
374375
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
375376
jsxGateNotices = [...jsxGate.notices];
376377
if (!flags.json) printJsxGateNotices(jsxGateNotices);
378+
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
379+
// [#20158] The engine's own filter admission over this stack's objects —
380+
// built lazily, driverless; see `utils/authoring-filter-judge.ts`. ONE
381+
// judge for the union run and the per-package pass below, because the
382+
// runtime judges every package's policies against one registry.
383+
const judgeFilter = stackFilterJudge(parsedUnion);
377384
const findings = runAuthoringRules('validate', {
378385
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
379-
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
386+
parsed: parsedUnion,
380387
sduiManifest: jsxGate.sduiManifest,
381388
// [#16546] Same ref set `os build` / `os lint` compute — keeps this
382389
// door's hook write-set findings at the same `path` as the other two.
383390
loweredHookRefs: lowering.loweredHookRefs,
391+
judgeFilter,
384392
});
385393
const { errors: ruleErrors, advisories } = splitBySeverity(findings);
386394
ruleAdvisories = advisories;
@@ -466,6 +474,7 @@ export default class Validate extends Command {
466474
// per-package hook write-set finding reports at the same `path` the
467475
// other two doors report it at.
468476
loweredHookRefs: lowering.loweredHookRefs,
477+
judgeFilter,
469478
});
470479
ruleAdvisories = [...ruleAdvisories, ...perPackage.advisories];
471480
if (perPackage.errors.length > 0) {

‎packages/cli/src/utils/artifact-packages.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
splitBySeverity,
4545
type AuthoringCommand,
4646
type AuthoringFinding,
47+
type AuthoringRuleContext,
4748
} from '@objectstack/lint';
4849

4950
/**
@@ -267,6 +268,12 @@ export function runPerPackageAuthoringRules(run: {
267268
unionFindings: readonly AuthoringFinding[];
268269
sduiManifest?: unknown;
269270
loweredHookRefs?: ReadonlySet<string>;
271+
/**
272+
* [#20158] The engine judge the union run was handed — the SAME one, built
273+
* from the union's objects, because the runtime judges every package's RLS
274+
* policies against one registry (`utils/authoring-filter-judge.ts`).
275+
*/
276+
judgeFilter?: AuthoringRuleContext['judgeFilter'];
270277
}): {
271278
/** How many package entries were walked — 0 means the pass did not run. */
272279
packageCount: number;
@@ -303,6 +310,7 @@ export function runPerPackageAuthoringRules(run: {
303310
parsed: asStack,
304311
sduiManifest: run.sduiManifest,
305312
loweredHookRefs: run.loweredHookRefs,
313+
judgeFilter: run.judgeFilter,
306314
}).filter((f) => !alreadyReported.has(findingKey(f)));
307315
for (const f of pkgFindings) alreadyReported.add(findingKey(f));
308316
// ⛔ ONE prefixer, applied to every member of all three lists. The `where`
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20158] The CLI door's engine judge — the ENGINE's own filter admission,
5+
* built from the stack's own objects, handed to the shared authoring rules.
6+
*
7+
* ## Why an engine, and why no driver
8+
*
9+
* `validateRlsPredicateEnforceability` (`@objectstack/lint`) judges every
10+
* read-scope RLS `using` with `IObjectQLEngine.judgeFilter` (ADR-0058 D2,
11+
* #19995 ruling C). At the runtime publish gate that is the live engine's
12+
* method. `os validate` / `os build` / `os lint` have no live engine, and the
13+
* rule may not model the engine's walks in its place — so this door builds the
14+
* engine itself and asks it.
15+
*
16+
* It can, because the admission reads nothing a driver or a kernel supplies:
17+
* `judgeFilter` runs the engine's `where` admission against the `where`, the
18+
* registry's field map and the context, and stops before any driver is
19+
* resolved. Measured on #20158: an `ObjectQL` constructed with no driver and no
20+
* kernel, the stack's objects registered through its own registry, answered
21+
* every class the runtime engine answers — including the registry-injected
22+
* system columns (`created_at` compared against an unreadable value is refused
23+
* here exactly as it is there), because registration is the registry's own
24+
* code path, `applySystemFields` included.
25+
*
26+
* ## What it answers for an object it does not know
27+
*
28+
* The engine's own answer: for an object its registry does not hold, the
29+
* field-map doors answer nothing and the schema-free doors (placeholders,
30+
* comparand shape) still judge. Two populations get that answer here and a
31+
* fuller one at runtime, where the live registry holds the whole universe:
32+
* an object another package or the platform defines, and an object the
33+
* registry itself refuses to register (a field type it rejects, a name
34+
* collision) — refused at boot by that same registry, and simply not
35+
* registered here rather than turned into a verdict this door invents.
36+
*
37+
* ## Cost
38+
*
39+
* Built on the FIRST judgement and reused after it, so a stack that declares no
40+
* read-scope RLS policy never constructs an engine at all. The objects are
41+
* COPIED before registration: `registerObject` stamps `name` onto nameless
42+
* field entries, and the rules that run after this one read the same stack.
43+
*/
44+
45+
import { createLogger } from '@objectstack/core';
46+
import type { AuthoringRuleContext } from '@objectstack/lint';
47+
import { ObjectQL } from '@objectstack/objectql';
48+
49+
type AnyRec = Record<string, unknown>;
50+
51+
/** The engine's judge-only filter admission, as the rules take it. */
52+
type FilterJudge = NonNullable<AuthoringRuleContext['judgeFilter']>;
53+
54+
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);
55+
56+
/**
57+
* The stack's object declarations, from either spelling a stack carries: the
58+
* array, or the name-keyed map (whose key is the name when the entry omits it).
59+
*/
60+
function objectsOf(value: unknown): AnyRec[] {
61+
if (Array.isArray(value)) return value.filter(isRec);
62+
if (!isRec(value)) return [];
63+
return Object.entries(value)
64+
.filter((entry): entry is [string, AnyRec] => isRec(entry[1]))
65+
.map(([key, obj]) => (typeof obj.name === 'string' && obj.name ? obj : { ...obj, name: key }));
66+
}
67+
68+
/** A copy registration may stamp without touching the stack the other rules read. */
69+
function registrationCopy(obj: AnyRec): AnyRec {
70+
if (!isRec(obj.fields)) return { ...obj };
71+
const fields: AnyRec = {};
72+
for (const [key, def] of Object.entries(obj.fields)) fields[key] = isRec(def) ? { ...def } : def;
73+
return { ...obj, fields };
74+
}
75+
76+
/**
77+
* The judge for `stack`: `ObjectQL.judgeFilter`, bound to a driverless engine
78+
* whose registry holds the stack's objects. Hand it to `runAuthoringRules` /
79+
* `runPerPackageAuthoringRules` as `judgeFilter`.
80+
*/
81+
export function stackFilterJudge(stack: AnyRec): FilterJudge {
82+
let engine: ObjectQL | undefined;
83+
const build = (): ObjectQL => {
84+
const ql = new ObjectQL({ logger: createLogger({ level: 'silent' }) });
85+
// The registry's own housekeeping log ("Registered object: …") is for a
86+
// booting server, not for an authoring command's output.
87+
ql.registry.logLevel = 'silent';
88+
for (const obj of objectsOf(stack.objects)) {
89+
try {
90+
ql.registerObject(registrationCopy(obj) as Parameters<ObjectQL['registerObject']>[0]);
91+
} catch {
92+
// Refused by the registry — see the header: that object gets the
93+
// engine's unknown-object answer, never a verdict made up here.
94+
}
95+
}
96+
return ql;
97+
};
98+
return (objectName, where, options) => (engine ??= build()).judgeFilter(objectName, where, options);
99+
}

‎packages/cli/src/utils/scaffold-validate.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type AuthoringCommand,
4141
type AuthoringFinding,
4242
} from '@objectstack/lint';
43+
import { stackFilterJudge } from './authoring-filter-judge.js';
4344
import { lowerCallables } from './lower-callables.js';
4445
import { resolveSduiManifest } from './sdui-manifest.js';
4546

@@ -98,6 +99,8 @@ export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport {
9899
// [#16546] Same ref set the other three doors compute — keeps this
99100
// door's hook write-set findings at the same `path` as they are.
100101
loweredHookRefs: lowering.loweredHookRefs,
102+
// [#20158] The same engine judge the other three doors hand the table.
103+
judgeFilter: stackFilterJudge(result.data as Record<string, unknown>),
101104
});
102105
const { errors, advisories } = splitBySeverity(findings);
103106

0 commit comments

Comments
 (0)