fix(core): the JSON-column refusal reads true on every face and reaches a REST caller whole - #21213
Conversation
…under the REST bound The withheld message named driver-sql's storage and its two wrong answers, untrue where the engine's per-aggregation filter and driver-memory print it, and ran to 748 characters, so the REST envelope cut it before the any-of example ended and before the sentence saying the field and the operator were withheld. One reason, true on every face, now serves both texts; the message is 461 characters. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…d face-neutral Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…lope on every dialect cell Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…d the whole-message wire Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…n, the bare spelling's as = Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…nder the REST bound Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 31 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 d30af816a70872f385ecf4c9c7dcee87a46a8945 && git checkout d30af816a70872f385ecf4c9c7dcee87a46a8945
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 862f12c0b9b36a6192f6ffe55f4052fa7dbaf34a 892092904e52f682c650166856a9d210ba240c39 && git checkout -B drift-repro 862f12c0b9b36a6192f6ffe55f4052fa7dbaf34a && git merge --no-ff 892092904e52f682c650166856a9d210ba240c39
node scripts/docs-audit/affected-docs.mjs --json 862f12c0b9b36a6192f6ffe55f4052fa7dbaf34a |
…a null comparand needs
A null comparand ({ f: null }, $eq: null, $ne: null) on a multi-value or
JSON field is refused with the same text, and $contains cannot express a
no-value test. One constant clause names $null and $empty; the message is
486 characters, still whole under the REST bound.
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
|
Generated by Claude Code |
…ve-db-isolation USE needle Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #21067 (body and all six comments, the two os-dev-reports included), PR #21213 (body, nine-file list, net diff against Check-runs on the head, read at 2026-10-01T18:04Z: all seven required contexts ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Reviewed and written at 2026-10-01T18:06Z. Generated by Claude Code |
Fixes #21067
Clause-②: no
What this changes
jsonColumnOperatorRefusalText(packages/core/src/utils/json-column-operator-refusal.ts) is the one builder of theINVALID_FILTER/ 400 refusal a filter gets for a scalar comparison or text operator on a multi-value or JSON field. It is rewritten once, for both of the card's reasons together (seat 2's carried note folded in: one rewrite, not two passes):/datadoor cuts a 4xx message of 500 or more to 499 plus an ellipsis, so the wire ended…Refused rather than compiled because the answ…, and no caller read the sentence saying the field and the operator were withheld. It is now 486 characters, one constant text.driver-sql's storage ("a field this driver stores as a JSON TEXT column") and the two wrong answers SQL used to give. Both are untrue on the engine's per-aggregationfilterand ondriver-memory, which print the same text. The reason is now the field's declaration:it aims a scalar comparison or text operator at a multi-value or JSON field, which it cannot test for one member.{ f: null },$eq: nulland$ne: null, which ask whether the field has a value;$containscannot express that. One constant clause, with no branch on the comparand, names the presence spellings:For no value, use "$null" or "$empty".Both answer on a multi-value or JSON field on every face (they are outside the refused set), and the REST pin now proves$null: true,$null: falseand$empty: trueanswer on both thewhereand the per-aggregation faces.The new message, in full:
The diagnostic (server log, and the author-disclosed wire text, see below) shares that reason, names the operator in it (
it aims "$in", a scalar comparison or text operator, at …; the bare spelling's operator as=, as before), names the field, and spells the remedy with the field's name, presence clause included. It drops the same storage and SQL history. OnerefusalReason()and onecontainsRemedy()(which ends inPRESENCE_REMEDY) serve both texts, so the two cannot drift. The SQL mechanism and the measured wrong answers stay in the builder's docblock.Unchanged:
code,status, the refused operator set (JSON_COLUMN_INCOMPATIBLE_OPERATORS, 27 spellings), the$containsremedy, no new export, no new code.Faces. Re-derived at
cb45469eby name and by text: every face that prints this sentence calls the builder, and none builds its own copy. They aredriver-sqljsonColumnOperatorError(sql-driver.ts),objectqlhaving-filter.ts(the judgment and the per-row backstop, three call sites), anddriver-memoryjsonStoredFieldOperatorError(filter-refusal.ts). No face file changes except one stale docblock insql-driver.ts.driver-turso'sremote-transport.tsanddriver-mongodbshare phrases with other refusals, not this sentence. No docs page or skill quotes it (content/docs/**,skills/**,docs/**,apps/docs/**, by builder name and by seven distinctive phrases: zero hits).Measured, before and after
BASEcb45469eerror, SQLite and live PostgreSQL 16.14,mapDataErrorRefused rather than compiled because the answ…owners,$inPOST /api/v1/data/:object/querythrough the realSecurityPluginRefused rather than compiled beca…mapDataError)A dispatch hypothesis this falsified (H5)
The dispatch read the diagnostic as server-log text the envelope never bounds. Measured, it is also a wire text.
driver-sql's'author'provenance arm (resolveWithheldFilterRefusal) swaps the refusal for the diagnostic when the predicate is the caller's own, andplugin-securitymarks the caller's verbatimwhere'author'(security-plugin.ts, themarkFilterSubtreeProvenance(callerWhere, 'author')call). ThroughPOST /api/v1/data/:object/querywith a realSecurityPlugin, a member caller sending{ owners: { $in: ['u1'] } }received the diagnostic: cut to 500 atBASE, whole (377) atcaf0e3c7, before the presence clause raised it to 402. That was a one-off measurement file, run twice and not committed; theBASEleg rebuiltcore'sdist/with the old text and restored it, with dist preflight proofs both ways.The diagnostic was in the rewrite already, because its reason clause was the same untrue
driver-sqlmechanism seat 2's note names. All four conditions of the bounded in-place fix hold: same defect class (a refusal cut at the envelope), a mechanical fix in the shape triage pinned, the claimed file, and the same gate families. So it is fixed here, not filed. Its content is kept: field and operator named, remedy with the field's name.Pins
New, compared with the builder's output and the bound's own function (
truncateClientMessage/mapDataErrorfrom@objectstack/types), never with a copied sentence or a retyped 500.CLIENT_MESSAGE_MAXitself is module-private inpackages/types/src/data-error-classification.ts, so it is not imported;truncateClientMessageis already exported there forrest, and nothing new is exported. A sibling that rewords nothing and only calls the builder cannot flip these.packages/core/src/utils/json-column-operator-refusal.test.ts: re-captured hashes and lengths. For every refused spelling,truncateClientMessage(message)returns it unchanged. The message carries the one-member, any-of and no-value remedy and ends with the withheld sentence. Neither text names a storage form or a backend's wrong answer. The diagnostic gives the same reason with the operator named.packages/drivers/driver-sql/src/sql-driver-json-column-refusal-wire-bound.test.ts(new,DIALECT_CELLS): for 14 operators plus bare equality, on a multi-value lookup, atagsfield and ajsonfield,mapDataError(err).body.errorequals the shared message (unmarked, carrying the presence clause) and the shared diagnostic (author-marked), with the remedy spelling. SQLite always; PostgreSQL and MySQL in CI'sTemporal Conformance (live PG + MySQL)job, which runs this package's whole suite with both URLs set.packages/rest/src/aggregation-filter-json-column-refusal.test.ts: throughPOST /api/v1/data/:object/query, thewheretwin's body equals the builder's message whole, not just the per-aggregation face's (toBe, plus the any-of remedy, the presence clause and the withheld sentence), on the null-comparand rows too; three new controls show$null: true,$null: falseand$empty: trueanswer with equal counts on both faces.Flipped, each to the new substance:
sql-driver-json-column-operator-refusal.test.ts:'JSON TEXT column'becomes the operator-named reason, and the remedy is asserted with the field's own name.sql-driver-target-field-provenance.test.ts: the class fragment that survives redaction,'JSON TEXT column', becomes'at a multi-value or JSON field'.sql-driver-json-column-refusal-shared-text.test.ts: the docblock's "did not change by one byte" claim.ADR-0112
codeplusstatusassertions are untouched everywhere.Reverse verification
At
fefb6e1f,BASE's builder was written to disk (tree only, never staged) and its landing checked by grep: old text 1, new 0. Then:sql-driver-json-column-refusal-wire-bound.test.ts(SQLite plus live PostgreSQL 16.14): 90 failed (45 per cell), 1 skipped (MySQL). The red direction, as expected.Restored with
git checkout HEAD -- PATHunder anEXIT INT TERMtrap. The blob is2f17d7f3, equal to HEAD's, andgit diff HEADis empty.Verification (on
d352319a, the final head: the branch, one merge oforigin/main0d421041, which touches none of these packages, and the presence-clause commit)The targeted files below ran on
d352319a, withOS_TEST_POSTGRES_URLset to a private PostgreSQL 16.14:aggregation-filter-json-column-refusal: 136 passed, 68 skipped (MySQL)memory-20444-*andmemory-21066-*: 125 passedengine-aggregate-filter-json-column-refusal,engine-aggregate-filter,engine-aggregate-filter-array-membershipandengine-cascade-delete-multivalue-probe: 263 passedFull suites, on the pre-merge commits (before the presence clause; that commit touches only the builder, its unit pin and the two wire pins above, which were re-run):
@objectstack/corelocal: 74 files, 2107 passed@objectstack/driver-sql, SQLite: 208 files passed, 11 skipped; 3469 passed, 192 skipped@objectstack/restlocal: 255 files, 4834 passed, 301 skippedTypecheck:
core,driver-sqlandrestall pass, andtsc --listFilesconfirms the four touched driver-sql test files are in its program.Gates (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived and re-run ond352319a): 65 derived, 65 exit 0, 0 NOT MEASURED.--ranwith exit codes reconciles to 65 accounted, 0 unrun.check:dual-build-cjs-loadsmeasured this time: 105 require entries across 66 packages load. The first run on8450f66bwas exit 3PREREQUISITE NOT MET, since a whole-repo build was missing then.check:driver-conformance, before the first edit and after the last commit: 50 covered, 0 DEBT, 0 exempt; dialect axis 8 suites, 0 in the DIALECT ledger. The ledger did not move.Lint, narrowed:
--format jsonreports every listed file with 0 warnings, so none was ignored.d352319a, the 8.tsfiles of this diff were linted, with 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot move the verdict on any untouched file.The repo-wide
pnpm lintis CI's to run.Patch round 1 (
89209290).Lint & Repo Gateswas red atd352319aonpnpm check:live-db-isolation:packages/rest/src/aggregation-filter-json-column-refusal.test.ts:258 use names the literal "$null". The fix is test-only: the presence clause is asserted in two pieces. On89209290:pnpm check:live-db-isolation: exit 0, 34 live-server files scanned.check:dual-build-cjs-loadsgave exit 3 PREREQUISITE NOT MET until another family built the missingdist/, then exit 0 on re-run.--ranreconciles to 65 run, 0 NOT MEASURED.check:live-db-isolationis a declared WIDE-population family, so per-card derivation never names it.Siblings that print this sentence (both landed before this PR; seat edit)
Both siblings this PR named as later landers merged first, so they are earlier landers now:
$containsmatches a substring instead of a member,$ninfails open, and the refused families compile over the serialized array (the orphaned #20987 remote item) #21178 →862f12c0b(PR fix(driver-turso)!: the remote filter compiler refuses the JSON-column family and answers $contains by membership (#21178) #21208): thedriver-tursoremote face. It prints the builder's output and pins it by equality with the builder, so this rewrite does not flip it.domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula,having); the memory reference matcher retires (D6) #20822 group 3b →e18fea6dc(PR fix(objectql,driver-mongodb,formula): having compiles the whole-day bound it is handed; $contains asks membership on a JSON-stored field (#20822 group 3b) #21196): newhaving-filter.tscallers of the builder.This branch is behind both. The merge queue rebuilds it onto
main, so their pins run against this text in the merge group.Acceptance notes
$in,$startsWithand bare equality). From 27 characters the cut takes the presence clause first, and from 37 the any-of example; the one-member remedy and both names come before both. Field names declare no maximum length, so no text that repeats the name can be bounded. The withheld message, the card's subject, is constant and bounded.check:live-db-isolation's statement-head needle matches the verb USE followed by a quoted operand (STATEMENTinscripts/check-live-db-isolation.mjs). It read the assertion string'For no value, use "$null" or "$empty".'atpackages/rest/src/aggregation-filter-json-column-refusal.test.ts:258as a MySQL USE naming a database"$null". Fixed in89209290by asserting the clause in two pieces; the gate and the product sentence are unchanged.packages/objectql/src/engine-cascade-delete-multivalue-probe.test.tskeeps a test double whose refusal paraphrases the old wording ("is stored as a JSON TEXT column"). It asserts onlycodeandstatus, so it is not a pin of this text. Left as is.rest's own PostgreSQL and MySQL cells ofaggregation-filter-json-column-refusal.test.tsare still provisioned by no CI job (its header says so). The PostgreSQL wire pin that CI does run is the newdriver-sqlfile.driver-sql's temporal files were not run against it). It was stopped afterwards.Generated by Claude Code