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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… 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>
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