Skip to content

fix(objectql)!: a no-operator object where a scalar field's value belongs is refused INVALID_FILTER / 400 on every driver (#20546) - #20744

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20546-no-operator-object-on-scalar
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20546-no-operator-object-on-scalar

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20546
Clause-②: no (narrowing)

What this changes

A plain object with no $-operator key where a scalar field's value belongs, for example where: { amount: { a: 1 } } on a number field, is now refused with INVALID_FILTER / 400. The refusal names the field, its declared type, the object's keys (never its values) and the path. It runs before any driver is resolved, on every driver, at the three positions the engine judges: where (object form and FilterArray sugar, on find / findOne / count / aggregate / update / delete and the judge-only judgeFilter), aggregations[i].filter, and having.

Landing site: the number-comparand door's walk, as a second arm. It adds no second traversal. Triage said: "If the same walk is the natural site, it lands serially after that PR, in the same walk. ⛔ No second traversal of the filter." PR #20545's walk (walkCondition in number-comparand-declared-type-door.ts) is the only filter walk the engine runs at all three positions with each column's declaration in hand. It already stood on the exact branch: a field spec with no $ key, which it stepped past (return kept(spec)). It now asks one question per field key before the number arm runs:

  • packages/objectql/src/no-operator-object-door.ts (new) holds the arm's classification (holdsScalarValues), its structure test (isNoOperatorObject) and its words. ⛔ Nothing in it walks a filter.
  • number-comparand-declared-type-door.ts: the walk's per-key resolver now supplies two facts, the number arm's meta and the column's scalar-valued type. The first refusal the walk meets is either arm's.
  • having-filter.ts: aggregatedRowColumnTypes reads each aggregated column's type off the query. aggregatedRowColumnClasses is now derived from it, so the class and the type are one reading of the query. The having arm needs the type because the text class lumps a json or lookup groupBy in with a real text column.
  • engine.ts: the having call passes the types; the other hunks are comments. PR fix(objectql,service-automation,runtime): the card's named warnings and endpoint hints state each decision in words instead of a tracker number #20738's warning-text region is untouched.

Which columns are judged (H3): a closed definition from spec's classes. SCALAR_FILTER_HEAD_TYPES (spec's published "stores one scalar value" set, derived from the ADR-0104 value classes; the #8371 dotted-head verdict reads the same set) with or without multiple: true, plus MULTI_OPTION_TYPES. The accepted side is never judged: relation types (lookup, master_detail, user, tree, single or multiple), structured-JSON types, file and media types (the #8371 carve-out: a legacy stored value is an inline object), formula (refused one door earlier, INVALID_FIELD), undeclared keys, and unknown types.

Before, measured on origin/main fbec216e2d

Through engine.find / engine.aggregate and POST /api/v1/data/:object/query (both doors answered alike). Three rows (amount 5 / 12 / 30; owner u1 / u2 / u1 with u1 in region NA; meta {a:1} / {a:2} / {b:1}). InMemoryDriver, SqlDriver on SQLite (better-sqlite3), SqlDriver on a live PostgreSQL 16.13:

position · filter InMemoryDriver SQLite PostgreSQL 16
where { amount: { a: 1 } } (number, the card) 200, no rows 400 INVALID_FILTER, the driver's words ("cannot be bound") same as SQLite
where { title: { a: 1 } } (text) 200, no rows 400, the driver's words 400, the driver's words
where single select, boolean, date, autonumber, multiple: true select, multiselect, tags 200, no rows 400, the driver's words 400, the driver's words
where { $not: { amount: { a: 1 } } } 200, every row 400 ("not one this driver evaluates") same
where { $or: [{ amount: { a: 1 } }, { amount: 30 }] } 200, 1 row 400 400
where sugar [['amount', '=', { a: 1 }]] 200, no rows 400 400
where { amount: {} } 400, the #5240 words 400, the #5240 words same
aggregations[1].filter { amount: { a: 1 } }, { title: { a: 1 } }, { amount: {} } 200, count 0 200, count 0 200, count 0
having { total: { a: 1 } } (a sum), { title: { a: 1 } } (a groupBy), { total: {} } 200, no group 200, no group 200, no group
control: where { owner: { region: 'NA' } } (lookup; master_detail and a multiple lookup alike) 200, no rows 400, the driver's words same
control: where { meta: { a: 1 } } (json) 200, 1 row (deep equality) 400, the driver's words same
control: where { amount: { $gt: { $field: 'cap' } } } 200 200, 2 rows 200, 2 rows

