From 4fe6bd2e67658d3d6ff5b8fbe8221c466f59cfd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:39:10 +0000 Subject: [PATCH 1/5] feat(lint,metadata-protocol,cli): RLS read scopes are admitted by the 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 --- packages/cli/src/commands/compile.ts | 10 +- packages/cli/src/commands/lint.ts | 8 + packages/cli/src/commands/validate.ts | 11 +- packages/cli/src/utils/artifact-packages.ts | 8 + .../cli/src/utils/authoring-filter-judge.ts | 99 +++++++++ packages/cli/src/utils/scaffold-validate.ts | 3 + packages/lint/src/authoring-rules.ts | 43 +++- packages/lint/src/runtime-gate.ts | 15 +- .../validate-rls-predicate-enforceability.ts | 193 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 21 +- .../src/runtime-authoring-gate.ts | 15 ++ 11 files changed, 410 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/utils/authoring-filter-judge.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 1a5a8a775b9..ed964783ea4 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -17,6 +17,7 @@ import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; import { lowerCallables } from '../utils/lower-callables.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js'; +import { stackFilterJudge } from '../utils/authoring-filter-judge.js'; import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint'; import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js'; @@ -417,10 +418,16 @@ export default class Compile extends Command { const jsxGate = resolveJsxGateManifest(result.data as Record); jsxGateNotices = [...jsxGate.notices]; if (!flags.json) printJsxGateNotices(jsxGateNotices); + const parsedUnion = authoringRuleUnionStack(result.data as Record); + // [#20158] The engine's own filter admission over this stack's objects — + // see `validate.ts` step 3 and `utils/authoring-filter-judge.ts`. One + // judge for the union run and the per-package pass. + const judgeFilter = stackFilterJudge(parsedUnion); const findings = runAuthoringRules('build', { normalized: authoringRuleUnionStack(normalized as Record), - parsed: authoringRuleUnionStack(result.data as Record), + parsed: parsedUnion, sduiManifest: jsxGate.sduiManifest, + judgeFilter, // [#16546] Ref strings, not hook indices — survive the union fold and // the per-package re-slice below unchanged (see `LoweringResult. // loweredHookRefs`'s header for why an index would not). @@ -520,6 +527,7 @@ export default class Compile extends Command { unionFindings: findings, sduiManifest: jsxGate.sduiManifest, loweredHookRefs: lowering.loweredHookRefs, + judgeFilter, }); const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> = perPackage.errors; diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index f816ba5594c..0b36206419d 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -20,6 +20,7 @@ import { packageBodyAsStack, runPerPackageAuthoringRules, } from '../utils/artifact-packages.js'; +import { stackFilterJudge } from '../utils/authoring-filter-judge.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js'; import { @@ -670,10 +671,16 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue // so `lowered` already carries the folded collections and re-folding it here // would be a second call that could only ever return by identity. const { lowered, loweredHookRefs } = lowerCallables(stack as Record); + // [#20158] The engine's own filter admission over this stack's objects — the + // same judge `os build` / `os validate` hand the table (see + // `utils/authoring-filter-judge.ts`), for the union run and the per-package + // pass below. + const judgeFilter = stackFilterJudge(lowered as Record); const unionFindings = runAuthoringRules('lint', { normalized: stack, parsed: lowered, sduiManifest: opts.sduiManifest, + judgeFilter, // [#16546] Same ref set `os build` computes from the same normalized // input — what lets `validateReadonlyHookWrites` / `validateHookBodyWrites` // report `hooks[i].handler` here byte-identically to `os build`. @@ -743,6 +750,7 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue unionFindings, sduiManifest: opts.sduiManifest, loweredHookRefs, + judgeFilter, }).findings; // ⛔ ONE mapping for both halves. A second copy of this expression is how one diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 418a592e284..a08b1e4739b 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -18,6 +18,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js'; // [#18677] The per-package half of the author-time rule run, shared with // `os compile` — ⛔ the loop is not re-written here; see that module's header. import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js'; +import { stackFilterJudge } from '../utils/authoring-filter-judge.js'; import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint'; import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js'; import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; @@ -374,13 +375,20 @@ export default class Validate extends Command { const jsxGate = resolveJsxGateManifest(result.data as Record); jsxGateNotices = [...jsxGate.notices]; if (!flags.json) printJsxGateNotices(jsxGateNotices); + const parsedUnion = authoringRuleUnionStack(result.data as Record); + // [#20158] The engine's own filter admission over this stack's objects — + // built lazily, driverless; see `utils/authoring-filter-judge.ts`. ONE + // judge for the union run and the per-package pass below, because the + // runtime judges every package's policies against one registry. + const judgeFilter = stackFilterJudge(parsedUnion); const findings = runAuthoringRules('validate', { normalized: authoringRuleUnionStack(normalized as Record), - parsed: authoringRuleUnionStack(result.data as Record), + parsed: parsedUnion, sduiManifest: jsxGate.sduiManifest, // [#16546] Same ref set `os build` / `os lint` compute — keeps this // door's hook write-set findings at the same `path` as the other two. loweredHookRefs: lowering.loweredHookRefs, + judgeFilter, }); const { errors: ruleErrors, advisories } = splitBySeverity(findings); ruleAdvisories = advisories; @@ -466,6 +474,7 @@ export default class Validate extends Command { // per-package hook write-set finding reports at the same `path` the // other two doors report it at. loweredHookRefs: lowering.loweredHookRefs, + judgeFilter, }); ruleAdvisories = [...ruleAdvisories, ...perPackage.advisories]; if (perPackage.errors.length > 0) { diff --git a/packages/cli/src/utils/artifact-packages.ts b/packages/cli/src/utils/artifact-packages.ts index 0b93d661b8f..08998d058b3 100644 --- a/packages/cli/src/utils/artifact-packages.ts +++ b/packages/cli/src/utils/artifact-packages.ts @@ -44,6 +44,7 @@ import { splitBySeverity, type AuthoringCommand, type AuthoringFinding, + type AuthoringRuleContext, } from '@objectstack/lint'; /** @@ -267,6 +268,12 @@ export function runPerPackageAuthoringRules(run: { unionFindings: readonly AuthoringFinding[]; sduiManifest?: unknown; loweredHookRefs?: ReadonlySet; + /** + * [#20158] The engine judge the union run was handed — the SAME one, built + * from the union's objects, because the runtime judges every package's RLS + * policies against one registry (`utils/authoring-filter-judge.ts`). + */ + judgeFilter?: AuthoringRuleContext['judgeFilter']; }): { /** How many package entries were walked — 0 means the pass did not run. */ packageCount: number; @@ -303,6 +310,7 @@ export function runPerPackageAuthoringRules(run: { parsed: asStack, sduiManifest: run.sduiManifest, loweredHookRefs: run.loweredHookRefs, + judgeFilter: run.judgeFilter, }).filter((f) => !alreadyReported.has(findingKey(f))); for (const f of pkgFindings) alreadyReported.add(findingKey(f)); // ⛔ ONE prefixer, applied to every member of all three lists. The `where` diff --git a/packages/cli/src/utils/authoring-filter-judge.ts b/packages/cli/src/utils/authoring-filter-judge.ts new file mode 100644 index 00000000000..b841c45d605 --- /dev/null +++ b/packages/cli/src/utils/authoring-filter-judge.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20158] The CLI door's engine judge — the ENGINE's own filter admission, + * built from the stack's own objects, handed to the shared authoring rules. + * + * ## Why an engine, and why no driver + * + * `validateRlsPredicateEnforceability` (`@objectstack/lint`) judges every + * read-scope RLS `using` with `IObjectQLEngine.judgeFilter` (ADR-0058 D2, + * #19995 ruling C). At the runtime publish gate that is the live engine's + * method. `os validate` / `os build` / `os lint` have no live engine, and the + * rule may not model the engine's walks in its place — so this door builds the + * engine itself and asks it. + * + * It can, because the admission reads nothing a driver or a kernel supplies: + * `judgeFilter` runs the engine's `where` admission against the `where`, the + * registry's field map and the context, and stops before any driver is + * resolved. Measured on #20158: an `ObjectQL` constructed with no driver and no + * kernel, the stack's objects registered through its own registry, answered + * every class the runtime engine answers — including the registry-injected + * system columns (`created_at` compared against an unreadable value is refused + * here exactly as it is there), because registration is the registry's own + * code path, `applySystemFields` included. + * + * ## What it answers for an object it does not know + * + * The engine's own answer: for an object its registry does not hold, the + * field-map doors answer nothing and the schema-free doors (placeholders, + * comparand shape) still judge. Two populations get that answer here and a + * fuller one at runtime, where the live registry holds the whole universe: + * an object another package or the platform defines, and an object the + * registry itself refuses to register (a field type it rejects, a name + * collision) — refused at boot by that same registry, and simply not + * registered here rather than turned into a verdict this door invents. + * + * ## Cost + * + * Built on the FIRST judgement and reused after it, so a stack that declares no + * read-scope RLS policy never constructs an engine at all. The objects are + * COPIED before registration: `registerObject` stamps `name` onto nameless + * field entries, and the rules that run after this one read the same stack. + */ + +import { createLogger } from '@objectstack/core'; +import type { AuthoringRuleContext } from '@objectstack/lint'; +import { ObjectQL } from '@objectstack/objectql'; + +type AnyRec = Record; + +/** The engine's judge-only filter admission, as the rules take it. */ +type FilterJudge = NonNullable; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** + * The stack's object declarations, from either spelling a stack carries: the + * array, or the name-keyed map (whose key is the name when the entry omits it). + */ +function objectsOf(value: unknown): AnyRec[] { + if (Array.isArray(value)) return value.filter(isRec); + if (!isRec(value)) return []; + return Object.entries(value) + .filter((entry): entry is [string, AnyRec] => isRec(entry[1])) + .map(([key, obj]) => (typeof obj.name === 'string' && obj.name ? obj : { ...obj, name: key })); +} + +/** A copy registration may stamp without touching the stack the other rules read. */ +function registrationCopy(obj: AnyRec): AnyRec { + if (!isRec(obj.fields)) return { ...obj }; + const fields: AnyRec = {}; + for (const [key, def] of Object.entries(obj.fields)) fields[key] = isRec(def) ? { ...def } : def; + return { ...obj, fields }; +} + +/** + * The judge for `stack`: `ObjectQL.judgeFilter`, bound to a driverless engine + * whose registry holds the stack's objects. Hand it to `runAuthoringRules` / + * `runPerPackageAuthoringRules` as `judgeFilter`. + */ +export function stackFilterJudge(stack: AnyRec): FilterJudge { + let engine: ObjectQL | undefined; + const build = (): ObjectQL => { + const ql = new ObjectQL({ logger: createLogger({ level: 'silent' }) }); + // The registry's own housekeeping log ("Registered object: …") is for a + // booting server, not for an authoring command's output. + ql.registry.logLevel = 'silent'; + for (const obj of objectsOf(stack.objects)) { + try { + ql.registerObject(registrationCopy(obj) as Parameters[0]); + } catch { + // Refused by the registry — see the header: that object gets the + // engine's unknown-object answer, never a verdict made up here. + } + } + return ql; + }; + return (objectName, where, options) => (engine ??= build()).judgeFilter(objectName, where, options); +} diff --git a/packages/cli/src/utils/scaffold-validate.ts b/packages/cli/src/utils/scaffold-validate.ts index c6030a055b6..2bc41568194 100644 --- a/packages/cli/src/utils/scaffold-validate.ts +++ b/packages/cli/src/utils/scaffold-validate.ts @@ -40,6 +40,7 @@ import { type AuthoringCommand, type AuthoringFinding, } from '@objectstack/lint'; +import { stackFilterJudge } from './authoring-filter-judge.js'; import { lowerCallables } from './lower-callables.js'; import { resolveSduiManifest } from './sdui-manifest.js'; @@ -98,6 +99,8 @@ export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport { // [#16546] Same ref set the other three doors compute — keeps this // door's hook write-set findings at the same `path` as they are. loweredHookRefs: lowering.loweredHookRefs, + // [#20158] The same engine judge the other three doors hand the table. + judgeFilter: stackFilterJudge(result.data as Record), }); const { errors, advisories } = splitBySeverity(findings); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index fddce19f54f..9aa91863fb0 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -145,6 +145,8 @@ import { lintLegacyOrganizationComposites, } from './data-model-rules.js'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; + type AnyRec = Record; // ─── Types ────────────────────────────────────────────────────────── @@ -317,6 +319,21 @@ export interface AuthoringRuleContext { * is author-written", and both validators keep reporting `body.source`. */ loweredHookRefs?: ReadonlySet; + /** + * [#20158] The engine's judge-only filter admission + * (`IObjectQLEngine.judgeFilter`, #19995 ruling C), BOUND to its engine — + * the one function that answers "would the engine run this `where`?". + * + * Supplied by the door that holds an engine: the runtime publish gate hands + * in the live engine's method, and the CLI commands hand in the method of an + * engine built from the stack's own objects with no driver. Read by + * `validateRlsPredicateEnforceability` only, which judges each read-scope + * RLS `using` with it (ADR-0058 D2). Absent, that judgement is skipped and + * every rule answers what it answered without it. ⛔ No rule models the + * engine's walks in its place: a rule that needs the engine's verdict reads + * this input or does not judge. + */ + judgeFilter?: IObjectQLEngine['judgeFilter']; } export interface AuthoringRule { @@ -1827,19 +1844,25 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // as an authorization and behaves as a blanket refusal. Same construction as // the sharing-rule entry: the verdict is the runtime's own function, reached // through `@objectstack/formula` (where #4983 hoisted it), never a model of it. + // + // [#20158] Across the runtime publish gate, for `permission` writes. The + // per-write snapshot has carried `permissions` and `objects` since #8309, so + // the only gap was this declaration (#8310 closed with no ruling that kept + // the rule CLI-only). A Studio, REST `/meta` or MCP author who writes an RLS + // policy has no other door, and until this entry crossed that door accepted + // every predicate this rule refuses. `ctx.judgeFilter` is the engine's + // judge-only admission, handed in by the door (ADR-0058 D2, #19995 ruling C): + // the live engine's at the publish gate, a driverless engine built from the + // stack's objects at the CLI. { name: 'validateRlsPredicateEnforceability', tier: 'gating', input: 'parsed', commands: ALL, source: 'packages/lint/src/validate-rls-predicate-enforceability.ts', - surfaces: CLI_ONLY, - surfaceReason: - 'The rule reads `stack.permissions[]`, which the per-write snapshot DOES carry since #8309 — ' - + 'the remaining gap is only the declaration: no `runtimeTypes` names `permission` here, and that ' - + 'flip is a rollout decision on #8310\'s axis, not a wiring fix. Recorded as pending rather than ' - + 'done, because a rule that has never run at a door should not claim it.', - run: (stack) => validateRlsPredicateEnforceability(stack), + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['permission'], + run: (stack, ctx) => validateRlsPredicateEnforceability(stack, { judgeFilter: ctx.judgeFilter }), }, // #4762 — the same "declared but enforces nothing" question, for the two // STATIC artifacts an object validation rule carries. A `format` rule's @@ -1929,7 +1952,11 @@ export function authoringRulesFor(command: AuthoringCommand): readonly Authoring */ export function runAuthoringRules(command: AuthoringCommand, run: AuthoringRuleRun): AuthoringFinding[] { const findings: AuthoringFinding[] = []; - const ctx: AuthoringRuleContext = { sduiManifest: run.sduiManifest, loweredHookRefs: run.loweredHookRefs }; + const ctx: AuthoringRuleContext = { + sduiManifest: run.sduiManifest, + loweredHookRefs: run.loweredHookRefs, + judgeFilter: run.judgeFilter, + }; for (const rule of authoringRulesFor(command)) { const stack = rule.input === 'normalized' ? run.normalized : (run.parsed ?? run.normalized); findings.push(...rule.run(stack, ctx)); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index b6a77b2db19..e507af9457f 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -56,6 +56,7 @@ import { type AuthoringRuleContext, } from './authoring-rules.js'; import { isSystemObject } from './validate-security-posture.js'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; type AnyRec = Record; @@ -885,6 +886,14 @@ export function runRuntimeAuthoringRules(args: { packageScope?: RuntimePackageScope; /** ADR-0080 SDUI manifest, when the host has one. */ sduiManifest?: unknown; + /** + * [#20158] The host engine's judge-only filter admission + * (`IObjectQLEngine.judgeFilter`), bound to that engine — handed to the rules + * as `AuthoringRuleContext.judgeFilter`. The host probes for it + * (`typeof engine.judgeFilter === 'function'`); omitted, the rules that read + * it skip the engine's judgement and answer as they did without it. + */ + judgeFilter?: IObjectQLEngine['judgeFilter']; }): RuntimeGateResult { const rules = runtimeAuthoringRulesFor(args.type); const empty: RuntimeGateResult = { errors: [], advisories: [], rulesRun: [] }; @@ -908,7 +917,11 @@ export function runRuntimeAuthoringRules(args: { // reference-integrity suite) which per-write snapshot it is judging, so it // can dispatch its MEMBERS as this gate dispatches entries. CLI callers // never set it; see `AuthoringRuleContext`. - const ctx: AuthoringRuleContext = { sduiManifest: args.sduiManifest, runtimeWriteType: args.type }; + const ctx: AuthoringRuleContext = { + sduiManifest: args.sduiManifest, + runtimeWriteType: args.type, + judgeFilter: args.judgeFilter, + }; const before = new Set(runRules(rules, snapshots.baseline, ctx).map(fingerprint)); const added = runRules(rules, snapshots.candidate, ctx) .filter((f) => !before.has(fingerprint(f))) diff --git a/packages/lint/src/validate-rls-predicate-enforceability.ts b/packages/lint/src/validate-rls-predicate-enforceability.ts index 8589ec8c05a..8cc1b95402b 100644 --- a/packages/lint/src/validate-rls-predicate-enforceability.ts +++ b/packages/lint/src/validate-rls-predicate-enforceability.ts @@ -157,8 +157,51 @@ * live — but a switched-off policy that can never enforce is a policy that will * grant nothing on the day someone switches it on, and that is precisely the * moment nobody re-runs the linter. + * + * ## The engine's own admission of the read scope (#20158, #19995 ruling C) + * + * A predicate can pass every pass above and still be a filter the ENGINE + * refuses to run: a text operator aimed at a number field, a date field + * compared against a value its storage cannot read, a filter on a virtual + * (formula) field, a `{placeholder}` string. The lowering accepts all of them, + * because they are about what the object's fields ARE, which the CEL compiler + * does not know. ADR-0058 D2 makes each an authoring-time error. + * + * The verdict is the engine's, never a model of it: the caller hands this rule + * the engine's judge-only method (`IObjectQLEngine.judgeFilter`) as an INPUT, + * and the rule calls it on the lowered read scope. The runtime publish gate + * hands in the live engine's method; the CLI commands hand in the method of an + * engine built from the stack's own objects with no driver (the admission + * reads the `where`, the registry's field map and the context, nothing else). + * One rule, two doors, one function answering. Without the input the pass is + * skipped, and the rule answers exactly what it answered before the input + * existed. + * + * - **What is judged:** the `using` clause of a policy whose operation is part + * of the object's READ SCOPE — `select` or `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, so + * judging it here would model a consumer it does not have. + * - **With what values:** the reference pass's probe, except that a + * `current_user.*` key the kernel does NOT resolve (an app-staged §7.3.1 + * membership set) is bound to `[]` for this pass only. The probe's string + * stands for a kernel value faithfully (an id is never a date), but an + * app-staged set holds app data this rule cannot know: measured, + * `close_date in current_user.holidays` lowered with the probe string is + * refused by the temporal door, while with `[]` it is admitted. + * - **With what context:** none. A `{placeholder}` string in a predicate + * therefore answers `FILTER_TOKEN_UNRESOLVED` (known) or + * `FILTER_TOKEN_UNKNOWN` (unknown) — the engine's own semantics for a filter + * judged with no caller. An RLS predicate names the caller as `current_user.*`. + * - **When:** only when every pass above left this clause clean, so one + * defect earns one finding. + * + * The finding keeps the id {@link RLS_PREDICATE_UNENFORCEABLE} and quotes the + * engine's message verbatim, with its code and status: at authoring time the + * text is the author's own, so nothing is withheld. */ +import type { EngineFilterJudgement, IObjectQLEngine } from '@objectstack/spec/contracts'; import { compileCelToFilter, isPushdownableCel, @@ -976,6 +1019,108 @@ function referenceConsequence(clause: 'using' | 'check', kind: 'field' | 'variab 'rather than relying on either behaviour.'; } +/** + * The engine's judge-only filter admission, as the caller hands it in + * (`IObjectQLEngine.judgeFilter`, bound to its engine). + */ +type FilterJudge = NonNullable; + +/** + * The policy operations whose `using` is part of an object's READ SCOPE: the + * two `RLSCompiler.getApplicablePolicies` matches for a read (`find` / `count` + * / `aggregate` map to `select`, and `all` applies to every operation). + */ +const READ_SCOPE_OPERATIONS: ReadonlySet = new Set(['select', 'all']); + +/** + * The probe the engine judges with: every kernel-resolved key keeps its + * type-faithful probe, and every other key (an app-staged §7.3.1 membership + * set, the only other thing `current_user.*` can hold) is bound to `[]`. + * + * `[]` because the set's MEMBERS are app data: the probe string is a faithful + * stand-in for a kernel value (a user id or an email is never a date), but not + * for a value an app stages, and the engine's temporal door judges members. + * Measured: `close_date in current_user.holidays` lowered with the probe + * string is refused (`INVALID_FILTER`), with `[]` it is admitted. A kernel key + * is left alone, so `close_date in current_user.org_user_ids` still reaches the + * door with a value of the type every request holds. + */ +function engineJudgeProbe(probe: UserProbe): { probe: UserProbe; rebound: boolean } { + const bound: UserProbe = {}; + let rebound = false; + for (const [key, value] of Object.entries(probe)) { + if (PRERESOLVED_USER_KEYS.has(key)) { + bound[key] = value; + } else { + bound[key] = []; + rebound = true; + } + } + return { probe: bound, rebound }; +} + +/** + * The engine's verdict on this predicate's lowered read scope, or `null` when + * it admits it or the predicate has no lowering to judge. + * + * Asked of the ENGINE, through the method the caller handed in — the same + * function the engine runs before any driver is resolved (`judgeFilter`). No + * context is passed: at authoring time there is no caller. + */ +function engineRefusal( + judge: FilterJudge, + object: string, + bridged: string, + filter: Record, + probe: UserProbe, +): Extract | null { + const { probe: judged, rebound } = engineJudgeProbe(probe); + // A kernel key the reference pass had to re-bind as a nested record (a + // `current_user.id.x` path) holds a value no request holds, so a verdict + // on it would be about the probe. Same guard as {@link sharedFaceRefusal}. + if (!Object.values(judged).every((v) => typeof v === 'string' || Array.isArray(v))) return null; + let where: Record = filter; + if (rebound) { + const res = compileWithProbe(bridged, judged); + if (!res.ok) return null; + where = res.filter as Record; + } + const verdict = judge(object, where, { operation: 'find' }); + return verdict.ok ? null : verdict; +} + +/** + * What an engine-refused read scope does at request time. The analytics face + * composes the read scope into the `where` it hands the engine, so the same + * admission that produced this verdict refuses the query there. + */ +const ENGINE_REFUSED_CONSEQUENCE = + '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 prescription for an engine refusal, keyed by the engine's `code` (never by its prose). */ +function engineRefusalHint(code: string): string { + if (code === 'FILTER_TOKEN_UNKNOWN' || code === 'FILTER_TOKEN_UNRESOLVED') { + return ( + '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 (${listNames(PRERESOLVED_USER_KEYS)}). ` + + 'Write `owner == current_user.id`, not `owner == \'{current_user_id}\'`.' + ); + } + if (code === 'INVALID_FIELD') { + return ( + '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.' + ); + } + return ( + '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. ' + PUSHDOWN_SUBSET + ); +} + /** * The reference pass: every finding a SHAPE-VALID predicate earns. * @@ -990,6 +1135,12 @@ function referenceFindings( where: string, path: string, object: string, + /** + * [#20158] The engine's judge, when this clause is a read scope the caller + * can have judged — `undefined` otherwise (no judge handed in, a `check`, a + * policy outside the read scope, or no object named). + */ + judge?: FilterJudge, ): RlsPredicateFinding[] { const findings: RlsPredicateFinding[] = []; const bridged = sqlPredicateToCel(source); @@ -1085,6 +1236,26 @@ function referenceFindings( }); } + // [#20158] The engine's own admission of the lowered read scope — run only + // on a clause every pass above left clean, so one defect earns one finding. + // The verdict and its sentence are the engine's; this rule adds the clause, + // the object and what the refusal costs. + const refused = judge && filter && findings.length === 0 + ? engineRefusal(judge, object, bridged, filter, probe) + : null; + if (refused) { + findings.push({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + where, + path, + message: + `RLS ${clause} \`${quote(source)}\` lowers, but the engine refuses to run the lowered filter on ` + + `"${object}" (${refused.code} / ${refused.status}): ${refused.message} ${ENGINE_REFUSED_CONSEQUENCE}`, + hint: engineRefusalHint(refused.code), + }); + } + return findings; } @@ -1092,10 +1263,18 @@ function referenceFindings( * Gate every stack-declared RLS predicate on the ONE thing the runtime does * with it: lower it to a FilterCondition (ADR-0056 D4). * - * Pure `(stack) => Finding[]`; tolerates the normalized and the parsed tier - * (`using` / `check` are plain `z.string()`, identical in both). + * Pure `(stack, options) => Finding[]`; tolerates the normalized and the parsed + * tier (`using` / `check` are plain `z.string()`, identical in both). + * + * @param options.judgeFilter [#20158] The engine's judge-only filter admission + * (`IObjectQLEngine.judgeFilter`, bound to its engine). Present, every + * read-scope `using` that the passes above leave clean is judged by it — see + * this file's header. Absent, that pass is skipped and nothing else changes. */ -export function validateRlsPredicateEnforceability(stack: unknown): RlsPredicateFinding[] { +export function validateRlsPredicateEnforceability( + stack: unknown, + options: { judgeFilter?: IObjectQLEngine['judgeFilter'] } = {}, +): RlsPredicateFinding[] { const findings: RlsPredicateFinding[] = []; const cfg = (stack ?? {}) as AnyRec; @@ -1133,8 +1312,14 @@ export function validateRlsPredicateEnforceability(stack: unknown): RlsPredicate // [#16119] The shape is fine, so the REFERENCE pass owns this predicate. // Disjoint from everything below by construction: the three shape ids // only ever run on predicates this branch has already returned from. + // [#20158] The engine judges the READ SCOPE only: a `using` on a + // `select` / `all` policy that names its object. + const judge = + clause === 'using' && objectEarly && READ_SCOPE_OPERATIONS.has(str(policy.operation)) + ? options.judgeFilter + : undefined; findings.push( - ...referenceFindings(graph, source, clause, whereEarly, pathEarly, objectEarly), + ...referenceFindings(graph, source, clause, whereEarly, pathEarly, objectEarly, judge), ); continue; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a9a77c8f6dc..af4266a1a98 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -105,7 +105,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // [#13331] The cluster fan-out transport type only — the protocol never // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. -import type { IPubSub } from '@objectstack/spec/contracts'; +import type { IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel } from '@objectstack/spec/ui'; // [#11350] Emitted-specifier pin. This module's inferred public declarations @@ -5115,6 +5115,24 @@ export class ObjectStackProtocolImplementation implements // an argument, the same shape `orgWallEnforced` uses below. const packageScope = this.resolveWritePackageScope(evt.packageId); + // [#20158] The live engine's judge-only filter admission — the third + // host fact of the #6285 kind, probed here and passed in so the gate + // stays pure. OPTIONAL on `IObjectQLEngine` by ruling (#19995 C): a host + // whose engine lacks it (a metadata-only store, a test double) passes + // nothing and the rules skip the engine's judgement. Bound, because + // `judgeFilter` reads the engine's own registry through `this`. + // + // ⚠️ It reads the LIVE registry, not the snapshot the rules resolve + // names against. An object that exists only in this write's pending + // batch or in an organization overlay row is one the engine does not + // know, and the engine's answer for that is its own: the field-map + // doors answer nothing and the schema-free doors still judge. The + // runtime read-scope withhold (#5367) stays the backstop there. + const engineJudge: IObjectQLEngine['judgeFilter'] = + typeof this.engine.judgeFilter === 'function' + ? (objectName, where, options) => this.engine.judgeFilter(objectName, where, options) + : undefined; + const verdict = evaluateRuntimeAuthoringGate({ type: singular, name: evt.name, @@ -5130,6 +5148,7 @@ export class ObjectStackProtocolImplementation implements ...(packageScope !== undefined ? { packageScope } : {}), ...(evt.organizationId !== undefined ? { organizationId: evt.organizationId } : {}), orgWallEnforced: this.orgWallEnforced(), + ...(engineJudge !== undefined ? { judgeFilter: engineJudge } : {}), }); if (verdict.error) throw verdict.error; return verdict.advisories; diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index c3698c31340..d36b7adbe6d 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -63,6 +63,7 @@ import { // wiring guard's third invariant), and `walkFlowNodes` is not on it. import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; /** * The structured issue shape a 422 carries — D3's "reuse the Zod envelope". @@ -659,6 +660,19 @@ export function evaluateRuntimeAuthoringGate(args: { * every call site that can know the answer states it. */ orgWallEnforced?: boolean; + /** + * [#20158] The host engine's judge-only filter admission + * (`IObjectQLEngine.judgeFilter`, #19995 ruling C), BOUND to that engine. + * + * The third input of the #6285 kind: a fact only the host holds (its live + * engine), gathered by the impure caller — `assertRuntimeAuthoringRules`, + * which probes `typeof engine.judgeFilter === 'function'` — and passed in + * so this function stays pure. Handed to the shared rules unchanged, where + * `validateRlsPredicateEnforceability` judges each read-scope RLS `using` + * with it (ADR-0058 D2). Absent (a host without the member, a test + * double), that judgement is skipped and every rule answers as before. + */ + judgeFilter?: IObjectQLEngine['judgeFilter']; }): RuntimeAuthoringVerdict { // D1 — drafts are never gated. Publishing one runs this same function. // No rules ran, so there is nothing to report on either half. @@ -681,6 +695,7 @@ export function evaluateRuntimeAuthoringGate(args: { datasets: mergePendingDeclarations(args.datasets ?? [], args.pending?.datasets), }, ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), + ...(args.judgeFilter !== undefined ? { judgeFilter: args.judgeFilter } : {}), }); // [#6285] The gate-local refusal, folded into the SAME verdict set as the From ba79c253e83ba51fead04b8f096bbcb6dbdeab6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:48:32 +0000 Subject: [PATCH 2/5] test(lint,metadata-protocol,cli): pin the engine judge at both doors; changeset Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../20158-rls-policy-authoring-admission.md | 25 ++ .../rls-policy-authoring-admission.test.ts | 223 ++++++++++++++++++ ...dicate-enforceability.engine-judge.test.ts | 214 +++++++++++++++++ .../protocol.runtime-authoring-gate.test.ts | 121 ++++++++++ 4 files changed, 583 insertions(+) create mode 100644 .changeset/20158-rls-policy-authoring-admission.md create mode 100644 packages/cli/test/rls-policy-authoring-admission.test.ts create mode 100644 packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts diff --git a/.changeset/20158-rls-policy-authoring-admission.md b/.changeset/20158-rls-policy-authoring-admission.md new file mode 100644 index 00000000000..2c89d6434b1 --- /dev/null +++ b/.changeset/20158-rls-policy-authoring-admission.md @@ -0,0 +1,25 @@ +--- +'@objectstack/lint': minor +'@objectstack/metadata-protocol': minor +'@objectstack/cli': minor +--- + +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` + +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. + +- **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. +- **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. +- **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. +- **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. + +**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. + +Two edges are not closed here, both deliberately: + +- 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. +- 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. + +Clause-②: yes (narrowing) + + diff --git a/packages/cli/test/rls-policy-authoring-admission.test.ts b/packages/cli/test/rls-policy-authoring-admission.test.ts new file mode 100644 index 00000000000..8ab1f147dcd --- /dev/null +++ b/packages/cli/test/rls-policy-authoring-admission.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20158] RLS read scopes are admitted when they are AUTHORED, at both doors, + * by the engine's own judge — one table over the fifteen read-scope classes of + * #19995, one verdict and one sentence per class. + * + * ## The two doors, both real + * + * - **CLI** — `os validate`'s own composition, in process: `normalizeStackInput` + * → `lowerCallables` → `ObjectStackDefinitionSchema` → the union fold → + * `runAuthoringRules('validate', …)`, handed the judge of a driverless engine + * built from the stack's objects (`stackFilterJudge`, the helper every CLI + * call site uses). + * - **Runtime** — `saveMetaItem` on a `permission` write, over a REAL `ObjectQL` + * host (in-memory driver, the real `sys_metadata` objects) holding the same + * objects: the protocol probes that engine's `judgeFilter` and hands it to the + * publish gate, which runs the same rule table. + * + * Neither door holds a model of the engine's walks; both ask `judgeFilter`. + * + * ## The fifteen classes + * + * The eleven PR #20017 / #20046 / #20072 withhold at the analytics merge + * boundary (the comparand-SHAPE face, the comparand-TYPE face, the placeholder + * resolver) and the four #19995 ruling C adds (the engine's schema-reading + * doors). Each row is written the way an RLS author can write it — CEL. Two + * classes have no CEL spelling, and their rows carry the nearest one, saying + * so: a range has no `$between` in CEL (its nearest, a range with a `null` + * bound), and CEL cannot hold `undefined` (its nearest, a `current_user` key + * nothing resolves, which the compiler refuses rather than lowering). + */ + +import { describe, expect, it } from 'vitest'; +import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; +import { runAuthoringRules, type AuthoringFinding } from '@objectstack/lint'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; +import { ObjectQL } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { createLogger } from '@objectstack/core'; + +import { stackFilterJudge } from '../src/utils/authoring-filter-judge.js'; +import { lowerCallables } from '../src/utils/lower-callables.js'; +import { authoringRuleUnionStack } from '../src/utils/stack-collections.js'; + +const deal = { + name: 'deal', + label: 'Deal', + fields: { + name: { type: 'text', label: 'Name' }, + owner: { type: 'text', label: 'Owner' }, + region: { type: 'text', label: 'Region' }, + amount: { type: 'number', label: 'Amount' }, + close_date: { type: 'date', label: 'Close date' }, + is_open: { + type: 'formula', + label: 'Is open', + expression: { dialect: 'cel', source: "record.region != 'closed'" }, + returnType: 'boolean', + }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + }, +}; +const account = { name: 'account', label: 'Account', fields: { region: { type: 'text', label: 'Region' } } }; + +const permissionSet = (using: string) => ({ + name: 'sales', + label: 'Sales', + objects: { deal: { allowRead: true } }, + rowLevelSecurity: [{ name: 'p', label: 'P', object: 'deal', operation: 'select' as const, using }], +}); + +const RLS = (f: { rule: string }) => f.rule.startsWith('rls-predicate-'); + +/** `os validate` step 3, in process — see the file header. */ +function cliDoor(using: string): AuthoringFinding[] { + const config = { + manifest: { id: 'com.example.rls', namespace: 'rls', version: '1.0.0', name: 'RLS', type: 'app' }, + objects: [deal, account], + permissions: [permissionSet(using)], + }; + const normalized = normalizeStackInput(config as Record); + const lowering = lowerCallables(normalized as Record); + const result = ObjectStackDefinitionSchema.safeParse(lowering.lowered); + if (!result.success) throw new Error(`fixture does not parse: ${result.error.message}`); + const parsedUnion = authoringRuleUnionStack(result.data as Record); + return runAuthoringRules('validate', { + normalized: authoringRuleUnionStack(normalized as Record), + parsed: parsedUnion, + loweredHookRefs: lowering.loweredHookRefs, + judgeFilter: stackFilterJudge(parsedUnion), + }).filter(RLS); +} + +/** A live engine holding the same objects, as the protocol's host. */ +async function runtimeHost() { + const engine = new ObjectQL({ logger: createLogger({ level: 'silent' }) }); + engine.registry.logLevel = 'silent'; + engine.registerDriver(new InMemoryDriver(), true); + await engine.init(); + for (const obj of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, deal, account]) { + engine.registry.registerObject(structuredClone(obj) as never); + } + return { engine, protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') }; +} + +interface SaveOutcome { + accepted: boolean; + code?: string; + status?: number; + issues: Array<{ rule: string; path: string; message: string }>; +} + +async function runtimeDoor(using: string): Promise { + const { protocol } = await runtimeHost(); + try { + await protocol.saveMetaItem({ type: 'permission', name: 'sales', item: permissionSet(using) }); + return { accepted: true, issues: [] }; + } catch (err) { + const e = err as { code?: string; status?: number; issues?: SaveOutcome['issues'] }; + return { accepted: false, code: e.code, status: e.status, issues: (e.issues ?? []).filter(RLS) }; + } +} + +interface ClassRow { + /** The #19995 class, in the words of the PR that withheld it. */ + klass: string; + family: 'comparand shape' | 'comparand type' | 'placeholder' | 'schema door'; + using: string; + /** The rule id both doors report. */ + rule: string; + /** The engine's own code, quoted in the sentence, where the engine's judge is the one refusing. */ + engineCode?: string; +} + +const UNENFORCEABLE = 'rls-predicate-unenforceable'; + +const CLASSES: readonly ClassRow[] = [ + { klass: 'list in the implicit equality slot', family: 'comparand shape', using: "region == ['emea', 'apac']", rule: UNENFORCEABLE }, + { klass: 'list under $eq', family: 'comparand shape', using: 'region == current_user.org_user_ids', rule: UNENFORCEABLE }, + { klass: 'scalar under $in', family: 'comparand shape', using: "region in 'emea'", rule: UNENFORCEABLE }, + { klass: 'scalar under $nin', family: 'comparand shape', using: "!(region in 'emea')", rule: UNENFORCEABLE }, + { klass: 'one-bound $between (no CEL spelling; nearest: a range with a null bound)', family: 'comparand shape', using: 'amount >= 1 && amount <= null', rule: UNENFORCEABLE }, + { klass: 'null member in $in', family: 'comparand shape', using: "region in ['emea', null]", rule: UNENFORCEABLE }, + { klass: 'plain-object member in $in', family: 'comparand type', using: "region in [{'a': 1}]", rule: UNENFORCEABLE }, + { klass: 'plain-object comparand under $eq', family: 'comparand type', using: "region == {'a': 1}", rule: UNENFORCEABLE }, + { klass: 'undefined comparand (no CEL spelling; nearest: a current_user key nothing resolves)', family: 'comparand type', using: 'region == current_user.nope', rule: 'rls-predicate-unknown-user-variable' }, + { klass: 'unknown filter placeholder', family: 'placeholder', using: "owner == '{current_usr_id}'", rule: UNENFORCEABLE, engineCode: 'FILTER_TOKEN_UNKNOWN' }, + { klass: 'known placeholder, no context to resolve it', family: 'placeholder', using: "owner == '{current_user_id}'", rule: UNENFORCEABLE, engineCode: 'FILTER_TOKEN_UNRESOLVED' }, + { klass: 'text operator on a non-text field', family: 'schema door', using: "amount.startsWith('5')", rule: UNENFORCEABLE, engineCode: 'INVALID_FILTER' }, + { klass: 'temporal comparand the platform cannot read', family: 'schema door', using: "close_date > 'soon'", rule: UNENFORCEABLE, engineCode: 'INVALID_FILTER' }, + { klass: 'filter on a virtual (formula) field', family: 'schema door', using: 'is_open == true', rule: UNENFORCEABLE, engineCode: 'INVALID_FIELD' }, + { klass: 'dotted path through a lookup', family: 'schema door', using: "account.region == 'emea'", rule: UNENFORCEABLE }, +]; + +const CONTROLS: ReadonlyArray<{ control: string; using: string }> = [ + { control: 'a clean owner policy', using: 'owner == current_user.id' }, + { control: 'the pre-resolved current_user IN form', using: 'owner in current_user.org_user_ids' }, + { control: 'a text operator on a real text field', using: "name.startsWith('A')" }, +]; + +describe('RLS read-scope admission at authoring time — fifteen classes, two doors, one answer (#20158)', () => { + it('the table covers the fifteen classes, in the four families', () => { + expect(CLASSES).toHaveLength(15); + expect(CLASSES.filter((c) => c.family === 'schema door')).toHaveLength(4); + expect(CLASSES.filter((c) => c.family === 'placeholder')).toHaveLength(2); + }); + + for (const row of CLASSES) { + it(`REFUSED at both doors with one sentence — ${row.klass}: \`${row.using}\``, async () => { + const cli = cliDoor(row.using); + const saved = await runtimeDoor(row.using); + + // CLI door: exactly one finding, the row's id, at the clause's path. + expect(cli.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path }))).toEqual([ + { severity: 'error', rule: row.rule, path: 'permissions[0].rowLevelSecurity[0].using' }, + ]); + + // Runtime door: the gate's own envelope for an authoring-rule refusal, + // carrying the same finding (the path keyed by the set's NAME on the wire). + expect(saved.accepted).toBe(false); + expect({ code: saved.code, status: saved.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(saved.issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([ + { rule: row.rule, path: 'permissions.sales.rowLevelSecurity[0].using' }, + ]); + + // One sentence: the author reads the same words at either door. + expect(saved.issues[0].message).toBe(cli[0].message); + + if (row.engineCode) { + // The engine's own verdict, quoted with its code — never withheld at + // authoring time, where the text is the author's own. + expect(cli[0].message).toContain(`(${row.engineCode} / 400): `); + expect(cli[0].message).toContain('lowers, but the engine refuses to run the lowered filter on "deal"'); + } + }); + } + + for (const { control, using } of CONTROLS) { + it(`ACCEPTED at both doors — ${control}: \`${using}\``, async () => { + expect(cliDoor(using)).toEqual([]); + expect(await runtimeDoor(using)).toEqual({ accepted: true, issues: [] }); + }); + } +}); + +describe('the judge pass binds an app-staged membership key to [] (#20158)', () => { + it('`close_date in current_user.holidays` is ACCEPTED at both doors — the probe string is not a date, the app data may be', async () => { + const using = 'close_date in current_user.holidays'; + expect(cliDoor(using)).toEqual([]); + expect(await runtimeDoor(using)).toEqual({ accepted: true, issues: [] }); + }); + + it('the binding does not switch the judge off: the same clause beside an engine-refused arm is REFUSED at both', async () => { + const using = "close_date in current_user.holidays && amount.startsWith('5')"; + const cli = cliDoor(using); + const saved = await runtimeDoor(using); + expect(cli.map((f) => f.rule)).toEqual([UNENFORCEABLE]); + expect(cli[0].message).toContain('(INVALID_FILTER / 400): '); + expect(saved.issues.map((i) => i.message)).toEqual([cli[0].message]); + }); +}); diff --git a/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts new file mode 100644 index 00000000000..6e728dc1c5d --- /dev/null +++ b/packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20158] The rule's contract with the ENGINE JUDGE it is handed — what it + * asks, when it asks, and how it reports the answer. + * + * The judge here is a recording double, on purpose: this package may not load + * the engine (it is spec-only by contract), and what these cases pin is the + * rule's side of the seam — the object, the lowered filter, the verb and the + * context it hands over, and the finding it builds from a refusal. The + * engine's own verdicts over the fifteen read-scope classes, through BOTH + * doors, are pinned in `packages/cli/test/rls-policy-authoring-admission.test.ts`, + * which holds a real engine. + */ + +import { describe, expect, it } from 'vitest'; +import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts'; + +import { validateRlsPredicateEnforceability, RLS_PREDICATE_UNENFORCEABLE } from './validate-rls-predicate-enforceability.js'; +import { AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js'; + +interface JudgeCall { + object: string; + where: unknown; + options: EngineFilterJudgementOptions | undefined; +} + +/** A judge that records every question and answers with `verdict`. */ +function recordingJudge(verdict: EngineFilterJudgement = { ok: true }) { + const calls: JudgeCall[] = []; + const judgeFilter = (object: string, where: unknown, options?: EngineFilterJudgementOptions) => { + calls.push({ object, where, options }); + return verdict; + }; + return { calls, judgeFilter }; +} + +const REFUSAL: EngineFilterJudgement = { + ok: false, + code: 'INVALID_FILTER', + status: 400, + message: "find('deal'): filter on 'amount' aims the text operator $startsWith at a declared number field.", +}; + +const deal = { + name: 'deal', + label: 'Deal', + fields: { + owner: { type: 'text', label: 'Owner' }, + amount: { type: 'number', label: 'Amount' }, + close_date: { type: 'date', label: 'Close date' }, + }, +}; + +/** One permission set, one policy on `deal`, with the clause and operation swapped in. */ +const stackWith = (policy: Record) => ({ + objects: [deal], + permissions: [ + { + name: 'sales', + label: 'Sales', + rowLevelSecurity: [{ name: 'p', object: 'deal', operation: 'select', ...policy }], + }, + ], +}); + +describe('validateRlsPredicateEnforceability — the engine judge is an INPUT (#20158)', () => { + it('without a judge, a clause only the engine refuses stays clean — the rule answers as it did before', () => { + expect(validateRlsPredicateEnforceability(stackWith({ using: "amount.startsWith('5')" }))).toEqual([]); + }); + + it('asks the judge about the lowered read scope: the policy object, the lowered filter, `find`, no context', () => { + const { calls, judgeFilter } = recordingJudge(); + validateRlsPredicateEnforceability(stackWith({ using: "amount.startsWith('5')" }), { judgeFilter }); + expect(calls).toEqual([ + { object: 'deal', where: { amount: { $startsWith: '5' } }, options: { operation: 'find' } }, + ]); + }); + + it('turns a refusal into ONE rls-predicate-unenforceable finding that quotes the engine verbatim', () => { + const { judgeFilter } = recordingJudge(REFUSAL); + const findings = validateRlsPredicateEnforceability(stackWith({ using: "amount.startsWith('5')" }), { + judgeFilter, + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: RLS_PREDICATE_UNENFORCEABLE, + path: 'permissions[0].rowLevelSecurity[0].using', + where: 'permission set "sales" policy "p" on object "deal"', + }); + const refusal = REFUSAL as Extract; + expect(findings[0].message).toContain(`(${refusal.code} / ${refusal.status}): ${refusal.message}`); + expect(findings[0].message).toMatch(/^RLS using `amount\.startsWith\('5'\)` lowers, but the engine refuses/); + }); + + it('prescribes by the engine CODE: a placeholder is sent to `current_user`, a field to denormalisation', () => { + const hintFor = (code: string) => { + const { judgeFilter } = recordingJudge({ ok: false, code, status: 400, message: 'refused.' }); + return validateRlsPredicateEnforceability(stackWith({ using: 'owner == current_user.id' }), { judgeFilter })[0] + .hint; + }; + expect(hintFor('FILTER_TOKEN_UNKNOWN')).toMatch(/Write `owner == current_user\.id`/); + expect(hintFor('FILTER_TOKEN_UNRESOLVED')).toMatch(/Write `owner == current_user\.id`/); + expect(hintFor('INVALID_FIELD')).toMatch(/Denormalise the value/); + expect(hintFor('INVALID_FILTER')).toMatch(/a text operator .* only on a field that holds a string/); + }); + + it('judges ONLY the read scope: `using` on a `select` / `all` policy — never a `check`, never another operation', () => { + const judged = (policy: Record) => { + const { calls, judgeFilter } = recordingJudge(); + validateRlsPredicateEnforceability(stackWith(policy), { judgeFilter }); + return calls.length; + }; + expect(judged({ operation: 'select', using: 'owner == current_user.id' })).toBe(1); + expect(judged({ operation: 'all', using: 'owner == current_user.id' })).toBe(1); + expect(judged({ operation: 'insert', using: 'owner == current_user.id' })).toBe(0); + expect(judged({ operation: 'update', using: 'owner == current_user.id' })).toBe(0); + expect(judged({ operation: 'delete', using: 'owner == current_user.id' })).toBe(0); + // The `using` beside a `check` is judged; the `check` never is. + expect(judged({ operation: 'all', using: 'owner == current_user.id', check: 'owner == current_user.id' })).toBe(1); + }); + + it('one defect, one finding: a clause another pass already refused is not handed to the judge', () => { + const { calls, judgeFilter } = recordingJudge(REFUSAL); + const findings = validateRlsPredicateEnforceability(stackWith({ using: 'no_such_field == current_user.id' }), { + judgeFilter, + }); + expect(calls).toEqual([]); + expect(findings.map((f) => f.rule)).toEqual(['rls-predicate-unknown-field']); + }); + + it('binds an app-staged membership key to [] for the judge, and keeps a kernel key\'s runtime-typed probe', () => { + // The measured artefact: with the probe STRING standing in for an app's + // set, `close_date in current_user.holidays` reaches the temporal door as + // `$in: ['__objectstack_lint_probe__']` and is refused — about the probe, + // not about the policy. The set's members are app data, so the judge sees + // an empty set instead. + const app = recordingJudge(); + validateRlsPredicateEnforceability(stackWith({ using: 'close_date in current_user.holidays' }), { + judgeFilter: app.judgeFilter, + }); + expect(app.calls.map((c) => c.where)).toEqual([{ close_date: { $in: [] } }]); + + // A kernel key holds what `ExecutionContext` declares on every request, so + // its probe is a faithful stand-in and is left alone. + const kernel = recordingJudge(); + validateRlsPredicateEnforceability(stackWith({ using: 'owner in current_user.org_user_ids' }), { + judgeFilter: kernel.judgeFilter, + }); + expect(kernel.calls.map((c) => c.where)).toEqual([{ owner: { $in: ['__objectstack_lint_probe__'] } }]); + }); +}); + +describe('the judge reaches the rule through BOTH run signatures (#20158)', () => { + it('`runAuthoringRules` hands `judgeFilter` to the rule on every CLI command', () => { + for (const command of ['validate', 'build', 'lint'] as const) { + const { judgeFilter } = recordingJudge(REFUSAL); + const stack = stackWith({ using: "amount.startsWith('5')" }); + const findings = runAuthoringRules(command, { normalized: stack, parsed: stack, judgeFilter }); + expect( + findings.filter((f) => f.rule === RLS_PREDICATE_UNENFORCEABLE).map((f) => f.path), + `os ${command}`, + ).toEqual(['permissions[0].rowLevelSecurity[0].using']); + } + }); + + it('the rule now runs at the runtime publish gate for `permission` writes', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateRlsPredicateEnforceability'); + expect(entry?.surfaces).toEqual(['cli', 'runtime-publish']); + expect(entry?.runtimeTypes).toEqual(['permission']); + expect(runtimeAuthoringRulesFor('permission').map((r) => r.name)).toContain('validateRlsPredicateEnforceability'); + }); + + it('`runRuntimeAuthoringRules` hands `judgeFilter` to the rule, and attributes only what the WRITE adds', () => { + const { calls, judgeFilter } = recordingJudge(REFUSAL); + const written = stackWith({ using: "amount.startsWith('5')" }).permissions[0]; + const result = runRuntimeAuthoringRules({ + type: 'permission', + item: written, + context: { objects: [deal], permissions: [] }, + judgeFilter, + }); + expect(result.rulesRun).toContain('validateRlsPredicateEnforceability'); + expect(result.errors.filter((f) => f.rule === RLS_PREDICATE_UNENFORCEABLE).map((f) => f.path)).toEqual([ + 'permissions.sales.rowLevelSecurity[0].using', + ]); + // Asked once for the candidate pass; the baseline carries no policy. + expect(calls).toHaveLength(1); + + // A SIBLING set's refused policy is somebody else's pre-existing condition + // (#4463 D4): it is judged in both passes and cancels in the differential. + const sibling = { ...stackWith({ using: "amount.startsWith('6')" }).permissions[0], name: 'other' }; + const clean = runRuntimeAuthoringRules({ + type: 'permission', + item: stackWith({ using: 'owner == current_user.id' }).permissions[0], + context: { objects: [deal], permissions: [sibling] }, + judgeFilter: (object, where, options) => + JSON.stringify(where).includes('$startsWith') ? REFUSAL : recordingJudge().judgeFilter(object, where, options), + }); + expect(clean.errors.filter((f) => f.rule === RLS_PREDICATE_UNENFORCEABLE)).toEqual([]); + }); + + it('without `judgeFilter` the runtime gate still runs the rule, and a judge-only class passes it', () => { + const result = runRuntimeAuthoringRules({ + type: 'permission', + item: stackWith({ using: "amount.startsWith('5')" }).permissions[0], + context: { objects: [deal], permissions: [] }, + }); + expect(result.rulesRun).toContain('validateRlsPredicateEnforceability'); + expect(result.errors.filter((f) => f.rule.startsWith('rls-predicate-'))).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 1b8435c16d2..a13868f96ad 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -949,3 +949,124 @@ describe('runtime authoring gate on PERMISSION writes — retired lifecycle resi expect(lines[0]).toContain('allowRestore'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #20158 — the RLS read-scope judge at the PERMISSION write door. +// +// `validateRlsPredicateEnforceability` crossed to this door for `permission` +// writes, and it judges every read-scope `using` with the engine's judge-only +// admission (`IObjectQLEngine.judgeFilter`), which this door takes from its +// HOST: `assertRuntimeAuthoringRules` probes `typeof engine.judgeFilter` and +// hands the bound method through the pure gate. The stub host below stands in +// for the engine's STORAGE only; the judge it carries records what it is asked. +// The engine's real verdicts over the fifteen classes, at both doors, are +// pinned in `packages/cli/test/rls-policy-authoring-admission.test.ts`, which +// holds a real engine (this package may not load one: the engine depends on it). +// ───────────────────────────────────────────────────────────────────────────── + +describe('runtime authoring gate on PERMISSION writes — the engine judge (#20158)', () => { + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + const permissionRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'permission'); + + /** A permission set with one read-scope policy on the stub's `leave_request`. */ + const policySet = (using: string) => ({ + name: 'sales_team', + label: 'Sales Team', + objects: { leave_request: { allowRead: true, readScope: 'own' } }, + rowLevelSecurity: [{ name: 'own_rows', object: 'leave_request', operation: 'select', using }], + }); + + const REFUSAL = { + ok: false as const, + code: 'FILTER_TOKEN_UNRESOLVED', + status: 400, + message: 'Filter placeholder "{current_user_id}" cannot be resolved: the request has no authenticated user.', + }; + + function hostWithJudge(verdict: { ok: true } | typeof REFUSAL) { + const { engine, rows } = makeStubEngine(); + const calls: Array<{ self: unknown; object: string; where: unknown; options: unknown }> = []; + engine.judgeFilter = function judgeFilter(this: unknown, object: string, where: unknown, options: unknown) { + calls.push({ self: this, object, where, options }); + return verdict; + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + return { engine, rows, calls, protocol }; + } + + it('probes the host engine for `judgeFilter` and hands it through, BOUND to that engine', async () => { + const { engine, rows, calls, protocol } = hostWithJudge({ ok: true }); + + const result = await protocol.saveMetaItem({ + type: 'permission', name: 'sales_team', item: policySet('owner == current_user.id'), + }); + + expect(result.success).toBe(true); + expect(permissionRows(rows)).toHaveLength(1); + // Asked about the lowered read scope, on the policy's object, as a read. + expect(calls.map(({ object, where, options }) => ({ object, where, options }))).toEqual([ + { object: 'leave_request', where: { owner: '__objectstack_lint_probe__' }, options: { operation: 'find' } }, + ]); + // `judgeFilter` reads its engine's registry through `this`. + expect(calls[0]!.self).toBe(engine); + }); + + it('a judge refusal refuses the publish in the gate\'s own 422 envelope, the engine\'s sentence verbatim', async () => { + const { rows, protocol } = hostWithJudge(REFUSAL); + + const err = await protocol.saveMetaItem({ + type: 'permission', name: 'sales_team', item: policySet("owner == '{current_user_id}'"), + }).then(() => null, (e: unknown) => e); + + expect({ code: (err as any)?.code, status: (err as any)?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = (err as { issues: Array<{ rule: string; path: string; message: string }> }).issues; + expect(issues.map((i) => ({ rule: i.rule, path: i.path }))).toEqual([ + { rule: 'rls-predicate-unenforceable', path: 'permissions.sales_team.rowLevelSecurity[0].using' }, + ]); + expect(issues[0]!.message).toContain(`(${REFUSAL.code} / ${REFUSAL.status}): ${REFUSAL.message}`); + expect(permissionRows(rows), 'a refused publish writes nothing').toHaveLength(0); + }); + + it('a host engine WITHOUT the member keeps its answer on a judge-only class, and the rule still runs here', async () => { + // The optional member's ruling (#19995 C): a host without it keeps its + // existing behaviour for what only the engine can judge… + const { engine, rows } = makeStubEngine(); + expect(typeof engine.judgeFilter).toBe('undefined'); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + const kept = await protocol.saveMetaItem({ + type: 'permission', name: 'sales_team', item: policySet("owner == '{current_user_id}'"), + }); + expect(kept.success).toBe(true); + expect(permissionRows(rows)).toHaveLength(1); + + // …while the rule itself now runs at this door: a predicate that does + // not parse as CEL was ACCEPTED here before this crossing. + const err = await protocol.saveMetaItem({ + type: 'permission', name: 'audit_team', + item: { ...policySet("owner = 'x' AND owner = 'y'"), name: 'audit_team' }, + }).then(() => null, (e: unknown) => e); + expect({ code: (err as any)?.code, status: (err as any)?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect((err as { issues: Array<{ rule: string }> }).issues.map((i) => i.rule)).toEqual([ + 'rls-predicate-unparseable', + ]); + }); + + it('a DRAFT save is not judged (D1 unchanged)', async () => { + const { calls, protocol } = hostWithJudge(REFUSAL); + const result = await protocol.saveMetaItem({ + type: 'permission', name: 'sales_team', item: policySet("owner == '{current_user_id}'"), mode: 'draft', + }); + expect(result.success).toBe(true); + expect(calls).toEqual([]); + }); +}); From e6025b6d5ef99ee91cb0c59f9c518b716bc12b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:52:01 +0000 Subject: [PATCH 3/5] chore(cli,scripts): alias driver-memory to source for the two-door pin; burn down the prose-id baseline Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- packages/cli/vitest.config.ts | 9 +++++++++ scripts/doc-authoring-prose-id.baseline.json | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index b6d0dd5a0a9..6f26afbefee 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -772,6 +772,15 @@ export default defineConfig({ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), }, + // `test/rls-policy-authoring-admission.test.ts` (#20158) runs the metadata + // save door over a REAL engine, and the in-memory driver is its storage. + // Resolved to source for the reason the entry above gives: a verdict about + // the checkout, not about the last build. Anchored like its neighbours + // (driver-memory publishes only `"."`). + { + find: /^@objectstack\/driver-memory$/, + replacement: path.resolve(__dirname, '../drivers/driver-memory/src/index.ts'), + }, ], }, test: { diff --git a/scripts/doc-authoring-prose-id.baseline.json b/scripts/doc-authoring-prose-id.baseline.json index a79c86bd3f8..4505eba4f7b 100644 --- a/scripts/doc-authoring-prose-id.baseline.json +++ b/scripts/doc-authoring-prose-id.baseline.json @@ -206,8 +206,7 @@ "#4716": 2, "#4717": 1, "#7220": 1, - "#8309": 2, - "#8310": 1, + "#8309": 1, "#9698": 1 }, "packages/lint/src/data-model-rules.ts": { From 9df0a6f5a927d9d119a323480a5e9e60d41f9a1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:27:46 +0000 Subject: [PATCH 4/5] test(cli): the two-door pin stores on a :memory: SQLite driver, not driver-memory Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../rls-policy-authoring-admission.test.ts | 30 ++++++++++++++----- packages/cli/vitest.config.ts | 12 ++++---- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/cli/test/rls-policy-authoring-admission.test.ts b/packages/cli/test/rls-policy-authoring-admission.test.ts index 8ab1f147dcd..84d76c284a6 100644 --- a/packages/cli/test/rls-policy-authoring-admission.test.ts +++ b/packages/cli/test/rls-policy-authoring-admission.test.ts @@ -13,9 +13,10 @@ * built from the stack's objects (`stackFilterJudge`, the helper every CLI * call site uses). * - **Runtime** — `saveMetaItem` on a `permission` write, over a REAL `ObjectQL` - * host (in-memory driver, the real `sys_metadata` objects) holding the same - * objects: the protocol probes that engine's `judgeFilter` and hands it to the - * publish gate, which runs the same rule table. + * host (a `:memory:` SQLite driver with real DDL, the real `sys_metadata` + * objects) holding the same objects: the protocol probes that engine's + * `judgeFilter` and hands it to the publish gate, which runs the same rule + * table. * * Neither door holds a model of the engine's walks; both ask `judgeFilter`. * @@ -31,13 +32,13 @@ * nothing resolves, which the compiler refuses rather than lowering). */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec'; import { runAuthoringRules, type AuthoringFinding } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { ObjectQL } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { createLogger } from '@objectstack/core'; import { stackFilterJudge } from '../src/utils/authoring-filter-judge.js'; @@ -93,15 +94,28 @@ function cliDoor(using: string): AuthoringFinding[] { }).filter(RLS); } -/** A live engine holding the same objects, as the protocol's host. */ +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { + await liveEngines.pop()?.destroy(); + } catch { + /* already torn down */ + } + } +}); + +/** A live engine holding the same objects, as the protocol's host — real tables, real DDL. */ async function runtimeHost() { const engine = new ObjectQL({ logger: createLogger({ level: 'silent' }) }); + liveEngines.push(engine); engine.registry.logLevel = 'silent'; - engine.registerDriver(new InMemoryDriver(), true); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); await engine.init(); for (const obj of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, deal, account]) { - engine.registry.registerObject(structuredClone(obj) as never); + engine.registry.registerObject(structuredClone(obj) as never, 'objectstack-test'); } + await engine.syncSchemas(); return { engine, protocol: new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') }; } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 6f26afbefee..3109b7cf595 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -773,13 +773,13 @@ export default defineConfig({ replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), }, // `test/rls-policy-authoring-admission.test.ts` (#20158) runs the metadata - // save door over a REAL engine, and the in-memory driver is its storage. - // Resolved to source for the reason the entry above gives: a verdict about - // the checkout, not about the last build. Anchored like its neighbours - // (driver-memory publishes only `"."`). + // save door over a REAL engine with real DDL, and a `:memory:` SQLite + // driver is its storage. Resolved to source for the reason the entry above + // gives: a verdict about the checkout, not about the last build. Anchored + // like its neighbours (driver-sqlite-wasm publishes only `"."`). { - find: /^@objectstack\/driver-memory$/, - replacement: path.resolve(__dirname, '../drivers/driver-memory/src/index.ts'), + find: /^@objectstack\/driver-sqlite-wasm$/, + replacement: path.resolve(__dirname, '../drivers/driver-sqlite-wasm/src/index.ts'), }, ], }, From 331e67f844fa64331e75a833137fe20c5dfd5f1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:54:47 +0000 Subject: [PATCH 5/5] test(cli): classify stackFilterJudge in the validate/build call-site roster Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- packages/cli/test/validate-build-gate-parity.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index f3f6ec07cb0..66480c839c0 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -184,6 +184,14 @@ const NOT_A_GATE: Readonly> = { // to a gap: both commands read it to COUNT the packages for the step line. 'Reads the artifact\'s `packages[]` for a count both commands print; the pass that judges them is a gate above': ['artifactPackages'], + // [#20158] Builds the engine judge BOTH commands hand the registry + // (`AuthoringRuleContext.judgeFilter`): an INPUT to `runAuthoringRules` / + // `runPerPackageAuthoringRules`, the way the SDUI manifest is. It refuses + // nothing itself — the verdict is the rule's, over the engine's admission. + // What it answers, at both doors, is held by + // `test/rls-policy-authoring-admission.test.ts`. + 'The engine judge handed to the authoring-rule registry as an input — the rule that reads it is the gate': + ['stackFilterJudge'], // [#18431] Artifact ASSEMBLY, and deliberately not a `BUILD_ONLY_GATES` row. // That ledger's entries are gates that cannot run read-only (they rewrite a // committed snapshot, or emit a sibling module); filing this one there would