Repository navigation
fix(plugin-security): the RLS write check refuses an operator the read refuses on a declared JSON-stored column (#21254) - #21317
Conversation
…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>
📓 Docs Drift Check10 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
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
… Node, re-arming the reroute probe CONTROL leg (objectstack-ai#21356) Fixes objectstack-ai#21308 Clause-②: no ## What this changes `tsx` 4.23.15 moved its ESM-API registration out of a `dist/` chunk and into `dist/esm/api/index.cjs`, two directories deeper. It did not rebase three file-relative specifiers. On a Node without tsx's `module.registerHooks` path, the CommonJS build of `tsx/esm/api` then calls `module.register()` with a hooks URL that names `dist/esm/api/esm/index.mjs`, a file that does not exist. `@oclif/core`'s `ts-path` is the caller in this repo. It swallows the error, logs `Could not find tsx`, and stays on `dist/`. This PR adds a `pnpm patch` of `tsx@4.23.15` that points the three specifiers back at the files 4.23.14 resolved. No version moves. - `patches/tsx@4.23.15.patch`: new, written by `pnpm patch` / `pnpm patch-commit`. - `pnpm-workspace.yaml`: the `patchedDependencies` entry and its note, next to the existing `tsup@8.5.1` entry. - `pnpm-lock.yaml`: regenerated by `pnpm install`, not edited by hand. - `packages/cli/test/published-entry-node-env-source-reroute.test.ts`: a docblock pointer only. No leg, assertion or bound changes. ## Measured ### H0: CI's reading of the CONTROL leg is NOT MEASURED (log hosts unreachable) Both log hosts refused this session at CONNECT (`gateway answered 403`): - `productionresultssa14.blob.core.windows.net`, the job logs of all six `Test Core` shards of run 36964705223 on 6091136; - `results-receiver.actions.githubusercontent.com`, the run log zip. `node scripts/pm/ci-failure.mjs --run 36964705223` exited 2 with `job log NOT RETRIEVED`. What was measured instead is the mechanism that decides it. tsx 4.23.12, 4.23.14 and 4.23.15 all take the synchronous `registerHooks` path only on Node 22.22.3+, 24.11.1+, 25.1.0+ or 26+ (version table `[[22,22,3],[24,11,1],[25,1,0],[26,0,0]]`). That path never reaches the broken specifier. Node 22.22.3 was released on 2026-05-13. CI's `setup-node` asks for `node-version: '22'`, and the dev containers run v22.22.0. Same tree (6091136), unpatched tsx: | Node | `published-entry-node-env-source-reroute.test.ts` | |---|---| | v22.22.0 | 1 failed (CONTROL), 4 passed | | v22.22.3 (official tarball, sha256 checked against `SHASUMS256.txt`) | 5 passed | So CI arms the leg if its runner resolved `'22'` to 22.22.3 or later. The runner's actual version is in the log this session could not read. ### H1: confirmed, with a version bound `DEBUG=oclif:config:ts-path` on v22.22.0, CONTROL preload, the test's own env (no `NODE_PATH`): ```text Could not find tsx. Skipping tsx registration for .../packages/cli. Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../tsx/dist/esm/api/esm/index.mjs' imported from .../tsx/dist/esm/api/index.cjs ``` Next it logs `Cannot find module 'ts-node'`, and `tsPath` returns the `dist/` path. In the tarballs, 4.23.12, 4.23.13 and 4.23.14 keep the `register()` call in `dist/register-*.cjs`, where it resolves. 4.23.15 inlines it into `dist/esm/api/index.cjs`, which grows from 1,298 to 16,770 bytes. The `.mjs` build of `tsx/esm/api` is unaffected. oclif reaches the `.cjs` build because it locates the module with `require.resolve`. ### H2: holds `npm view tsx dist-tags` gives `latest` as 4.23.15 (2026-09-20). There is no newer release, so no UP move (remedy d) exists. ### A second casualty of the same defect On v22.22.0 with unpatched tsx, `bin/run-dev.js` also lands on `dist/commands`. That file is the CLI's SOURCE entry, the one `runServe` and the other spawning tests launch (104 test files under `packages/cli` mention it). `tsx bin/run-dev.js --version` under `DEBUG=oclif:config:ts-path` logs `Could not find tsx` and never `Found source directory`. With the patch it logs `Found source directory for .../dist/commands at .../src/commands`. So on such a Node, source-entry tests have measured the build output rather than `src/` since objectstack-ai#21162. The same patch fixes this with nothing added. ## Why (b), and not (a) or (c) **(a)** pins tsx down to 4.23.14. That is a DOWN move and needs a ruling, and measurement does not make it the only sound remedy. Not taken. **(c)** reworks the CONTROL leg. To arm without oclif's own tsx registration, the child would need a test-only change to how it resolves `tsx/esm/api`: a `Module._resolveFilename` shim in the preload, or `--conditions=import` on the child. That fails the second acceptance criterion as measured. The absence legs do not load the preload, so on v22.22.0 with unpatched tsx, deleting the declaration leaves them green (row 4 below). Arming them too would put the shim in every leg, and then every leg measures a resolution no real install has. **(b)** patches the component that fails. The test's behaviour is unchanged, and both acceptance criteria hold on v22.22.0 and on v22.22.3. Four axes: - **Real business need:** measured. Two consumers break on Node below 22.22.3. One is this probe's CONTROL leg. The other is the source entry `bin/run-dev.js`, which is the larger one, because the CLI's spawning tests silently ran `dist/`. The published CLI is not reached: `bin/run.js` sets `settings.enableAutoTranspile = false`, so oclif never registers tsx there, and nothing in `packages/**` source imports `tsx/esm/api`. - **Long-term soundness:** the patch is a workaround for an upstream packaging defect, and it has a cost. It replaces one 16 KB minified line, which nobody can review by eye. The token comparison below is how to review it. The key names the exact version, so a tsx bump fails `pnpm install` (`ERR_PNPM_UNUSED_PATCH`, the same rule the `tsup@8.5.1` entry records), and the patch cannot outlive its reason silently. Retire it when a tsx release resolves the three paths itself, or when the toolchain's Node floor reaches 22.22.3. (c) would carry a test shim with no such tripwire. After an upstream fix it would become dead code that still makes every leg diverge from a real install. - **Making AI mistakes harder:** (b) gives the probe one meaning in every environment. Today the same file is green in CI and red in the dev containers, and three devs reported that red in passing, without a card, because it read as "environment". (c) adds a second resolution dialect inside a test fixture, which is the consumer-side tolerance that contract-first rules out. - **Startup focus:** no new gate, no new dependency, no version movement. Three tracked files, plus the lockfile the tooling wrote. ## CONTROL leg and reverse verification (Node v22.22.0 unless stated) | tree | tsx | declaration in `bin/run.js` | `development` / `test` legs | CONTROL (development, neutralised) | |---|---|---|---|---| | 6091136 (main) | unpatched | present | pass | FAIL: output is only `@objectstack/cli/17.6.0 linux-x64 node-v22.22.0` | | cc0c05b | patched | present | pass | pass: card signature present, exit non-zero | | cc0c05b | patched | deleted | FAIL x2: `expected '(node:...) [MODULE_NOT_FOUND] Warni...' not to contain 'Cannot find module './registry''` | pass | | cc0c05b, `packages/cli/node_modules/tsx` linked to the pristine 4.23.15 | unpatched | deleted | pass (vacuous: 1 failed, 4 passed) | FAIL | | 8569d5f (final) | patched | present | pass | pass (5 passed) | | 8569d5f, Node v22.22.3 | patched | present | pass | pass (5 passed) | `scripts/ablation-replace.mjs --delete` removed the declaration (anchor 1 to 0, blob `569e6b6f` to `40a368ce`). The same tool restored it and showed the blob equal to HEAD's, with `git diff HEAD` empty. The tsx link swap ran under an EXIT/INT/TERM trap, and `readlink` confirmed the restore. `bin/run.js` runs unbuilt, so no `dist/` step is involved. ## Reviewing the patch The patch replaces one minified line. Review it with this comparison, which splits the pristine and the patched file on commas and prints every token that differs: ```sh npm pack tsx@4.23.15 && tar xzf tsx-4.23.15.tgz node -e 'const fs=require("fs");const a=fs.readFileSync(process.argv[1],"utf8").split(","),b=fs.readFileSync(process.argv[2],"utf8").split(",");let n=0;for(let i=0;i!==a.length;i++)if(a[i]!==b[i]){n++;console.log("-",a[i].slice(-60));console.log("+",b[i].slice(-60))}console.log(n,"of",a.length,"differ")' package/dist/esm/api/index.cjs node_modules/.pnpm/tsx@4.23.15_patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6/node_modules/tsx/dist/esm/api/index.cjs ``` Output: ```text - se=[new URL("loader.mjs" + se=[new URL("../../loader.mjs" - new URL("esm/index.mjs" + new URL("../index.mjs" - essageChannel;R.register(`./esm/index.mjs?${_.randomUUID()}` + e.MessageChannel;R.register(`../index.mjs?${_.randomUUID()}` 3 of 514 comma-separated tokens differ ``` The `register()` specifier is the one that breaks things. The two `new URL(...)` entries feed tsx's `isTsxImport` set and carry the same wrong base. They are fixed in the same patch because they are the same defect, in the same file, with the same mechanical change. Both rebased targets exist on disk (`dist/esm/index.mjs`, `dist/loader.mjs`). No test here reaches those two entries, because no child in this suite passes a TypeScript preload ahead of an absolute tsx loader path. ## Lockfile `pnpm install` regenerated it. With the tsx patch hash normalised away, every removed line equals an added line, except for the three new `patchedDependencies` lines. 0 versions moved, 0 DOWN, 0 packages added or removed. Ten snapshot keys change only by the hash suffix: `tsx` itself, and the peer suffix of `tsup`, `postcss-load-config`, `fumadocs-mdx`, `vite` x2, `vitest` x2 and `@vitest/mocker` x2. `pnpm install --frozen-lockfile` passes on the final head. ## Verification - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 8569d5f derives 58 commands from 4 paths. All 58 were run with exit codes captured before any pipe, and all exited 0. `--ran` reports 58 derived, 58 run, 0 NOT-MEASURED. (In an earlier pass at 6a22902, `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET while sibling packages had no `dist/`. Once those were built it passed: 105 entry points across 66 packages.) - `@objectstack/cli` unit tier at 6a22902: 244 files, 3461 tests passed. - `@objectstack/cli` integration tier at 6a22902, two runs under the verify lock: 71 files, 607 tests passed, 1 skipped. The skip is the existing `it.skipIf` in `test/migrate-meta-default-range.test.ts`, a file this branch does not touch. - `pnpm --filter @objectstack/cli typecheck` at 6a22902: exit 0, `check:test-typecheck` included. - 6a22902 to 8569d5f is two merges of `main`: objectstack-ai#21317 (plugin-security), objectstack-ai#21335 (objectql, rest, plugin-auth), and two docs-only commits (objectstack-ai#21337, objectstack-ai#21343). None touches a CLI file, the lockfile, `pnpm-workspace.yaml` or tsx, so the CLI tiers were not re-run for them. The packages the merge touched (objectql, plugin-auth, rest) were rebuilt. Then the reroute file (5 passed on v22.22.0, 5 passed on v22.22.3) and all 58 gates were re-run on 8569d5f. - Not run here: `pnpm lint` (repo-wide, CI's run). **skip-changeset:** nothing published moves. `@objectstack/cli` ships only `dist`, `README.md` and `CHANGELOG.md`, so the test file is not published. `patches/`, `pnpm-workspace.yaml` and `pnpm-lock.yaml` are root files no package ships. The CLI's dependency range on tsx is unchanged. ## Acceptance notes - The dev containers run Node v22.22.0, while CI's `setup-node` floats on `'22'`. That gap is why the same file read red in three dev containers and green in `Test Core`. Observation only, no card. Carrier: none. - Upstream: whether tsx already has an issue or fix in flight was not read, because this session cannot reach `privatenumber/tsx` through the API. The retirement condition is in the `pnpm-workspace.yaml` note. - Published reach: `@objectstack/cli` depends on `tsx ^4.23.15` at runtime, so a customer on Node 22.0 to 22.22.2 installs the defective build. No published code path calls `require('tsx/esm/api')`, so no public entry point reaches it. Measured by a grep of `packages/**` source and by the published entry's `enableAutoTranspile = false`. - `packages/cli/README.md` and `packages/cli/package.json` belong to objectstack-ai#21310 and are not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21254
Clause-②: no
The row-level write
checknow refuses an operator the read refuses on a column the object declares JSON-stored, with the read'sINVALID_FILTER/ 400 and the read's words. The operator set is@objectstack/core'sJSON_COLUMN_INCOMPATIBLE_OPERATORSplus implicit equality. The core module's "two faces, one rule" gains a third face.storedFormCheckJudgeimports the set andjsonColumnOperatorRefusalTextand copies neither. Its error constructor is its own, as each face keeps its own, and it carries the read's envelope. There is nopackages/coreedit and no authoring-door refusal (triage ruling5942971732).Premise, re-measured on
mainat5a9292e6fHarness:
ObjectQL.insert+SecurityPlugin, a member resolving a permission set,usingandcheckthe same predicate.tagsis declaredtagsandmetais declaredjson. 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.checkmainrecord.tags != 'x'['x'], and'x'["x"]INVALID_FILTERINVALID_FILTER!(record.tags in ['x'])['x']["x"]record.tags == 'x'['x']record.tags in ['x']['x']record.tags > 'a'['x']record.meta != 'x'/record.meta == 'x'record.tags.contains('x')/!record.tags.contains('x')['x']/['y']record.tags != null['x']record.title != 'x'(text)'y'/'x'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.$eq,$inand$gtare in that set. Sorecord.tags == 'x'andrecord.tags in ['x']move from 403 to the read's 400.record.tags > 'a'keeps 400INVALID_FILTERwith core's words. Keeping 403 there would need either a subset of the set with$eq/$inexempted (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.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 existingsatisfiesCheckcatch logs it beside the policy name. Conflict check: none of the 13 open PRs touchespackages/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'sSTRUCTURED_JSON_TYPES. This is the same population the evaluator already asks$containsmembership 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.codeINVALID_FILTER,status400 andhttpStatus400.jsonColumnCheckRefusalCarriedBy.storedFormCheckJudgefinds 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:containsand its negation, presence, and a scalar column.packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts: fixture triage of the stage-2e pins this rule supersedes.tags/metaarejson,watchersis amultiplelookup) were compared before. They are now refused 400, as the read is.nullcontrol and the stage-2a equality control (C3) move to thetextcolumnstatus, where the evaluator still decides, so their subject stays covered..changeset/21254-rls-write-check-json-column-operator-refusal.md:@objectstack/plugin-securitypatch,Clause-②: no. No export of any package changes;rls-check-stored-formis not on the plugin'sexports.Verification (at
f9d5020aa, after mergingorigin/main393ae878d)origin/mainnow 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, includingcheck:test-typecheck(0 files / 0 errors in the test-layer ledger).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 63 commands. On the rebuilt workspace all 63 ran at this head with exit 0, and--ranreconciled "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-loadsandcheck:i18nexited 3 (PREREQUISITE NOT MET). That was a missingdist/, not a finding.--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.Test Core,Dogfood,Temporal ConformanceandBuild Core.Ablations (run at the committed head
9e6b96b95;scripts/ablation-replace.mjsproved each mutation on disk and each restore: blob equalsHEAD, andgit diff HEADis empty)The subject is imported relatively (
./rls-check-stored-form.js) inside the package's ownsrc, so nodist/is involved. All three ranvitest run src/rls-check-stored-form.test.ts.if (refusal) throw …line deleted$contains/$notContainsadded to the walk's refusal testcontains/ negated-containscontrol, this card's and the existing multi-value ones!jsonStored.has(key)skip removedtitle != 'x',title in ['x'],title == …), and the unit pins on depth and leave-aloneDocs
content/docs/**(outsidereleases/) andskills/**were searched for sentences on how a row-levelcheckevaluates operators on multi-valued or JSON columns. That coverspermissions/rls.mdx,skills/objectstack-data/rules/security.md, and every "JSON-stored", "multi-value field" and$containspage. No page states it, so no sentence becomes false and no doc is edited.Acceptance notes
security.explain's record attribution. It evaluates the same policies with the evaluator and the declared columns, but without this refusal. It answersvisible: true, decidedBy: rlsfor a record underrecord.tags != 'x',record.meta == 'x'or!(record.tags in ['x']), while the find under the same policy answers 400INVALID_FILTER. This was measured in-process through the registeredsecurityservice on driver-sql at5a56607ab, a merge of this branch that predates the latestmain; the HTTP route was not driven. It is reported to the seat as a finding and is not touched here.INVALID_FILTER/ 400. The read faces judge the comparand shape first.$notContainsreaches the check only as a filter. The CEL lowering spells a negatedcontainsas$notover$contains. The engine-door control uses that spelling, and$notContainsitself is pinned at unit level.driver-memoryordriver-mongodbmeasurement. The pins are on the two SQL families the card measured.record.tags == null/!= nulllower to the presence spelling and are unchanged.Generated by Claude Code