After, the same run on this branch

Every non-control row above answers 400 INVALID_FILTER in the engine's words on all three drivers, at the path the object sits at (where.amount, where.$not.amount, where.$or[0].amount, aggregations[1].filter.amount, having.total). No read of the object runs. Every control answers exactly as before: the lookup, master-detail, multiple-lookup and JSON filters reach the driver as written, and so do the file field, the $field reference, the undeclared key and the id key. Example of the words:

find('rp_ledger_20546'): filter on 'amount' puts an object with no operator key (keys "a") at where.amount, where a value of the declared number field 'amount' belongs. An object with no "$" operator is filter structure, not a value: beneath a field it is a nested-relation condition, which only a relation field (lookup, master-detail, user, tree) can carry, or a whole-value match, which only a JSON-bearing field can hold. A number column holds scalar values — one, or a list of them — so no record can match an object there, and an empty answer would read exactly like a real one. The filter was NOT applied. Compare 'amount' with a value ({ "amount": VALUE }) or an operator ({ "amount": { "$eq": VALUE } }).

Hypotheses (zone 2), which held

  • H1: held, with one refinement. lowerWhereFilterArray is the seam, and narrowNumberComparands is called there on both branches (the object branch and the lowered array branch). The number door's walk was number-specific only at its per-field gate (numberComparandFieldVerdict(meta) !== 'judged'), and its where resolver already returned every declared field's type. The text door and the temporal door each walk too, but neither runs at having with a column declaration, so neither covers every position. The number door's walk is the one walk that does. The arm rides it, and no traversal was added.
  • H2: held, and all three positions are reached. Measured above: where answered per driver, and aggregations[i].filter and having answered a silent empty on every driver. Each is pinned.
  • H3: refined. The closed definition is above. Multi-value fields were measured on their own: a multiple: true select, multiselect and tags split exactly as a scalar field does (memory 200 no rows, SQL 400). That includes { tags: { 0: 'x' } }, the spelling the [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 multi-value carve-out exists for: the nested-object form does not reach an array member on InMemoryDriver. So they are judged. A multiple lookup stays on the relation side. A { $field } reference carries a $ key, so it is never this arm's (measured: served 2 rows on SQL, as before).
  • H4: held. No driver file changes (git diff fbec216e2d HEAD -- packages/drivers is empty). The SQL driver's own INVALID_FILTER stays as defence in depth for driver-direct callers and for the columns this arm does not judge (the lookup and JSON controls above still meet it).

Tests

All from b50627aca9 or from a commit whose non-test source is byte-identical to it (the last two commits touch only the changeset).

  • pnpm --filter @objectstack/objectql test: 338 files / 6704 tests passed. test:repo: 1 file / 5 passed.
  • pnpm --filter @objectstack/objectql typecheck: exit 0 (check:test-typecheck OK, the debt ledger held).
  • pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/rest test: 229 files / 4391 passed / 63 skipped (the live-dialect cells, no URL set).
  • New pin packages/objectql/src/engine-no-operator-object-door.test.ts (17 tests). It uses a recording driver, which is InMemoryDriver's cell by construction because the arm answers before a driver is resolved. It covers every scalar class, {}, every verb and the judge, $and / $or / $not paths, sugar, the three REST doors into findData, the per-aggregation filter, having (sum, groupBy, max of a date, a month bucket), the accepted side at all three positions, a Map and the classification GUARD over every FieldType.
  • New pin packages/rest/src/data-no-operator-object-door.test.ts: SQLite always, PostgreSQL and MySQL where OS_TEST_POSTGRES_URL / OS_TEST_MYSQL_URL are set. where refusals, the per-aggregation filter, having, and the two controls (a lookup nested-relation filter and a JSON object comparand: the driver is asked, and the answer is never the arm's). Local run with a live PostgreSQL 16.13: 8 passed (sqlite 4, live postgres 4) / 4 skipped (mysql, no URL). ⚠️ As with the sibling door suites, no CI job sets these URLs for @objectstack/rest, so the live cells run only locally.
  • Consumer suites (downstream of @objectstack/objectql): service-analytics 137 files / 3216 passed; plugin-security 147 files / 3202 passed / 23 skipped. The other downstream consumers are declared to CI.

Reverse verification (ablation), from the committed fix. It ran through scripts/ablation-replace.mjs (WRAP mode, trap-restored). The anchor if (facts.scalarType !== null && isNoOperatorObject(value)) { was replaced by if (facts.scalarType === '__ablated_20546__' && …) {. On disk the anchor went 1 → 0 and the marker 0 → 1, with blob 16151b29f6c1 → 0e8acf882100. Then objectql was rebuilt and ablation-dist-preflight reported the marker present in 4 built files. Predicted direction: red. Observed: red. The objectql pin went 10 failed / 7 passed: every refusal case failed, and every control and GUARD stayed green. The rest pin went 4 failed / 4 passed / 4 skipped: the where and aggregate refusals failed on SQLite and live PostgreSQL, and the controls stayed green. Restore leg: blob equals HEAD (16151b29f6c1), git diff HEAD empty, the whole-tree git status --porcelain empty, rebuilt, --absent preflight (marker absent from all 14 built files), then both pins green again (17 / 17; 8 passed + 4 skipped).

Gates

node scripts/pm/dispatch-gates.mjs --commands at b50627aca9 derived 65 commands. All were run on b50627aca9, and --ran reconciles them: 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. 63 exit 0, including check:adr-0087-registration --base origin/main (not-required (no-migration-prescription) accepted), check:changeset-no-major, check:empty-changeset, check:doc-authoring, check:nul-bytes, check:engine-double-contract, check:where-matcher, check:driver-memory-census, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage and check:query-options-erasure.

  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Reason: each exits 3 (PREREQUISITE NOT MET) because it reads the built closure of every package, and this box built only the objectql/rest closure. CI's Lint & Repo Gates builds that closure.
  • Driver-related families read before (on fbec216e2d, a detached comparison worktree) and after (on b50627aca9):
    • check:where-matcher: 440 matchers, 440 correct or loudly refusing, before and after.
    • check:driver-memory-census: 12 bindings / 2 ruled consumers, before and after.
    • check:engine-double-contract: pinned rows 825 → 825 and discovered files 953 → 953. Test files went 4231 → 4233 and production files 2997 → 2998, which are the two new tests and the new module. No new fake engine.
  • Lint, narrowed and proven: pnpm exec eslint --no-inline-config --format json over the 7 changed .ts files, at b50627aca9, found 7 files, 0 errors, 0 warnings. The checked population comes from eslint's own config: calculateConfigForFile answers isPathIgnored=false for all 7. The file count comes from the JSON output (7 results). Untouched files cannot change verdict: parserOptions.project and projectService are null for every file, so type-aware linting is not enabled and this diff cannot move any untouched file's lint result.

Changeset

.changeset/20546-no-operator-object-on-scalar.md: @objectstack/objectql minor, a BREAKING banner, Clause-②: no (narrowing) and the ADR-0087 marker not-required (no-migration-prescription), following the #20501 / #20545 precedent. Its "Who is affected" section names a caller that sends the shape to the in-memory driver: a test suite, a local or embedded deployment on InMemoryDriver, or a flow or hook calling the engine in-process. No export or published type changes: the door modules are internal, and @objectstack/objectql's root and ./core exports are unchanged.

Acceptance notes


Generated by Claude Code

… under a scalar column (#20546)

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…egation filter and having

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…ver SqlDriver (SQLite, live PostgreSQL/MySQL)

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 26 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
  • content/docs/api/wire-format.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /api/v1/data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query))
What this run could not see
  • 6 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 — 17 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 01e78dceeffb28477bcdbcab26f951b4cbef78ec → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 01e78dceeffb28477bcdbcab26f951b4cbef78ec

⚠️ 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 01e78dceeffb28477bcdbcab26f951b4cbef78ec → 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: b50627aca98d596a70d5f60c165ddca239ebdadf
Local-runs: none

Inputs: card #20546 (body; triage 5882227960; claim 5901567907; dev report 5902224595), PR #20744 (body, the 8-file list, git diff origin/main...b50627aca9, merge-base fbec216e2d), and the 31 check-runs on the head. Base facts read on origin/main (01e78dceef): number-comparand-declared-type-door.ts, having-filter.ts, spec filter-dotted-head.ts / field-value.zod.ts / field.zod.ts, and the headers of check-changeset-no-major.mjs, check-adr-0087-registration.mjs, lint.yml. Nothing built, run or re-run.

① Derived judgments

The narrowing, cell by cell — what is refused now that was accepted, and where.

  • where (object form and FilterArray sugar; find / findOne / count / aggregate / update / delete / judgeFilter), a plain object with no $ key under a column whose declared type is in SCALAR_FILTER_HEAD_TYPES or MULTI_OPTION_TYPES: InMemoryDriver went from 200 with no rows (every row under $not) to 400 INVALID_FILTER; SqlDriver (SQLite, PostgreSQL) went from a 400 in the driver's words to a 400 in the engine's words — same envelope, one door earlier. RIGHT: the ruling's sentence, before any driver is resolved (the arm returns before getDriver).
  • aggregations[i].filter and having, the same shape: from a silent count 0 / no group on every driver to 400 INVALID_FILTER. RIGHT: "every position the engine judges" — these are the two positions the engine evaluates itself.
  • {} under a judged column: at where both driver families already refused it (the { field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 family), so the accept-set does not move there, only the words; at aggregations[i].filter / having it moves from silent to refused. RIGHT: {} is a plain object with no $ key and matches nothing anywhere; the changeset says "{} included". Under an unjudged column {} keeps the drivers' words, so the WORDING now splits by column class while the envelope does not — the same door-by-door wording every neighbour on this seam already has, not a contract split.
  • Multi-value columns (multiple: true on a scalar-head type; multiselect / checkboxes / tags), the { tags: { 0: 'x' } } spelling included: judged. RIGHT, and it does NOT contradict [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371. On origin/main, filter-dotted-head.ts carves array-valued heads out of the DOTTED verdict for one measured reason: a numeric-index dotted KEY ('tags.0') reaches an array member on memory (mingo) and mongodb. This arm never touches a dotted key (key.includes('.') still skips, unchanged) and judges only a nested-object VALUE under an undotted key. { tags: { 0: 'x' } } is an equality of an array against a document, which matches neither an element nor the whole array: measured by the dev on memory (200, no rows) and SQL (400). driver-mongodb was not measured; Mongo's equality semantics give the same no-match, so no capability is deleted there — reasoning, not a reading. The card's reach and triage's pins name memory / SQLite / PostgreSQL, all covered.
  • The judged set is closed and DERIVED, not hand-listed: holdsScalarValues(t) is SCALAR_FILTER_HEAD_TYPES.has(t) || MULTI_OPTION_TYPES.has(t), both spec sets built from the ADR-0104 value classes (a new type joins by declaring its class). Partition of the 49 FieldType members on origin/main: 32 judged — 14 string types (text … qrcode, code, color, secret), 7 numeric (number, currency, percent, rating, slider, progress, summary), boolean / toggle, date / datetime / time, select / radio, autonumber, multiselect / checkboxes / tags; 17 left out — lookup / master_detail / user / tree (relation: the nested-relation form FilterCondition declares at filter.zod.ts:1982), json / composite / repeater / record / location / address / vector (structured JSON: an object comparand is a whole-value match), image / file / avatar / video / audio (the [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 carve-out: a legacy stored value is an inline object), formula (INVALID_FIELD one door earlier). Every member lands in exactly one class; an unknown type is unjudged — fail-open, the neighbours' direction. The GUARD test asserts the complement classes answer false and the classification equals the spec sets over FieldType.options.
  • Controls: relation fields (lookup, master_detail, a multiple lookup), json, address, image, operator bags, { $field }, an undeclared key and a Map — unchanged, pinned as "the driver was asked and the answer is not the arm's" on the recording driver, SQLite and (locally) live PostgreSQL.

One walk, not a second traversal. RIGHT. walkCondition in number-comparand-declared-type-door.ts gains one if per field key, asked before the number arm; MetaOf becomes FactsOf returning { number, scalarType }; the number-arm gate if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue; is intact; the $and / $or / $not descent, the depth bound and the $-key and dotted-key skips are byte-unchanged. no-operator-object-door.ts holds the classification, the structure test and the words — nothing in it iterates a filter. Because the arm is asked first, findNonNumericComparand (internal; no non-test caller outside its own module on the branch) answers null when the first refusal is the arm's — its docblock says so.

having-filter.ts. aggregatedRowColumnTypes is new; aggregatedRowColumnClasses is now classOfDeclaredType mapped over it. Same values, verified by reading both ladders: formula / unreadable → undefined; count / count_distinct / sum / avg → 'number' → 'numeric'; min / max → the field's type → its class; a day bucket → 'date' → 'date'; a coarser bucket → 'text' → 'text'; the class ladder itself is untouched. Needed because the text class lumps a json or lookup groupBy in with a real text column and the arm needs the type. Inside the dispatch's file surface (packages/objectql), beyond the dev's own narrower plan — flagged honestly, justified.

Public surface. None moves. packages/objectql's two entries (src/index.ts, src/core.ts) export nothing from having-filter.ts, number-comparand-declared-type-door.ts or no-operator-object-door.ts; narrowHavingNumberComparands's new required types parameter has one caller (engine.ts). No file under packages/drivers changes (8-file list). No packages/spec change — no spec contract change, as triage ruled.

Refusal envelope and words. invalidFilterError, the existing INVALID_FILTER / 400 envelope (httpStatus 400 pinned). The words name the field, its declared type, the object's keys — never its values (pinned) — and the path (where.amount, where.$not.amount, where.$or[0].amount, aggregations[1].filter.amount, having.total); the having form names "the aggregated column". No tracker number in any added runtime string (the added non-comment lines of the four source files were scanned); [#20546] appears in comments and docblocks only.

Check-runs on b50627aca9 — read in this act (2026-09-30T01:36Z), not awaited. success: Build Core, Type Check · source gates, Type Check · debt ledger, Governed Surface Queue Guard, Check Changeset, Check PR Size, Dogfood Verify CLI, Dogfood Regression Gate (1/3), the four claim / single-writer / part-of guards, Auto Label, filter, Check Documentation Links, Flag docs affected. in_progress: Lint & Repo Gates, Type Check · workspace, Type Check · consumer gates, Test Core (1/6 … 6/6), Dogfood Regression Gate (2/3, 3/3), Temporal Conformance (live PG + MySQL). skipped: Console Pin Gate, Build Docs, Packed-tarball smoke. No failure at read time. The two gates the dev could not measure are both answered green: check:type-check-debt runs in Type Check · debt ledger (success); check:dual-build-cjs-loads is hosted by Build Core per lint.yml's own note (success). The landing seat still reads every context green before queueing — Tier rule, not this record's to waive.

Shipped prose, sentence by sentence. Changeset: the title, the Clause-②: no (narrowing) line and the BREAKING banner — true. "It ships as minor under the launch-window convention for accept-set narrowings" — true: check-changeset-no-major.mjs forbids major until GA and the stock .changeset/ carries BREAKING-as-minor entries. The judged-field list — true (matches the spec sets above). The having column typing — true (matches aggregatedRowColumnTypes; count_distinct rides with count). The refusal description and the by-hand fix — true. The before / now table — consistent with the dev's measurement and with the pins. "Who is affected" — true. "No existing test … sent the shape: both suites pass with no fixture changed" — true in substance: no fixture or data moved; one sibling GUARD test was adapted to pass the new types argument (a signature, not a shape sent). The "Unchanged" paragraph — true (the classes verified above). ADR-0087 marker not-required (no-migration-prescription) — right: no authorable key, export or stored shape moves and no mechanical rewrite exists; its gate verdict arrives with Lint & Repo Gates. PR body: true throughout, with two imprecisions that do not ship — "engine.ts: the having call passes the types; the other hunks are comments" (one hunk is the aggregatedRowColumnTypes import); the "#20501 / #20545 precedent" is cited by PR number and no changeset for either PR (or for #20545's card #20502) is in .changeset/ on origin/main, so the convention is verified from the gate header and the stock BREAKING-as-minor entries instead. "PR #20738's warning-text region is untouched" — true (hunks at 269, 1022, 1111, 16365, 16501; #20738 sits near 7079 / 7173).

② Semver level

.changeset/20546-no-operator-object-on-scalar.md: @objectstack/objectql minor, a BREAKING banner, Clause-②: no (narrowing), exactly one ADR-0087 marker. It matches what the diff publishes: the only published source that moves is packages/objectql (packages/rest receives a test file only). A narrowing is BREAKING (Post-Task Checklist §3) and the launch-window guard ships BREAKING as minor, never major. Clause-②: no (narrowing) is right: no key joins a published payload; an input set shrinks. Not skip-changeset, not a bare patch. RIGHT.

③ Boundary flags

  1. having-filter.ts touched beyond the claimed surface — ANSWERED: inside packages/objectql, values proven equal, needed by the having arm. Accepted.
  2. H3, multi-value columns judged — ANSWERED: right; [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 is a dotted-KEY verdict and this arm skips every dotted key (①). Not a contradiction.
  3. {} judged — ANSWERED: right; only the wording changes at where, the accept-set moves at the two engine-evaluated positions; the changeset says so.
  4. Changeset reworded after check:adr-0087-registration — ANSWERED: the stored text carries no rewrite label, the banner and marker are kept, the category is right; the gate's own verdict comes with Lint & Repo Gates.
  5. Before / after reads (check:where-matcher 440/440; check:driver-memory-census 12 bindings / 2 ruled; check:engine-double-contract 825 / 953 with +2 test files, +1 production file) — ANSWERED: a fair rendering of "driver conformance"; Lint & Repo Gates re-answers each on the head.
  6. Memory pinned through the recording driver; the real InMemoryDriver measured in scratch only — ANSWERED: acceptable by construction (the arm returns before a driver is resolved) and the fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) #20545 posture; the SQL pins run a real SqlDriver.
  7. check:dual-build-cjs-loads / check:type-check-debt NOT MEASURED — ANSWERED: both green on the head (Build Core, Type Check · debt ledger).
  8. Out-of-scope 1 — the nested-relation form FilterCondition declares is served by no data-path driver, so lookup / json / undeclared id split per driver (memory 200 vs SQL 400) — ESCALATED to the seat as ONE card to file: class (a), reach POST /api/v1/data/:object/query and engine.find, seam spec FilterCondition arm 4 to driver-memory deep equality / driver-sql refusal. Not this PR's: triage kept them as the controls.
  9. Out-of-scope 2 — file / media keep the [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 carve-out — ANSWERED: noted-only is right; the carve-out is a ruling.
  10. open_questions: [] — nothing to answer.
  11. The rest pin's live PostgreSQL / MySQL cells never run in CI (the dev's own warning) — NOTED: the same posture as the sibling door suites; the PR carries the local PostgreSQL 16 run (8 passed / 4 skipped). Not a defect of this PR.

Implemented-by: claude/issue-20546-no-operator-object-on-scalar
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 01:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 97005ae Sep 30, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20546-no-operator-object-on-scalar branch September 30, 2026 01:56
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 36656292008 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Neither spec appears in the built console — no @objectstack/spec
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 8 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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