Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/20158-rls-policy-authoring-admission.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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. -->
10 changes: 9 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -417,10 +418,16 @@ export default class Compile extends Command {
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
// [#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<string, unknown>),
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
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).
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>);
// [#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<string, unknown>);
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`.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -374,13 +375,20 @@ export default class Validate extends Command {
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const parsedUnion = authoringRuleUnionStack(result.data as Record<string, unknown>);
// [#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<string, unknown>),
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
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;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/utils/artifact-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
splitBySeverity,
type AuthoringCommand,
type AuthoringFinding,
type AuthoringRuleContext,
} from '@objectstack/lint';

/**
Expand Down Expand Up @@ -267,6 +268,12 @@ export function runPerPackageAuthoringRules(run: {
unionFindings: readonly AuthoringFinding[];
sduiManifest?: unknown;
loweredHookRefs?: ReadonlySet<string>;
/**
* [#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;
Expand Down Expand Up @@ -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`
Expand Down
99 changes: 99 additions & 0 deletions packages/cli/src/utils/authoring-filter-judge.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** The engine's judge-only filter admission, as the rules take it. */
type FilterJudge = NonNullable<AuthoringRuleContext['judgeFilter']>;

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<ObjectQL['registerObject']>[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);
}
3 changes: 3 additions & 0 deletions packages/cli/src/utils/scaffold-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown>),
});
const { errors, advisories } = splitBySeverity(findings);

Expand Down
Loading
Loading