Skip to content

feat(lint,metadata-protocol,cli)!: RLS read scopes are admitted by the engine judge when authored, at the save door and at os validate / build / lint - #20265

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20158-rls-policy-authoring-admission
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20158-rls-policy-authoring-admission

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20158

Clause-②: yes (narrowing)

yes because two published @objectstack/lint signatures gain an optional judgeFilter input, so @objectstack/lint is minor. (narrowing) because the metadata save door now refuses RLS policies it accepted before, so the release is BREAKING and the changeset carries the ADR-0087 marker. Route A, as ruled in seat answer 5856289438 on the card, following triage 5855733474 and ruling C on #19995 (comment 5852158605).

What changed

A row-level-security policy is permission-set metadata (rowLevelSecurity[]). Its read-scope using could lower cleanly and still be a filter the engine refuses to run: a text operator on a number field, a date field compared against a value its storage cannot read, a filter on a formula field, or a {…} placeholder string. Nothing refused any of these at authoring time. The metadata save door (Studio, REST /meta, MCP) also did not run the RLS predicate rule at all.

  • The rule takes the engine's judge as an input. validateRlsPredicateEnforceability(stack, { judgeFilter }) judges the lowered using of every select / all policy with IObjectQLEngine.judgeFilter. It runs only when every existing pass left the clause clean, so one defect produces one finding. The finding keeps the existing id rls-predicate-unenforceable and quotes the engine's code, status and sentence verbatim. packages/lint holds no model of the engine's checks. Without the input, the pass is skipped and the rule answers exactly as before.
  • The input is threaded through the run signatures: AuthoringRuleContext.judgeFilter (so AuthoringRuleRun.judgeFilter for runAuthoringRules) and the judgeFilter argument of runRuntimeAuthoringRules. The registry entry passes ctx.judgeFilter to the rule.
  • Runtime door. assertRuntimeAuthoringRules (protocol.ts) probes typeof this.engine.judgeFilter === 'function'. It passes the method, bound to the engine, into evaluateRuntimeAuthoringGate, which is internal to the package and stays pure (a new judgeFilter argument, the same pattern as orgWallEnforced), and from there to runRuntimeAuthoringRules.
  • CLI door. The new packages/cli/src/utils/authoring-filter-judge.ts builds an ObjectQL with no driver and no kernel, registers the stack's objects through its own registry (applySystemFields included), and returns its bound judgeFilter. It is built on the first judgement only, and objects are copied before registration so the rules that run later see the author's stack untouched. All five runAuthoringRules call sites pass it: commands/validate.ts, commands/compile.ts, commands/lint.ts, utils/artifact-packages.ts (the per-package pass, handed the union's judge) and utils/scaffold-validate.ts.
  • The surfaces flip. The registry entry is now surfaces: CLI_AND_RUNTIME, runtimeTypes: ['permission']. The per-write snapshot has carried permissions and objects since [2 of #7891] permission/book wiring: TYPE_TO_STACK_KEY entries + the cross-collection snapshot the three comparison rules need #8309, and [3 of #7891] Flip the registration: runtimeTypes gains object + permission/book — whole rule families cross the publish door #8310 closed with no ruling that kept this rule CLI-only (0 matches for rls / RLS / rowLevelSecurity / validateRlsPredicate / CLI_ONLY across its body and 2 comments; control: the body names validateSecurityPosture).
  • No spec change, no new error code, no new rule id.

The refusal text (new, quoted)

The message the rule builds for an engine refusal. Both doors carry it byte for byte:

RLS using `SOURCE` lowers, but the engine refuses to run the lowered filter on "OBJECT" (CODE / STATUS): ENGINE_MESSAGE This clause is part of the object's row-level READ SCOPE, and the analytics face hands the read scope to this same engine admission before it runs a query: every analytics query over the object that this policy scopes is refused.

The hint, chosen by the engine's code (never by its prose):

  • FILTER_TOKEN_UNKNOWN / FILTER_TOKEN_UNRESOLVED: "A {…} placeholder is saved-filter syntax, and an RLS predicate does not use it: the predicate is CEL, and the caller's values are the current_user keys (… the kernel-resolved keys …). Write owner == current_user.id, not owner == '{current_user_id}'."
  • INVALID_FIELD: "A row filter reaches only the stored columns of its own object. Denormalise the value the engine names onto this object (a stored field, written when the source changes) and test that column."
  • any other code: "Rewrite the comparison the engine names so it can run: a text operator (startsWith / endsWith / contains) only on a field that holds a string, and a date or datetime field only against a value its storage can read." followed by the rule's existing lowerable-subset sentence.

At the save door the refusal is the gate's existing envelope, 422 INVALID_METADATA, whose issues[] entry carries the same rule, message and hint. The path is keyed by the set's name (permissions.sales.rowLevelSecurity[0].using). The text is the author's own, so nothing is withheld at authoring time. The runtime read-scope withhold (#5367) is untouched.

The judge-pass binding (as ruled)

The judge sees the reference pass's probe with one change: a current_user.* key the kernel does NOT resolve (an app-staged §7.3.1 membership set) is bound to [] for the judge pass only. Kernel keys keep their runtime-typed probe (a user id or an email is never a date, so the probe stands in faithfully). An app-staged set holds app data this rule cannot know. The measured artefact: close_date in current_user.holidays lowered with the probe string reaches the engine as { close_date: { $in: ['__objectstack_lint_probe__'] } }, and the temporal door refuses it (INVALID_FILTER). Lowered with [], the engine admits it. Pinned at both doors: that clause is accepted, and close_date in current_user.holidays && amount.startsWith('5') is still refused (the binding does not switch the judge off). At the lint layer, a recording judge shows it is handed { close_date: { $in: [] } } for the app key and { owner: { $in: ['__objectstack_lint_probe__'] } } for current_user.org_user_ids.

The fail-open edge (named, not closed)

At the runtime door, the judge reads the live engine's registry, not the per-write snapshot. An object that exists only in the same publish batch (publishPackageDrafts pending declarations), or only in an organization overlay row, is one that registry does not hold, so it gets the engine's unknown-object answer: the field-map doors (text operator, temporal, formula, dotted) answer nothing, while the schema-free doors (placeholders, comparand shape and type) still judge. The runtime read-scope withhold (#5367) stays the backstop there. As ruled, no snapshot-based second registry is added to close it. The CLI door gives the same answer for an object the stack does not define (another package's, or a platform object), and for an object the registry itself refuses to register (refused at boot by that same registry).

What the judge does not see

It judges only using clauses that belong to the read scope: select and all, the two getApplicablePolicies matches for a read. A check is matched in memory against a post-image and never reaches the engine's where admission. A using on an insert / update / delete-only policy is not part of the read scope either. Both are pinned (a recording judge counts 0 calls for them).

The fifteen classes, both doors (packages/cli/test/rls-policy-authoring-admission.test.ts)

One table. Each row runs os validate's own composition in process (normalizeStackInput → lowerCallables → ObjectStackDefinitionSchema → union fold → runAuthoringRules('validate', …) with stackFilterJudge) and saveMetaItem({ type: 'permission' }) over a real ObjectQL host (in-memory driver, the real sys_metadata objects from @objectstack/metadata-core) holding the same objects. It asserts one verdict and one sentence (issues[0].message === cliFinding.message), plus the envelope INVALID_METADATA / 422 and the path.

# Class (#19995) Family CEL as written Rule at both doors Engine code quoted Before (805af4f2): os validate / save
1 list in the implicit equality slot comparand shape region == ['emea', 'apac'] rls-predicate-unenforceable — refused / accepted
2 list under $eq comparand shape region == current_user.org_user_ids rls-predicate-unenforceable — refused / accepted
3 scalar under $in comparand shape region in 'emea' rls-predicate-unenforceable — refused / accepted
4 scalar under $nin comparand shape !(region in 'emea') rls-predicate-unenforceable — refused / accepted
5 one-bound $between (no CEL spelling; nearest: a range with a null bound) comparand shape amount >= 1 && amount <= null rls-predicate-unenforceable — refused / accepted
6 null member in $in comparand shape region in ['emea', null] rls-predicate-unenforceable — refused / accepted
7 plain-object member in $in comparand type region in [{'a': 1}] rls-predicate-unenforceable — refused / accepted
8 plain-object comparand under $eq comparand type region == {'a': 1} rls-predicate-unenforceable — refused / accepted
9 undefined comparand (no CEL spelling; nearest: a key nothing resolves) comparand type region == current_user.nope rls-predicate-unknown-user-variable — refused / accepted
10 unknown placeholder placeholder owner == '{current_usr_id}' rls-predicate-unenforceable FILTER_TOKEN_UNKNOWN accepted / accepted
11 known placeholder, no context placeholder owner == '{current_user_id}' rls-predicate-unenforceable FILTER_TOKEN_UNRESOLVED accepted / accepted
12 text operator on a non-text field schema door amount.startsWith('5') rls-predicate-unenforceable INVALID_FILTER accepted / accepted
13 temporal comparand the platform cannot read schema door close_date > 'soon' rls-predicate-unenforceable INVALID_FILTER accepted / accepted
14 filter on a virtual (formula) field schema door is_open == true rls-predicate-unenforceable INVALID_FIELD accepted / accepted
15 dotted path through a lookup schema door account.region == 'emea' rls-predicate-unenforceable — refused / accepted

Controls, accepted at both doors: owner == current_user.id, owner in current_user.org_user_ids (the pre-resolved IN form) and name.startsWith('A') (a text operator on a text field).

The "Before" column was measured on 805af4f2 with a scratch probe (not committed). The rule was run directly for os validate, and saveMetaItem for a permission write on a stub host. Rows 5 and 9 were not in that probe. Their os validate reading was taken at this head with no judge passed, which runs exactly the passes 805af4f2 ran: the rule's diff only adds the judge pass, and packages/formula changed no production file between the two. Rows 1–4, 7, 8 and 15 are CEL shapes the pushdown compiler refuses, so the existing shape pass already reports them. Rows 5 and 6 are the existing shared-face pass, and row 9 is the existing unknown-variable pass. The five rows the engine alone refuses (10–14) were accepted at both doors. Every row was accepted at the save door, because the rule did not run there.

Placeholder semantics are as 5855081099 records them: a known placeholder judged with no context answers FILTER_TOKEN_UNRESOLVED, an unknown one FILTER_TOKEN_UNKNOWN. No authored policy in the census uses a {…} token, so no mode change is needed and none is added.

Measurements

  • Producer census, with the real rule and a real judge (scratch probe, not committed; tree cb223b1e). A driverless ObjectQL held every object definition the tree ships (packages/**/src/**/*.object.ts: 75 files, 75 objects, 0 imports failed, 0 refused by the registry). The run was validateRlsPredicateEnforceability(stack, { judgeFilter }):
    • plugin-security defaultPermissionSets: 107 clauses, 104 read-scope using, 104 judge calls, 0 findings. 28 of its 29 policy objects are known to that engine; the 29th is the '*' wildcard, which gets the unknown-object answer.
    • packages/metadata hotcrm fixture: 4 clauses, 2 read-scope using, 2 judge calls, 0 findings.
    • Positive control, same run: a planted user_id.startsWith('{current_user_id}') on sys_session produced 1 finding (FILTER_TOKEN_UNRESOLVED).
    • examples/app-showcase (allPermissionSets over its 24 objects, judged by an engine holding those objects, 0 refused by the registry): 3 clauses, 2 read-scope using, 2 judge calls, 0 findings.
  • Placeholder spelling: in examples/** and packages/** (non-test), 63 of 88 using: / check: lines name current_user. (CEL variables), and 0 carry a {token}. cloud origin/main 96eb092 has 0 authored policies (control: rowLevelSecurity 1 comment hit). objectui at pin f8a9d0fb has 0 authored policies (control: rowLevelSecurity 10 hits, the Studio RLS editor PermissionAdvancedFacets.tsx, a real producer of save-door writes).

Tests

All heavy runs went through scripts/pm/os-verify-lock.sh. Each verdict below is the run's own tally.

Final head a8102979 (origin/main 443b2f4f merged; the post-merge re-verification ran after pnpm install --frozen-lockfile and a @objectstack/cli... closure build, Tasks: 56 successful, 56 total):

  • The two-door pin (packages/cli/test/rls-policy-authoring-admission.test.ts; the tier file places it in integration, run locally because this diff adds it): Tests 21 passed (21).
  • Runtime door (packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, the new #20158 block on the file's existing pinned stub host): Tests 39 passed (39).
  • Lint contract with the judge (packages/lint/src/validate-rls-predicate-enforceability.engine-judge.test.ts, 11 cases) runs inside the lint suite below.
  • Consumer suites (each package's whole suite):
    • pnpm --filter @objectstack/lint test: Test Files 111 passed (111), Tests 4273 passed (4273).
    • pnpm --filter @objectstack/cli exec vitest run --project unit: Test Files 229 passed (229), Tests 3250 passed (3250). The integration tier is left to CI, except the new pin above.
    • pnpm --filter @objectstack/plugin-security test (read-only consumer): Test Files 139 passed (139), Tests 2839 passed (2839).
    • pnpm --filter @objectstack/metadata-protocol test at cb223b1e: Test Files 189 passed | 3 skipped (192), Tests 2707 passed | 19 skipped (2726). The second merge brought no metadata-protocol source change (one new test file on main), and the gate file was re-run at the final head (above).
  • Typecheck at the final head: pnpm --filter @objectstack/lint typecheck, @objectstack/metadata-protocol and @objectstack/cli all exit 0.
  • An earlier red, fixed in this PR: the first CLI unit run (9df0a6f5) failed test/validate-build-gate-parity.test.ts with compile.ts / validate.ts call 1 name(s) this file has not classified: stackFilterJudge. The name is now classified in that file's NOT_A_GATE ledger as an input to the registry. Two published-subpath-* files failed in the same run only because the CLI itself was not built; after the build they passed (Tests 29 passed (29)).

Reverse verification (ablations)

The pin reads @objectstack/lint and @objectstack/metadata-protocol through exports, which resolve to dist/, and no alias points them at source. So every leg was: scripts/ablation-replace.mjs in wrap mode (the anchor hits exactly once, the blob changes, the restore is proven blob == HEAD with an empty git diff HEAD); rebuild the package; scripts/ablation-dist-preflight.mjs shows the marker in dist/; run the pin; then rebuild after the restore and preflight --absent.

Leg Mutation Marker in dist/ Pin under mutation Failing cases Restore
A1B protocol.ts passes the gate NO judge (… && ('ABLATION_20158_A1B' in globalThis) ? { judgeFilter } : {}) present in 2 built files 6 failed | 15 passed (21) rows 10–14 and the binding-refusal case: expected true to be false (the save door ACCEPTED; the CLI door still refused) rebuilt, marker absent, pin 21 passed
A2B the rule never asks the judge (const refused = ('ABLATION_20158_A2B' in globalThis) && …) present 6 failed | 15 passed (21) the same six, failing first at the CLI door: expected [] to deeply equal [ { severity: 'error', … } ] rebuilt, marker absent, pin 21 passed

Direction as predicted: red, and red only where the engine alone refuses. The ten rows owned by earlier passes and the three controls stayed green under both legs.

⚠️ The first pair of legs did not measure anything, and their readings are discarded:

  • A1's replacement read !('…' in globalThis), which is true, so the judge was still handed through. The mutation was semantically a no-op, and the pin stayed 21 passed.
  • A2's replacement still contained its own anchor, so ablation-replace refused it (the anchor count moved 1 → 1) and restored the file. Nothing ran.

Both were redone as A1B and A2B above. The tree was clean after every leg (git status --porcelain empty).

Gates

Derived at the final head a8102979 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 74 families. Every line was run and recorded as command :: exit N before any pipe, then reconciled:

Run reconciliation — 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN.
dispatch-gates --ran: 74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3).
  • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3: studio, client-react, embedder-openai, knowledge-ragflow, organizations and service-cluster-redis had no dist/). After those six were built it exited 0 (104 published require entry point(s) across 66 package(s) load), and the record carries that re-run.
  • check:i18n, check:i18n-coverage and check:i18n-walk-parity answered exit 3 on the first pass (the CLI was not built). After their named closure build they exited 0.
  • Fixed by this PR on the way:
    • check:test-source-alias asked for an anchored @objectstack/driver-sqlite-wasm source alias for the new pin.
    • check:driver-memory-census refused a new driver-memory test consumer, so the pin stores on a :memory: SQLite driver instead.
    • check:doc-authoring asked for the shrink-only prose-id ledger burn-down.
  • Lint, narrowed and proven. pnpm lint is CI's. The population comes from eslint.config.mjs itself (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED). eslint --no-inline-config --format json over the 16 TypeScript files this diff touches read 16 files, with 0 errors and 0 warnings. The resolved parserOptions for these files is {"ecmaVersion":"latest","sourceType":"module"}, with no project and no projectService, so type-aware linting is off and this diff cannot move a verdict on any untouched file.
  • Not local, by the dispatch tooling's own accounting: the CI job steps and the wide-population and roster families the derivation lists outside its 74 (Test Core shards, Dogfood, Build Core, Temporal Conformance, the workspace type-check lanes). CI runs them on this PR.

Acceptance notes


Generated by Claude Code

… engine's judge at both authoring doors

The RLS predicate rule takes the engine's judge-only filter admission
(IObjectQLEngine.judgeFilter) as an optional input and judges every
read-scope `using` with it. The runtime publish gate hands in the live
engine's method; the CLI commands hand in a driverless engine built from
the stack's objects. The rule now also runs at the runtime door for
`permission` writes.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
…n; burn down the prose-id baseline

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/lint, @objectstack/metadata-protocol, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 51 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7ce7f5491a88404f418ea13c4ec6c2b7c55cbec6 — the merge of head a81029795938139498932f60b317305be065275d into base a9fb83ef06a079a938c97d1ea5e364705e1bc13b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7ce7f5491a88404f418ea13c4ec6c2b7c55cbec6 && git checkout 7ce7f5491a88404f418ea13c4ec6c2b7c55cbec6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a9fb83ef06a079a938c97d1ea5e364705e1bc13b a81029795938139498932f60b317305be065275d && git checkout -B drift-repro a9fb83ef06a079a938c97d1ea5e364705e1bc13b && git merge --no-ff a81029795938139498932f60b317305be065275d

node scripts/docs-audit/affected-docs.mjs --json a9fb83ef06a079a938c97d1ea5e364705e1bc13b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a9fb83ef06a079a938c97d1ea5e364705e1bc13b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a81029795938139498932f60b317305be065275d

① Derived judgments

  • 1. One judge, two doors, one answer — RIGHT. Re-ran the enumerating pin on a fresh detached worktree at the head (pnpm install --frozen-lockfile, turbo build --filter=@objectstack/cli... 56/56, lint and metadata-protocol resolved through dist/): packages/cli/test/rls-policy-authoring-admission.test.ts 21 passed (21); it asserts per row one CLI finding (severity/rule/path), the runtime 422 INVALID_METADATA with the same rule at the name-keyed path, and saved.issues[0].message === cli[0].message, with the engine code quoted for rows 10–14. Beyond the pin: (a) the driverless judge's every recorded question over the whole producer census (see item 5) was replayed into a live ObjectQL (sqlite driver, init(), all 83 shipped *.object.ts definitions registered, 0 refused) and JSON.stringify of the verdicts differed in 0 of 110 calls; (b) registry-injected system columns are byte-identical between the driverless and live registries (created_at:datetime, created_by:lookup, organization_id:lookup, owner_id:lookup, owning_business_unit_id:lookup, updated_at:datetime, updated_by:lookup), and multiTenant only changes the index (registry.ts:540–545), so created_at gt 'soon' (gt spells the greater-than operator, kept out of this record for the sanitizer), created_at.startsWith('2'), created_by.name == 'x', owner_id == current_user.id, organization_id == current_user.organization_id all agree at both doors; (c) an object held under a package namespace in the live registry yields the same sentence (find('deal'): …); (d) plugin field types cannot diverge: a non-member type is refused by ObjectStackDefinitionSchema before the CLI rule runs and by registerObject (ObjectFieldTypeRefusedError, [finding] driver-sql and both migration generators default an absent or unknown field type to DIFFERENT families — string versus text, so the unvalidated authoring door produces two different columns #16319) in both registries. The only disagreement class is the declared one: a policy on an object the CLI stack does not define (another package's, a platform object, '*') gets the schema-free verdict only at os validate (other_pkg_obj/'*' with amount.startsWith('5')/close_date gt 'soon' → clean; with '{current_user_id}' → FILTER_TOKEN_UNRESOLVED) while the save door judges the full field map. Named in the body and the changeset.
  • 2. No second admission — RIGHT. git grep over packages/lint/src (non-test) for judgeWhereAdmission|lowerWhereFilterArray|filter-comparand-shape|temporal-comparand-door|text-operator-declared-type-door|assertFilterIsMaterializable|assertTextOperatorTargetsAreStringCapable|assertTemporalComparandsInterpretable|from '@objectstack/objectql → 0 code hits (4 docblock mentions in unrelated rules). The rule imports only @objectstack/formula, @objectstack/spec/{contracts,data,kernel} and ./object-graph.js; packages/lint/package.json depends on formula/sdui-parser/spec only. The new pass is engineRefusal → judge(object, where, { operation: 'find' }) and nothing else (validate-rls-predicate-enforceability.ts:1071–1095); the pre-existing lint: validate-rls-predicate-enforceability is silent on == / != against a kernel membership key (current_user.org_user_ids …), a policy the runtime drops on every request once PR #19947 lands #19951 shared faces (assertListComparandShapes/normalizeFilterComparandTypes from spec) are the engine's own functions, not a model, and predate this PR. The CLI judge is a real ObjectQL instance's bound judgeFilter (authoring-filter-judge.ts:98).
  • 3. Fail direction — RIGHT with one misstatement. (a) Judge absent: options.judgeFilter undefined → judge undefined → referenceFindings runs the pre-existing passes unchanged (lines 1240–1246 gate the new pass on judge && filter && findings.length === 0); pinned at lint (without a judge … stays clean), at the pure gate (without judgeFilter the runtime gate still runs the rule) and at the protocol door (a host engine WITHOUT the member keeps its answer, which also pins that the surfaces flip alone now refuses rls-predicate-unparseable there). assertRuntimeAuthoringRules probes typeof this.engine.judgeFilter === 'function' (protocol.ts:5131). (b) Judge throws a non-envelope fault, probed with a live host whose judgeFilter throws Error('boom'): CLI runAuthoringRules propagates (command fails loudly); the runtime door does NOT 500 and does NOT fail loudly — the pre-existing runRules catch in runtime-gate.ts converts it to a warning-tier authoring-rule-threw advisory, saveMetaItem returns success with that advisory on the 2xx and console-warns, and the row is stored (accepted: true, stored: 1). Not silent, and by the judge's contract a non-envelope throw is a bug not a refusal, so no ruled requirement is broken; but the PR body's Acceptance note "the publish or the command fails loudly instead of skipping the judgement" is wrong for the save door — that door skips the judgement and lands the write. Not pinned. See ③. (c) Runtime unknown-object edge, probed through runRuntimeAuthoringRules with the live judge and pending_obj only in context.objects: amount.startsWith('5') → accepted (fail-open, field-map doors answer nothing), owner == '{current_user_id}' → refused (schema-free door). Exactly as declared in the body ("The fail-open edge (named, not closed)"), the changeset and the protocol.ts comment; analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 stays the backstop.
  • 4. The [] binding — RIGHT, confined to the judge pass. engineJudgeProbe (lines 1049–1061) rebinds only keys outside RESERVED_RLS_MEMBERSHIP_KEYS (id, organization_id, positions, org_user_ids, accessible_org_ids, email); the rebound probe feeds a local compileWithProbe whose output is passed to the judge and discarded (engineRefusal). The reference passes keep PROBE_ARRAY; the runtime RLSCompiler never sees it. Probed: after a judged save of close_date in current_user.holidays, the sys_metadata.metadata row holds using: "close_date in current_user.holidays" verbatim and getMetaItem reads it back unchanged; the CLI judge leaves the author's stack byte-identical (JSON.stringify before/after equal). What it hides: a refusal that depends on the MEMBERS of an app-staged set — concretely a date/datetime field tested with in current_user.APP_KEY whose staged members the temporal door cannot read (INVALID_FILTER at request time). That is app data the rule cannot know; a kernel key keeps its typed probe (close_date in current_user.org_user_ids still reaches the door), a non-kernel key in a scalar position is already rls-predicate-unknown-user-variable so the judge never runs, and a virtual field under in current_user.APP_KEY is still refused (INVALID_FIELD judges the field, not the members). The binding does not switch the judge off (pinned: close_date in current_user.holidays && amount.startsWith('5') refused at both doors).
  • 5. Placeholder semantics and the census — RIGHT, nothing real is refused. Per 5855081099: known without context → FILTER_TOKEN_UNRESOLVED, unknown → FILTER_TOKEN_UNKNOWN; pinned as rows 10/11 and reproduced. Census through both doors with the real rule, the real driverless judge and the live-engine replay: plugin-security securityDefaultPermissionSets 8 sets / 107 clauses / 104 read-scope using / 104 judge calls / 27 policy objects all known → 0 findings; examples/app-showcase allPermissionSets 9 sets / 3 clauses / 2 judged → 0; packages/metadata hotcrm fixture 6 sets / 4 clauses / 2 judged (objects not in tree, unknown-object answer) → 0. Total 114 clauses, 0 refused, sentences identical at both doors, 0 replay differences. Positive control fires at both doors with one sentence (user_id.startsWith('{current_user_id}') on sys_session → FILTER_TOKEN_UNRESOLVED; created_at gt 'soon' on sys_user → INVALID_FILTER). git grep over packages/**/src, examples/** (non-test): 0 using/check strings carry a {token}; cloud 48d70663 has 0 authored policies (3 hits are a comment, a test assertion of ?? [], a design doc); objectui 5c61e524 has 0 authored policies (the Studio editor's default policy is object: '*', operation: 'all', using: '' and its placeholder text is organization_id == current_user.organization_id, CEL not a token).
  • 6. The published lint surface — RIGHT. Diff of exported declarations across lint/metadata-protocol/cli: only validateRlsPredicateEnforceability(stack, options?) (optional second parameter) and the new CLI-internal stackFilterJudge (packages/cli exports ., ./console, ./hook-body only; utils/ is not published). Built dist/: AuthoringRuleContext.judgeFilter? and the runRuntimeAuthoringRules args' judgeFilter? in runtime-BdcNALoi.d.ts:180,559, the rule's options? in index.d.ts:2431; AuthoringRuleRun extends AuthoringRuleContext, so it inherits the member. All optional; a caller passing nothing gets the prior verdicts. No lint api-surface baseline exists in the tree (packages/spec/api-surface/* covers spec only), so the delta is measured from the .d.ts, not a gate. Consumers of runAuthoringRules/runRuntimeAuthoringRules/evaluateRuntimeAuthoringGate: the 5 CLI sites + metadata-protocol in this repo; 0 in cloud, 0 in objectui.
  • 7. Scope — consistent with ruling C; one gap noted, not ruled. Only select/all using reaches the judge (READ_SCOPE_OPERATIONS, line 1034; getApplicablePolicies maps reads to select plus all), pinned with a recording judge (0 calls for check, insert/update/delete using). Ruling C says "judge the lowered read-scope filter"; the judge contract itself says RLS predicates middleware composes after admission are not judged, so the engine's where admission is reached by an RLS predicate only on the analytics read scope. check is matched in memory (post-image). The fifteen ruled classes are all read-scope. ADR-0058 D2 names using/check on every operation as compile surfaces; the compile-lowering half of this rule already runs on both clauses and all operations — only the engine field-type pass is read-scope-only. Consequence (③): an update/delete-only using or a check with a text operator on a number field is judged at neither door and is answered at request time by the driver doors / in-memory evaluator.
  • 8. Pins — substance at both doors; nothing flipped silently. Enumerating pin: verdict + envelope + path + sentence equality + engine code per row, 3 controls, the binding pair. Lint contract test (11): call shape (object, lowered where, { operation: 'find' }), verbatim message, hints keyed by code, read-scope-only, one-defect-one-finding, [] vs typed probe where-shapes, both run signatures, registry entry ['cli','runtime-publish']/['permission'], no-judge behaviour. Protocol test (4 new): bound-this probe, 422 envelope with the sentence and no row written, no-member host, draft not judged. Per-file deletions are confined to source (authoring-rules.ts 8 = the retired surfaceReason, rule file 4, runtime-gate 1, protocol 1, compile/validate 1 each, baseline JSON 2); no test file loses a line. Existing surface pins re-run green: authoring-rule-wiring, rule-id-barrel-exports, runtime-gate.inert-type-writes 71 passed (71); validate-rls-predicate-enforceability.engine-judge.test.ts 11/11; protocol.runtime-authoring-gate.test.ts 39/39. Ablations in the body (A1B/A2B, six engine-only cases red each) match the pin's structure. CI at this head at review close: 31 success / 6 skipped / 1 in progress (Lint & Repo Gates), 0 failing; Check Changeset, both Type Check source/consumer lanes and Test Core 1–6 green.

② Semver level

③ Boundary flags

  • Runtime door on a throwing judge is fail-open with an advisory, and the body says otherwise. runRules's pre-existing catch (runtime-gate.ts, "a rule that throws … must not take the write down") turns any rule throw into authoring-rule-threw (warning) and the write lands; probed at the real saveMetaItem door: accepted, stored, advisory on the 2xx. Only the CLI door propagates. The Acceptance note "the publish or the command fails loudly" should be corrected to name the runtime advisory; a one-line pin at the protocol door would fix the direction in the record. Gate-wide policy, not introduced here; analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 still backstops the read scope.
  • The runtime unknown-object edge (object only in the publish batch or an org overlay): declared and measured fail-open for the field-map doors, fail-closed for placeholders/comparand shape; not pinned. As ruled, no snapshot registry is added.
  • The CLI unknown-object answer (a policy on another package's or a platform object at os validate) is the one place the two doors answer differently; declared. A stack that names the same object in two packages: the driverless registry re-registers under __runtime__ and holds the LAST definition (probed), while a booted engine's cross-package collision is error unless OS_METADATA_COLLISION=warn (registry.ts:1873); contrived, unmeasured at a public door.
  • Object writes that retype a field an existing policy names are not re-judged (declared; runtimeTypes: ['permission']); os validate over the whole stack catches it.
  • Non-read-scope predicates (update/delete-only using, every check) never reach the engine's field-type doors at either door; consistent with ruling C, outside the fifteen classes; a follow-up would need the driver/evaluator faces, not this judge.
  • The [] binding hides member-dependent temporal refusals on app-staged membership sets (named in ①.4).
  • [finding] $ne with an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 remainder (the lint arm for field-vs-multi-valued-field comparison) is serial after this PR, per the seat ACCEPT.
  • The authoring-rule-threw and unknown-object behaviours are the two untested edges; everything the body claims about verdicts, sentences, the hatch (OS_ALLOW_UNLINTED_METADATA_WRITES=1 → loud warn, write lands), draft-then-publish (draft accepted unjudged, publishMetaItem refused 422) and the re-save differential was reproduced.

Implemented-by: claude/issue-20158-rls-policy-authoring-admission
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ds a list or an object (objectstack-ai#20310)

Part of objectstack-ai#19886
Clause-②: no

Stage 2e, remainder items 2 and 3 of release `5858069107`, as ruled on
the fork report `5858423045` in seat ruling `5858444849` (Q1 A, Q2 A, Q3
A). Claim `5858140051`. Base `17bd3187`, merged with `origin/main`
`ae8e3ca0` through `scripts/pm/os-regen-merge.sh`; head `2e54475b`.

## What changes

- **`@objectstack/formula` `matchesFilterCondition`.** One arm in
`evalOp`: `$gt` / `$gte` / `$lt` / `$lte` and `$between` refuse, per
record, a STORED operand (`actual`) that is a list or a plain object,
whatever the comparand. It uses the stage 2d predicate
`isNonScalarValue` and the stage 2d error `arrayComparandError`
(`INVALID_FILTER` / 400). `null` and `Date` do not move, and neither
does equality (`$eq`, `$ne`, implicit equality, `$in`, `$nin`) against a
stored list.
- **The norm it follows** is driver-sql's declared refusal (objectstack-ai#7398,
`JSON_COLUMN_INCOMPATIBLE_OPERATORS`): every ordering comparison, and
`$between`, on a column it stores as JSON text is refused by declared
type with 400. The evaluator has no schema, so it judges the value on
the record.
- **`@objectstack/spec`.** A new ADR-0087 semantic entry,
`rls-predicate-stored-list-ordering-refused`, plus the Q3 correction to
the stage 2a entry `rls-predicate-array-comparand-refused`. Only the
generated regions of `registry.ts` move, at about :12722 and :12749.
- **Changeset** `.changeset/19886-stored-list-ordering-refused.md`:
`Clause-②: no (narrowing)`, BREAKING, `registered
rls-predicate-stored-list-ordering-refused`. It names the three Q1 moves
and explain's four cells.

No change to explain, `packages/lint`, `security-plugin.ts`,
`cel-to-filter.ts` or the shared face.

## Measured cells

The stack is real: SecurityPlugin + ObjectQL on driver-sql
(better-sqlite3) and driver-memory. Before is `17bd3187`,
cells-before.json, sha256 `a3d2b42d49563cca`. After is head `2e54475b`,
cells-head.json, sha256 `21548839aac31220`. The after table is
byte-identical to the fork report's temporary measurement of the same
route.

Each cell reads before → **after**. Every 400 is `INVALID_FILTER`; every
403 is `PERMISSION_DENIED`. "(driver)" marks driver-sql's own refusal.

| id | predicate | list on the write | check insert (sql / memory) |
by-id update carrying the list | by-id update of another field, stored
list | `using` read, driver-sql | `using` read, driver-memory |
|---|---|---|---|---|---|---|---|
| O1 | `record.tags > 'a'` | `['m']` | admitted / admitted → **400 /
400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m,
r_scalar → rows r_list_az, r_list_m, r_scalar |
| O2 | `record.tags < 'z'` | `['m']` | admitted / admitted → **400 /
400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m,
r_scalar → rows r_list_az, r_list_m, r_scalar |
| O3 | `record.tags >= 'a'` | `['m']` | admitted / admitted → **400 /
400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m,
r_scalar → rows r_list_az, r_list_m, r_scalar |
| O4 | `record.tags <= 'z'` | `['m']` | admitted / admitted → **400 /
400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m,
r_scalar → rows r_list_az, r_list_m, r_scalar |
| O5 | `record.tags > 'n'` | `['m']` | 403 / 403 → **400 / 400** | 403 /
403 → **400 / 400** | 403 / 403 → **400 / 400** | 400 (driver) → 400
(driver) | rows r_list_az → rows r_list_az |
| O6 | `record.meta < 'a'` | `{a:1}` | admitted / admitted → **400 /
400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | no rows → no rows |
| O7 | `record.meta > 'a'` | `{a:1}` | 403 / 403 → **400 / 400** | 403 /
403 → **400 / 400** | 403 / 403 → **400 / 400** | 400 (driver) → 400
(driver) | rows r_scalar → rows r_scalar |
| O8 | `record.watchers > 'a'` | `['p1']` | admitted / admitted → **400
/ 400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m →
rows r_list_az, r_list_m |
| O9 | `record.watchers < 'p2'` | `['p1']` | admitted / admitted → **400
/ 400** | admitted / admitted → **400 / 400** | admitted / admitted →
**400 / 400** | 400 (driver) → 400 (driver) | rows r_list_az, r_list_m →
rows r_list_az, r_list_m |
| C1 | `record.status > 'a'` | `status: ['m']` | admitted / admitted →
**400 / 400** | admitted / admitted → **400 / 400** | admitted /
admitted → **admitted / admitted** | rows r_list_az, r_list_m, r_null,
r_obj, r_scalar → rows r_list_az, r_list_m, r_null, r_obj, r_scalar |
rows r_list_az, r_list_m, r_null, r_obj, r_scalar → rows r_list_az,
r_list_m, r_null, r_obj, r_scalar |
| C2 | `record.amount > 10` | `amount: [500]` | admitted / admitted →
**400 / 400** | admitted / admitted → **400 / 400** | 403 / admitted →
**403 / 400** | no rows → no rows | no rows → no rows |
| C3 | `record.tags == 'm'` | `['m']` | 403 / 403 → **403 / 403** | 403
/ 403 → **403 / 403** | 403 / 403 → **403 / 403** | 400 (driver) → 400
(driver) | rows r_list_m, r_scalar → rows r_list_m, r_scalar |

Notes on the table:
- The C1 update edits `status` itself, so the stored list is replaced
before the check runs.
- On the C2 update, driver-sql had stored `[500]` as the text `'[500]'`
(see the REST cell below), so its post-image holds a string and is
denied 403.
- driver-memory's `using` read is unchanged. It still compares a stored
list element by element, so its write and read now part; this is
declared on objectstack-ai#15104, as for 2d's `{ $field }` half.
- `$between` measured the same coercion on the evaluator: `{ tags: {
$between: ['a','z'] } }` on `['m']` gave true before and 400 after. CEL
never produces it (`record.tags >= 'a' && record.tags <= 'z'` lowers to
`$and` of `$gte` and `$lte`).
- objectql `having` is byte-identical before and after on both drivers.
The literal path uses objectql's own comparator, and the `{ $field }`
path at :1117 was already refused by 2d.

### Explain (Q2 A: named, not changed)

`security/explain` evaluates the business RLS layer in-process. Both 400
paths, this stage's (O1) and 2d's `{ $field }` (X1), per operation:

| explain operation | driver | enforcement answers | equal |
|---|---|---|---|
| `read` | driver-sql | 400 (the driver's refusal) | yes |
| `update` | driver-memory | 400 (the post-image check) | yes |
| `update` | driver-sql | 403: the pre-image gate fails closed on the
driver's 400 | no, both deny |
| `read` | driver-memory | the rows its element-wise read admits | no,
the test driver |

## Premises

- **P1 holds.** The census covered `packages/**` and `examples/**` at
`17bd3187`, and cloud `96eb092`. 0 shipped using/check/sharing-condition
predicates use any ordering operator.
- **P2** as restated by the ruling: it holds for every production door,
and parts only on the frozen driver-memory (O5, O6).
- **P3** has four cells, above.

## Refusal text (the one changed sentence, quoted)

Before (the tail of the first clause of `arrayComparandError`):

```text
… or as a member of an "$in" / "$nin" list, or a column on either side of a { "$field" } comparison that holds a list or an object. A list is not one comparable value.
```

After:

```text
… or as a member of an "$in" / "$nin" list, or a column that holds a list or an object on either side of a { "$field" } comparison or on the compared side of an ordering operator or "$between". A list is not one comparable value.
```

The rest of the message is unchanged. It still withholds the field, the
operator and the value, and a pin asserts that for the stored side too.

Q3, the stage 2a entry's `replacement` now ends:

```text
Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before
```

## Pins

- **`packages/formula/src/matches-filter-array-comparand.test.ts`**, new
stage-2e block of 38 tests:
- six stored shapes × five operators (a one- and a multi-element list,
an empty list, a list of lists, an object, an empty object);
  - the depths evaluation reaches, including under `$not`;
  - every kind of comparand (number, Date, null, `{ $field }`);
  - a list written into a scalar field;
- per-record controls (a scalar compared), `null`, a missing field and a
`Date`;
  - equality against a stored list, unchanged;
  - the message withholding the field and value.
- The 2a/2d pins are unchanged, including "a scalar comparand against a
STORED array is untouched".
-
**`packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts`**,
94 tests, real stack on driver-sql (better-sqlite3) and
driver-sqlite-wasm:
  - O1–O9, C1, C2, each on:
- the check insert (400, nothing stored), beside its scalar or null
control;
    - the by-id update carrying the list (400, row unchanged);
- the by-id update of another field on a stored-list row (400, row
unchanged; json and multiple columns);
    - the `using` read (driver's own 400; C1 and C2 compare).
  - The `null`, `Date` and C3 controls.
- The explain cells for O1 and X1 (read 400 = find 400; update 400
against a 403 by-id update).
- driver-memory is not pinned in-tree. Declaring
`@objectstack/driver-memory` in plugin-security needs a
`driver-memory-census` ledger disposition, which is a maintainer ruling;
the in-repo precedent is
`bootstrap-platform-admin-promotion-selection.test.ts`. Its cells above
are measured out of tree.

## Ablation (one-shot proof that the pins can fail)

- **Base.** Taken from committed `18e5a78b`, via
`scripts/ablation-replace.mjs`. The arm was disarmed (`op ===
'ZZ_ABL_19886E' && …`): anchor 1 → 0, blob `6e061b4c` → `af46be42`.
- **Build.** Formula rebuilt, and `ablation-dist-preflight` found the
marker in `dist/index.js` and `dist/index.mjs`.
- **Red.**
- formula: 34 of 129 tests red — the 30-cell matrix, depth, comparand
kinds, list in a scalar field, and message.
- plugin-security: 64 of 94 tests red — every list-holding insert and
update cell and the O1 explain cell, on both drivers.
- The 30 still green are exactly the `using` reads, the controls and
2d's X1 explain.
- **Restore.** Back to the HEAD blob `6e061b4c`; `git diff HEAD` empty;
rebuilt; `preflight --absent` passes on all 6 built files.

## Tests and gates (at head `2e54475b`)

**Build and suites.** Full `turbo run build` exited 0 (72/72). All of
these exit 0, each through `scripts/pm/os-verify-lock.sh`:

| suite | files | tests |
|---|---|---|
| formula | 39 | 1165 |
| plugin-security | 141 | 2990 |
| new pin alone | 1 | 94 |
| lint | — | 4295 (no pin moved) |
| objectql having (`engine-aggregate-having-comparand-shape`,
`having-filter`) | 2 | 176 |
| plugin-sharing | 37 | 913 |
| driver-memory | 56 | 1374 |
| driver-sql consumer files of the evaluator | 8 | 474, 4 skipped |
| driver-sqlite-wasm consumer files | 6 | 217 |
| platform-objects consumer | — | 18 |

**Typechecks.** formula and plugin-security exit 0. Both run
`check:test-typecheck` over `tsconfig.test.json`, which includes
`src/**/*`, so the test files are covered.

**Gates.** `dispatch-gates --commands` at `2e54475b` derives 90
commands. All 90 exit 0, and `--ran` reconciles: "90 run, 0
NOT-MEASURED". Also run, all exit 0:
- the dispatch lead's `pnpm check:authz-resolver`;
- `check:generated` (15/15 up to date);
- the four roster gates whose roster lies under a changed path
(`check-changeset-fixed`, `check:meta-url-spelling`,
`check:error-code-casing`, `check:filter-alias-parity`);
- `check:engine-double-contract`;
- `check:doc-authoring`.

`check:entry-nameability` prints a pre-existing partial NOT MEASURED for
`@objectstack/spec/api-assembled` and `/qa`, unrelated to this diff.

**eslint (narrowed, measured).** Covered here:
1. The population is the 6 changed `.ts` files, each resolved by
eslint's own config (`--print-config`).
2. `--format json` counts 6 files, 0 errors, 0 warnings, under
`--no-inline-config`.
3. `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict of an
untouched file.

Repo-wide `pnpm lint` is CI's.

## Remainder, still open on this card

- **Item 1, the lint arm at the compile door** (`os validate`): a field
compared with a `json` / `multiple` field, and now an ordering over one.
Serial after PR objectstack-ai#20265, which landed on `main` as `1207baf0` during this
stage, so item 1 is unblocked.

## Acceptance notes (noted, not filed)

- **Explain fidelity.** On driver-sql json/multiple predicates, explain
reports `visible=true` on rows where the enforced read answers 400 and
the by-id update 403. Measured on `17bd3187` before any change, cells
O1–O4, O8, O9, C3. Reach measured at `explain()`; the HTTP door was not
driven.
- **`having` `$between`.** It keeps a NULL group while `$gte` with
`$lte` excludes it, and numeric bounds compare NULL as 0 (both spellings
keep NULL for `[-10, 10]`). Reach is `engine.aggregate`; no REST door
was driven.
- **C2 at the REST data door**, measurement only, for the seat to file.
On the CRM dev server with driver-sql, better-sqlite3, `POST
/api/v1/data/crm_activity` with `duration_minutes: [500]` into a
`number` field, no RLS involved, answered 201. SQLite stored it as TEXT
`'[500]'`, and `GET` returns the string `"[500]"`. `[5, 7]` answers 400
`VALIDATION_FAILED`; a scalar `500` is stored as a real. Not fixed here.
- **Correction of review `5857896186` ③.** "driver-sql compares the JSON
text on the read" is false at `17bd3187`: driver-sql refuses with 400
(objectstack-ai#7398). The seat corrected it in `5858444849`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… or multiple field when it is authored (objectstack-ai#20346)

Part of objectstack-ai#19886
Clause-②: no

Stage 2f of objectstack-ai#19886: remainder item 1 of release `5860028604`, the
compile-door arm ruled A in `5857706935` ("in the lint rule, serial
after objectstack-ai#20158"). Claim `5860056842`. Base `de091b50`, head `509728de`.
The changeset declares `Clause-②: no (narrowing)`, BREAKING, following
the 2d / 2e precedent.

## What changes

`validateRlsPredicateEnforceability` (`packages/lint`) gets one arm. It
reports `rls-predicate-unenforceable` for every lowered field-to-field
comparison (`==`, `!=` and the four ordering operators, on either side,
under `!` too) in which either column is DECLARED to hold a list or an
object.

- **Judged by declared type, from the rule's own `ObjectGraph`.** The
class is read from the spec's value-shape classes, not from a list in
lint: `STRUCTURED_JSON_TYPES` (`json`, `composite`, `repeater`,
`record`, `location`, `address`, `vector`) and `isMultiValueField`
(`multiselect`, `checkboxes`, `tags`, and `select` / `radio` / `lookup`
/ `user` / `file` / `image` with `multiple: true`). These are the same
two that driver-sql builds its JSON-column set and its multi-valued test
from, so the lint arm and the driver's cross-field refusal share one
definition.
- **Every clause.** It covers `using` on `select` / `all` / `update` /
`delete` / `insert` and `check` on `insert` / `update` / `all` (Zone 2
item 3, decided from the runtime measurement below).
- **One defect, one finding.** The arm runs before the objectstack-ai#20158
engine-judge pass. A clause it refuses is never handed to `judgeFilter`,
and the engine judge answers none of these cells anyway (Zone 2 item 1).
- **Both doors.** `os validate` / `build` / `lint` and the metadata save
door already run this rule, so both refuse with the same sentence. No
gate is added, and no runtime seam. The 2d / 2e runtime refusals stay as
the backstop.

Files: the rule (+141), a new table-driven pin beside it, six refusal
rows plus three controls added to the CLI/runtime parity pin, and the
changeset.

## The refusal text (new)

Message, for a `using` clause (a real `os validate` finding):

> RLS using `record.status != record.tags` lowers, but compares a field
with a field that holds a list or an object: `record.status !=
record.tags`, where `tags` is declared `type: 'json'`. A column that
holds a list or an object is not one comparable value, on either side of
a field-to-field comparison, so the platform refuses the comparison
instead of evaluating it: every read this policy scopes is refused on
the SQL drivers (`INVALID_FILTER` / 400: driver-sql refuses a
cross-field comparison against such a column by its declared type), and
every by-id update or delete it scopes fails closed (`PERMISSION_DENIED`
/ 403). On an `insert`, `update` or `all` policy the same `using` is
also the write check whenever no applicable policy for that operation
declares a `check` (ADR-0058 D4): then every single-record insert and
by-id update whose record holds a list or an object in that column is
refused (`INVALID_FILTER` / 400) and stores nothing, because the write
check compares one value with one value and will not guess what a list
means.

For a `check` clause, the part after the colon is the write sentence
alone, followed by: "The policy reads as a comparison and behaves as a
refusal of every write that leaves a list or an object in that column."

Hint:

> A field compared with a `json` or `multiple` field has no row-filter
form: a row filter compares one value with one value, and cannot test
membership in a list another column holds. Compare with a single-valued
column, or with a literal or a `current_user` value — "one of these
values" is `record.status in ['open', 'pending']` or `record.owner in
current_user.org_user_ids` — or move the condition into a validation
rule or a hook.

The class sentence ("A column that holds a list or an object is not one
comparable value, on either side of a field-to-field comparison")
follows the wording of 2d's `arrayComparandError`. The prescription
follows the registered entry
`cel-predicate-one-value-comparand-refused`. At authoring time the field
names are the author's own text, so nothing is withheld.

## Zone 2, measured first

**1. Is (a) still silent after objectstack-ai#20265?** Yes, at both doors, and the
engine judge refuses none of it. The cell table was written to disk
before any edit (`cells-before.json`):
- 400 cells: `==`, `!=`, `!(==)`, `>` and less-or-equal; five
list-holding columns (`json`, `address`, `multiselect`, `multiple`
lookup, `multiple` user); both operand orders; eight clause/operation
pairs.
- Doors: the real CLI (`node packages/cli/bin/run.js validate --json`)
and the runtime save door (`saveMetaItem('permission')` over a real
`ObjectQL` with the sqlite-wasm driver).

| tree | leak cells (400) | scalar controls (48) | firing controls (16)
|
|---|---|---|---|
| `de091b50` (base) | 400 clean at the CLI, 400 accepted at the save
door | 48 clean / accepted | 10 refused at both: the list literal on all
8 clauses, the engine-judge text operator on select/all `using`; 6
clean, the engine judge on other clauses, as designed |
| `509728de` (head) | 400 refused at both, one finding each, the same
sentence at both doors (400 / 400) | 48 clean / accepted | unchanged |

**2. Declared type.** The judgement uses the graph's declared `type` and
`multiple`, never a value. An object outside the stack or with no field
map is not judged; an undeclared name is the unknown-field finding alone
(pinned).

**3. Which clauses.** The runtime refuses every clause, so the arm
covers every clause. Measured at `de091b50` through the real
`SecurityPlugin` + `ObjectQL` + driver-sql (better-sqlite3), for
`record.status != record.tags`, its mirror, `==` against `json`, and
`!=` against a `multiple` lookup:

| clause / operation | action | answer (36 of 36 leak cells) |
|---|---|---|
| `using` on select, all | `find` | `INVALID_FILTER` / 400 |
| `using` on update, delete | by-id update / delete |
`PERMISSION_DENIED` / 403, nothing changed |
| `using` on insert, all (standing in as the check) | insert |
`INVALID_FILTER` / 400, nothing stored |
| `check` on insert, update, all | insert / by-id update |
`INVALID_FILTER` / 400, nothing stored |

The scalar control `record.status != record.reviewer` was admitted and
enforced on all 9 pairs.

**4. Producer census (narrowing).**
- objectstack at `509728de`: 187 string literals under a `using` /
`check` / `condition` key (111 distinct), in `packages/` and
`examples/`, non-test. 2 lower to a `{ $field }` comparison. Both are
scalar-to-scalar: the showcase hook condition `record.spent >
record.budget` (currency) and a doc-formula script's `record.a >
record.b`. Neither is an RLS or sharing predicate, and 0 compare with a
list-holding field.
- cloud `main` `96eb092`: 3 occurrences, 0 `{ $field }` comparisons.
- The real `os validate` over `app-crm`, `app-multi-package`,
`app-showcase` and `app-todo` at head: 0 `rls-predicate-*` findings. 0
over-refusals.

## Tests (head `509728de`, every run through `os-verify-lock.sh`)

- `@objectstack/lint`, full package: 112 files / 4640 tests pass. The
new pin has 336 tests: 224 cells (8 clause-operation pairs × 7 operators
× 2 classes × 2 orders), 16 declared classes, 88 scalar controls on
every clause, and the arm's own properties. `typecheck`: exit 0.
- `@objectstack/cli` `unit` project: 229 files / 3251 pass.
`integration` project, the parity pin (touched by this diff, so run
locally): 30 / 30. `typecheck`: exit 0.
- `@objectstack/metadata-protocol` (the save-door gate): 189 files pass,
3 skipped; 2728 tests pass, 19 skipped.
- `@objectstack/plugin-security`, the unchanged 2d / 2e backstop pins
(read only): 150 / 150.

**Ablation** of the committed arm (`fdda49e5`; the rule's blob is the
same at head). The mutation replaced the arm's call with an empty list
through `scripts/ablation-replace.mjs` (anchor 1 to 0, marker 0 to 1,
blob changed). `pnpm --filter @objectstack/lint build` then emitted JS;
its DTS step exited 1 on the now-unused helper (TS6133).
`ablation-dist-preflight.mjs @objectstack/lint` found the marker in all
4 built entries.
- The new lint pin: **244 red**, 92 green. Red: every refusal cell and
property. Green: the controls, the table-size check, and the not-judged
and unknown-field cases.
- The CLI parity pin: **6 red**, exactly the six new rows; 24 green.
- Restored with `git checkout HEAD -- ABS_PATH`: blob equal to HEAD,
`git diff HEAD` empty. Rebuilt with exit 0, and the preflight `--absent`
was clean with a clean tree.

## Gates

Re-derived on the actual paths with `dispatch-gates.mjs --repo
objectstack-ai/objectstack`, all run at head, exit codes recorded before
reading. `--ran` reconciliation: **61 derived, 60 run, 1 NOT MEASURED, 0
UNRUN**. The two families new over the dispatch lead are
`check:cli-test-child-env` and bare `check-issue-citations.mjs`, both
run, both exit 0.
- NOT MEASURED: `check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT
MET. Eight packages outside the built closures (studio, client-react,
embedder-openai and five more) have no `dist/`, and the gate needs a
whole-tree `pnpm build`. As a proxy, both of lint's `require` entries
(`dist/index.cjs`, `dist/runtime.cjs`) load.
- ADR-0087 disposition: `not-required (already-registered
cel-predicate-one-value-comparand-refused)`. That entry already names
this exact class in its `surface` and carries its prescription in its
`replacement`. This PR moves where the registered class is refused (run
time to authoring time) and adds no class, no prescription and no
stored-metadata rewrite. `check-adr-0087-registration` passes. The
objectstack-ai#20158 precedent (`no-migration-prescription`) did not fit: this
changeset carries a "What to change" line.

## Acceptance notes

- **The stage 2d changeset's "Not changed" sentence.** It says a field
compared with a `json` or `multiple` field "is not reported at authoring
time". That no longer holds. This PR's changeset states the delta rather
than editing an unreleased changeset, as 2d did for 2a.
- **The sharing-rule twin (same class, another surface).**
`sharingRules[].condition` `record.status != record.tags` and
`record.reviewers == record.status` both pass the real `os validate` at
head, while the firing control `record.status == ['a', 'b']` in the same
run is reported. The arm belongs in
`validate-sharing-rule-enforceability.ts`, which is outside this claim's
surface (other `packages/lint` files are fenced). Its runtime answer on
the sharing path was not measured.
- **Other cross-field classes driver-sql refuses by declared type.**
driver-sql's `crossFieldComparisonClass` refuses these too, and they are
a different class from a list. A text column compared with a number
column, a single-valued `image` column or a `formula` field all pass `os
validate`. Measured through the real plugin-security on driver-sql for
text against number and text against image: the `using` read answers
`INVALID_FILTER` / 400, and the `check` insert is admitted and stored.
The formula cell was not measured at run time.
- **driver-memory** still reads a `{ $field }` comparand as a literal on
a read (objectstack-ai#15104, frozen; pointer `5857707647`). Unchanged here.

## What remains on objectstack-ai#19886

The release's remainder list held item 1 only, and this PR closes it. It
is `Part of` rather than `Fixes` because the sharing-rule twin above is
the same authoring-door gap on a sibling surface. Whether it stays on
this card or goes to its own card is the seat's call. If it moves off
the card, the first line can take the closing keyword instead.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants