Skip to content

fix(plugin-security): the RLS write check refuses an operator the read refuses on a declared JSON-stored column (#21254) - #21317

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21254-rls-write-json-operator-refusal
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21254-rls-write-json-operator-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21254
Clause-②: no

The row-level write check now refuses an operator the read refuses on a column the object declares JSON-stored, with the read's INVALID_FILTER / 400 and the read's words. The operator set is @objectstack/core's JSON_COLUMN_INCOMPATIBLE_OPERATORS plus implicit equality. The core module's "two faces, one rule" gains a third face. storedFormCheckJudge imports the set and jsonColumnOperatorRefusalText and copies neither. Its error constructor is its own, as each face keeps its own, and it carries the read's envelope. There is no packages/core edit and no authoring-door refusal (triage ruling 5942971732).

Premise, re-measured on main at 5a9292e6f

Harness: ObjectQL.insert + SecurityPlugin, a member resolving a permission set, using and check the same predicate. tags is declared tags and meta is declared json. Both driver families (driver-sql on better-sqlite3, and driver-sqlite-wasm) gave the same answer in every row. The read is a system-stored row of the same value, read by the member under the same policy.

check member writes write on main read, same policy write on this branch
record.tags != 'x' ['x'], and 'x' admitted, stored ["x"] 400 INVALID_FILTER 400 INVALID_FILTER
!(record.tags in ['x']) ['x'] admitted, stored ["x"] 400 400
record.tags == 'x' ['x'] 403 400 400
record.tags in ['x'] ['x'] 403 400 400
record.tags > 'a' ['x'] 400 (the evaluator's list-under-ordering refusal) 400 400 (this refusal's words)
record.meta != 'x' / record.meta == 'x' a scalar admitted, stored 400 400
record.tags.contains('x') / !record.tags.contains('x') ['x'] / ['y'] admitted or 403, by membership shown or hidden, alike unchanged
record.tags != null ['x'] admitted shown unchanged
record.title != 'x' (text) 'y' / 'x' admitted / 403 shown / hidden unchanged

On this branch the refused write's message is byte-identical to the read's (pinned against jsonColumnOperatorRefusalText(...).message, imported). The full diagnostic names the field, the operator and the policy, and it goes to the server log, which the message points to.

⚠️ Two deviations the PM should read

  1. Rows 3 to 5 change their answer. They are not admitted before or after, and nothing is stored. The pin says "the two rows that already refuse are unchanged". The ruling's rule refuses "an operator in the core set, on a declared JSON-stored / multi-valued column, with the read side's code and status", and $eq, $in and $gt are in that set. So record.tags == 'x' and record.tags in ['x'] move from 403 to the read's 400. record.tags > 'a' keeps 400 INVALID_FILTER with core's words. Keeping 403 there would need either a subset of the set with $eq / $in exempted (a second copy of the rule), or a verdict that depends on the record. I took the ruling's rule as written and read "unchanged" as "still refused, nothing stored". If the 403 must stay, that is a ruling question.
  2. File surface widened by one file: security-plugin.ts (+21/-1). It is the judge's only caller. The core message says "the full diagnostic is in the server log". Without a log line on this face that sentence would be false. The judge carries the diagnostic on the error under a symbol, which keeps it off the wire, the way @objectstack/formula's comparison-class refusal does. The gate's existing satisfiesCheck catch logs it beside the policy name. Conflict check: none of the 13 open PRs touches packages/plugins/plugin-security/src/security-plugin.ts, read at this write.

Changes

  • packages/plugins/plugin-security/src/rls-check-stored-form.ts:
    • declaredJsonStoredColumns: the declared multi-valued columns plus the spec's STRUCTURED_JSON_TYPES. This is the same population the evaluator already asks $contains membership of on the same declaration, so no new classification.
    • findJsonColumnCheckRefusal: a pure walk over the parts as compiled, using the traversal of objectql's per-aggregation gate. It takes the policy name from the compiler's marks.
    • The refusal error, with code INVALID_FILTER, status 400 and httpStatus 400.
    • jsonColumnCheckRefusalCarriedBy.
    • storedFormCheckJudge finds the refusal once and throws it for every image before any is evaluated, so the verdict comes from the declaration and never from a record.
  • packages/plugins/plugin-security/src/security-plugin.ts: logs the carried diagnostic (deviation 2).
  • packages/plugins/plugin-security/src/rls-check-stored-form.test.ts:
    • The card's table at the engine door on both drivers: the write, the stored row, the read's envelope and message, and the log line.
    • A by-id update and a predicate update under a check-only policy.
    • Controls: contains and its negation, presence, and a scalar column.
    • Unit pins beside the module: every operator in the imported set, implicit equality for any comparand, depth and part paths, the membership and presence pair left alone, no declaration meaning no refusal, and the diagnostic kept off the wire.
  • packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts: fixture triage of the stage-2e pins this rule supersedes.
    • The nine scalar cells on declared JSON columns (tags / meta are json, watchers is a multiple lookup) were compared before. They are now refused 400, as the read is.
    • The null control and the stage-2a equality control (C3) move to the text column status, where the evaluator still decides, so their subject stays covered.
  • .changeset/21254-rls-write-check-json-column-operator-refusal.md: @objectstack/plugin-security patch, Clause-②: no. No export of any package changes; rls-check-stored-form is not on the plugin's exports.

Verification (at f9d5020aa, after merging origin/main 393ae878d)

origin/main now carries the core module's field-class parameter. This face calls the text builder with three arguments, so it gets the default class, whose words are unchanged. The workspace was rebuilt after the merge.

  • pnpm --filter @objectstack/plugin-security test: 157 files passed, 3450 passed, 23 skipped. Before the stage-2e triage the same run had 22 red cells in that one file, listed above.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0, including check:test-typecheck (0 files / 0 errors in the test-layer ledger).
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 63 commands. On the rebuilt workspace all 63 ran at this head with exit 0, and --ran reconciled "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line carries its exit code). On the first pass, before the full build, check:dual-build-cjs-loads and check:i18n exited 3 (PREREQUISITE NOT MET). That was a missing dist/, not a finding.
  • ESLint, narrowed to the 4 touched source files with --no-inline-config --format json: 4 files, 0 errors, 0 warnings. The config never enables type-aware linting (its own header says so), so this diff cannot move the verdict on an untouched file.
  • Not run locally, declared to CI: the full lint farm, Test Core, Dogfood, Temporal Conformance and Build Core.

Ablations (run at the committed head 9e6b96b95; scripts/ablation-replace.mjs proved each mutation on disk and each restore: blob equals HEAD, and git diff HEAD is empty)

The subject is imported relatively (./rls-check-stored-form.js) inside the package's own src, so no dist/ is involved. All three ran vitest run src/rls-check-stored-form.test.ts.

leg mutation result what went red
refusal removed the judge's if (refusal) throw … line deleted 23 failed / 81 passed every refused cell on both drivers (rows 1 and 2 included), the update pin, and the 3 unit refusal pins
refusal applied to the membership pair $contains / $notContains added to the walk's refusal test 26 failed / 78 passed every contains / negated-contains control, this card's and the existing multi-value ones
refusal applied to non-JSON columns the walk's !jsonStored.has(key) skip removed 44 failed / 60 passed every scalar-column control (title != 'x', title in ['x'], title == …), and the unit pins on depth and leave-alone

Docs

content/docs/** (outside releases/) and skills/** were searched for sentences on how a row-level check evaluates operators on multi-valued or JSON columns. That covers permissions/rls.mdx, skills/objectstack-data/rules/security.md, and every "JSON-stored", "multi-value field" and $contains page. No page states it, so no sentence becomes false and no doc is edited.

Acceptance notes

  • A fourth in-process face still answers the old way: security.explain's record attribution. It evaluates the same policies with the evaluator and the declared columns, but without this refusal. It answers visible: true, decidedBy: rls for a record under record.tags != 'x', record.meta == 'x' or !(record.tags in ['x']), while the find under the same policy answers 400 INVALID_FILTER. This was measured in-process through the registered security service on driver-sql at 5a56607ab, a merge of this branch that predates the latest main; the HTTP route was not driven. It is reported to the seat as a finding and is not touched here.
  • Refusal order. On the write face this refusal is judged before the evaluator's own shape refusals. A policy that is malformed and also aims a refused operator at a JSON column gets this message. Both answers are INVALID_FILTER / 400. The read faces judge the comparand shape first.
  • $notContains reaches the check only as a filter. The CEL lowering spells a negated contains as $not over $contains. The engine-door control uses that spelling, and $notContains itself is pinned at unit level.
  • No driver-memory or driver-mongodb measurement. The pins are on the two SQL families the card measured.
  • record.tags == null / != null lower to the presence spelling and are unchanged.

Generated by Claude Code

claude added 4 commits October 2, 2026 02:54
…d refuses on a declared JSON-stored column

The write check now refuses, with the read's INVALID_FILTER / 400 and
@objectstack/core's words, an operator in JSON_COLUMN_INCOMPATIBLE_OPERATORS
(or implicit equality) aimed at a column the object declares JSON-stored,
so a policy whose read is refused no longer admits a write. The gate logs
the withheld diagnostic beside the policy's name.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…nst the read, on two driver families

The card's table at the engine write door beside the read the same policy
scopes, the membership-pair, presence and scalar-column controls, a unit
pin of the declaration-only verdict, and the stage-2e fixtures re-judged:
a scalar on a declared JSON-stored column is now refused by the
declaration, and the null / equality controls move to a text column where
the evaluator still decides. Plus the changeset.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…s-write-json-operator-refusal

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…s-write-json-operator-refusal

Brings in the core JSON-column refusal's field-class parameter (default
unchanged), which this face imports.

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

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 4 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 — 16 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 1371dc980cdf0d3128bee4a5441f2c6bec18f008 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e9476ad4d351e31c90c1d7a07d37fe1e33789f04 — the merge of head f9d5020aadac4786209ea83410b7a6e263775dfc into base 1371dc980cdf0d3128bee4a5441f2c6bec18f008, 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 e9476ad4d351e31c90c1d7a07d37fe1e33789f04 && git checkout e9476ad4d351e31c90c1d7a07d37fe1e33789f04
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1371dc980cdf0d3128bee4a5441f2c6bec18f008 f9d5020aadac4786209ea83410b7a6e263775dfc && git checkout -B drift-repro 1371dc980cdf0d3128bee4a5441f2c6bec18f008 && git merge --no-ff f9d5020aadac4786209ea83410b7a6e263775dfc

node scripts/docs-audit/affected-docs.mjs --json 1371dc980cdf0d3128bee4a5441f2c6bec18f008

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 05:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 05:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 97239c3 Oct 2, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21254-rls-write-json-operator-refusal branch October 2, 2026 05:59
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/l tests tooling

Projects

None yet

2 participants