Skip to content

fix(spec)!: a list at a scalar operator is refused at the shared comparand-shape face, whatever the column type (#21448) - #21484

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21448-list-at-scalar-operator
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-21448-list-at-scalar-operator

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21448
Clause-②: no (narrowing)

What this changes

The shared comparand-shape face (assertListComparandShapes, @objectstack/spec/data) now refuses a LIST at every scalar operator, whatever the column type. That covers $gt, $gte, $lt, $lte, the text operators ($contains, $notContains, $startsWith, $endsWith, $icontains, $like, $ilike) and the flags ($null, $exists, $empty). $eq and $ne keep their own ruled arms.

  • Envelope: INVALID_FILTER / 400, before any read.
  • Sentence: one sentence naming the operator, the field, the list and the path. Its leading clause is driver-memory's arrayComparandError for the same condition, word for word.
  • Remedy: one value; $in (authoring in) for "one of these values"; $between (authoring between) for a range.

This implements triage's ruling (5958292323) as written. There is one verdict, at the shared face. It is not in the number or boolean declared-type verdicts. The lowering gains no second rule and no values[0] read of a list.

Measured on origin/main b94a2a727, SQLite and PostgreSQL 16.14 alike

Through AnalyticsService.query / .queryDataset (what POST /api/v1/analytics/query and /api/v1/analytics/dataset/query relay), on both faces, and through engine.find on the real ObjectQL:

filter engine-aggregate face native face engine.find
{ amount: { $gt: [10, 99] } } (number) 200, 2 (the driver got $gt: 10) 400, the number verdict 400, the number verdict
{ amount: { $gt: [10] } } 200, 2 400, the number verdict 400, the number verdict
{ amount: { $lte: [12, 1] } } 200, 2 ($lte: 12) 400, the number verdict 400, the number verdict
{ note: { $gt: ['a', 'z'] } } (text) 200, 3 ($gt: 'a') 200, 3 (bound 'a') 400, driver-sql's bind refusal
[['note', '>', ['a', 'z']]] 200, 3 200, 3 400, driver-sql's
{ note: { $eq: ['b'] } }, { note: { $ne: ['b'] } } 400, the face 400, the face 400, the face
{ note: { $contains: ['b', 'm'] } } 400, this package's LIKE gate 400, the same 400, driver-sql's
controls: $in: ['b'], $nin: ['b'], $gt: 10 1 / 2 / 2 the same the same

Triage's "measure first": the engine door's own answer for the text cell. The engine door does NOT bind the first member. On driver-sql it refused 400 in the driver's own words ("…cannot be bound as a SQL parameter…"). So its answer was right and only its wording was per driver. The same verdict now answers it first, in the face's words (pinned: the engine.find text cell). One position over, driver-memory ANSWERS a list at a text operator (memory-matcher-array-and-date-comparand.test.ts). The face now refuses that before any driver runs.

Dispatch assumptions this measurement corrected:

Design

  • The operator set is the spec's own: SCALAR_COMPARAND_OPERATORS, the comparand-TYPE face's split, which filter-comparand-type.test.ts reconciles against FieldOperatorsSchema's keys.
    • It moved verbatim from filter-comparand-type.ts into a new module outside the data barrel (filter-comparand-operators.ts). Both faces and the save door read ONE split, and nothing is published: check:api-surface is unchanged.
    • The face test also derives the arm's operators from the schema: every declared operator whose enforced slot refuses an array, which is all but $in / $nin / $between.
  • No rule in the lowering. lowerAnalyticsWhere already hands every field entry to the face before any leaf exists. So the arm reaches both analytics faces at every position (where, runtimeFilter, a dataset's scope, a measure's filter) with no code change there. filter-normalizer.ts and comparand-shape.ts change docblocks only; they state the invariant that only a list operator's array is spread into a leaf's values.
  • The save door asks the same face (filter-save-door-refusals.ts).
    • A stored dataset, measure, widget or report filter carrying the shape is refused on save, in the face's sentence less its location. The parity test's §2 requires a save-door sentence for every face arm.
    • The HTTP routes that Zod-parse a filter in their body therefore answer VALIDATION_FAILED / 400, located on the member, as for every other face arm. In-process callers get INVALID_FILTER / 400. Both layers are pinned.
  • The native number arm (PR fix(service-analytics): native SQL judges a comparand against a declared number column by the spec's verdict, as the comparand walk's second arm #21446). judgedComparands lowers through lowerAnalyticsWhere first, so that arm's array refusal is no longer reached at a scalar operator from any native position. native-sql-strategy.ts is untouched; the now-unreachable branch is a note, not an edit.
  • Flags. A list at $null / $exists / $empty now reads in the shape sentence, because how many values comes before which value. A non-boolean scalar flag keeps the boolean rule's sentence.

Pins

  • New, service-analytics: list-at-scalar-operator-both-faces.test.ts, run on SQLite and PostgreSQL. Each measured cell, plus $gte / $lt: [] / $contains / $startsWith, $or / $not, and the FilterArray spelling, is checked at both doors.
    • Each cell answers one 400 on both faces, with the same message on each face and no raw statement, engine aggregate or driver read.
    • A registered dataset's scope and measure filter are refused the same way. DatasetSchema refuses the stored filter on save, in the sentence less its location.
    • engine.find refuses the text cell in the face's words, not the driver's.
    • Controls ($in, $nin, scalar $gt, $between) count alike on both faces.
  • Both-faces pin: PR fix(service-analytics): native SQL judges a comparand against a declared number column by the spec's verdict, as the comparand walk's second arm #21446's native-only $gt: [10] cell is now a both-faces cell in native-sql-number-comparand-door.test.ts.
  • New, @objectstack/spec: a filter-comparand-shape.test.ts block covering the derived operator set; every list shape (pair, one member, strings, empty); nested paths; every AST spelling that carries a value; the message and remedy ($in, $between, both declared); flags; controls; the 500-char bound.
  • Moved because the face now answers first (each row left its old table and is pinned as the shape face's):
    • the declared-type corpora (filter-number- / filter-boolean-comparand-declared-type.ts) and their tests: list rows only at list members now;
    • filter-save-door-face-parity.test.ts (§1: every declared operator is face-judged; §2: new rows);
    • objectql's number, boolean and aggregate-flag doors;
    • REST's number and boolean data doors;
    • analytics-filter-refusal-envelope.test.ts (a new HTTP cell);
    • analytics' flag, $empty and type-face tests.

Ablations

Each leg was committed first, mutated through scripts/ablation-replace.mjs (WRAP, with the restore trapped), and its restore proven by blob == HEAD and an empty git diff HEAD.

  • A: the spec arm deleted. Rebuilt; ablation-dist-preflight.mjs @objectstack/spec 'throw arrayScalarComparandError(' --absent exit 0.
    • Predicted: each list-at-scalar cell goes back to a 200 (or to the native number verdict on a number column); $eq / $ne and the controls stay green.
    • Observed: analytics went 60 failed / 142 passed (30 cells × SQLite and PG):
      • the text and number cells were answered 200 on the engine-aggregate face;
      • the LIKE cells fell to the analytics LIKE gate's words;
      • engine.find answered in driver-sql's words;
      • the save door accepted the stored filter;
      • every $eq / $ne and control cell stayed green.
    • The face test's new block went 12 red. Restore leg: rebuilt, marker present in dist/, tree clean, 202 / 202 green.
  • B: the lowering's consumption deleted (assertWhereComparandShapes' face hand-over). The subject resolves from src, so no rebuild is owed.
    • Predicted: the object-spelling analytics cells go red; the FilterArray spelling, $eq, the save door and engine.find stay green.
    • Observed: 48 failed (24 cells × 2 drivers: the object-spelling cells, $ne included, and the registered scope and measure). 0 failures among the FilterArray, $eq, save-door and engine.find cells.

Verification, at the merged head 2b9fd4f5e (origin/main merged in)

  • Full suites:
    • @objectstack/spec test: 602 files / 17754 tests green.
    • @objectstack/service-analytics test, with PostgreSQL 16.14: 172 files green. One file's 4 live-PG cells need a UTC server; see the acceptance notes.
    • @objectstack/objectql test: 366 files green. One barrel-import test timed out at 5 s at load ~7, then 34 / 34 when run alone.
    • REST door pins: 4 files, 67 tests (MySQL cells are named skips).
  • Typecheck: spec, service-analytics, objectql and rest all green.
  • Face importers, at the pre-merge head: driver-memory, driver-mongodb, driver-turso, driver-sql (with a non-UTC PostgreSQL server), lint, metadata-core, metadata-protocol, plugin-security and plugin-sharing are all green.
  • Gates: dispatch-gates.mjs --commands at 2b9fd4f5e derives 90 families. All 90 ran with recorded exit codes, all 0, including check:dual-build-cjs-loads (105 require entries across 66 packages load). --ran reconciles 90 run / 0 NOT MEASURED.
  • Lint (narrowed, measured): eslint --no-inline-config --format json over the 24 changed .ts files gives 24 files, 0 errors, 0 warnings, none ignored.
    • The population is read from eslint's own output.
    • Invariance: eslint.config.mjs has no type-aware linting (no parserOptions.project / projectService), so this diff cannot move an untouched file's verdict.
  • Docs: grepping content/docs/** (outside releases/) and skills/** for the comparand-shape rules and the filter operators found no sentence made false.

Blast radius

  • Shipped producers writing a list at a scalar operator (object form, [field, op, value] and { field, operator, value }, across examples/, skills/, content/docs/, apps/ and packages/** non-test sources): none. The CEL lowering already refuses one (cel-to-filter.ts).
  • NOT MEASURED: objectui's console filter builder. ../objectui is not checked out here, and packages/console/dist is not built.

File surface against the claim

The claim named filter-normalizer.ts, comparand-shape.ts, spec filter-comparand-shape.ts and its test, the pins and the changeset. Added, each a consequence of the narrowing inside the rule's consumer radius:

  • filter-comparand-operators.ts (new, internal);
  • filter-comparand-type.ts (the split's import, and one now-false sentence);
  • filter-comparand-refusal-text.ts (the shared sentence);
  • filter-save-door-refusals.ts (the save door's sentence);
  • the two declared-type corpora and the parity test;
  • the objectql, REST and analytics pins listed above.

None of native-sql-strategy.ts, objectql-strategy.ts, analytics-service.ts or preview-evaluator.ts is touched.

Acceptance notes

  • Out of scope; reported to the seat, not filed here. On PostgreSQL with the server TimeZone set to Asia/Shanghai, objectql-face-order-limit.test.ts's live cells answer the newest month bucket of a date column holding 2026-06-01 as 2026-05 (2 rows). At UTC the answer is 2026-06 (1 row). The cell is the engine-aggregate face, since the native face declines granularity. Not touched by this diff.
  • The compilers' values[0] reads stay as they are. With the face's arm, no list reaches a scalar leaf through any analytics door.
  • PR feat(spec,service-analytics)!: retire the cube metric types number / string / boolean, refused in both analytics strategies in the spec's words (#21000) #21452 (which held the analytics strategies) landed while this was open. It was merged in at 2b9fd4f5e cleanly, with no overlap.

Generated by Claude Code

claude added 8 commits October 2, 2026 19:28
…ave their list-at-scalar rows to the shape face (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…or; moved pins follow the shape face (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…'s scalar-slot arm (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…pe face's refusal at every engine and wire position (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…e shape face's refusal (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…d comparand-shape face (#21448)

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…st-at-scalar-operator

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/comparand-shape.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 5 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-3.mdx (via analytics.queryDataset (sdk, the route ledger binds it to POST /api/v1/analytics/dataset/query), queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-5.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/analytics/dataset/query (route, a path literal in a comment on a changed line), /api/v1/analytics/query (route, a path literal in a comment on a changed line))
  • content/docs/releases/v9.mdx (via queryDataset (sdk, the bare tail of client method analytics.queryDataset, bound to POST /api/v1/analytics/dataset/query))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/comparand-shape.ts) — pages documenting those are invisible to this run
  • 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 — 138 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 aa4632235ba571ef800b95e6bc18d00a30aa1d57 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json aa4632235ba571ef800b95e6bc18d00a30aa1d57

⚠️ 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 aa4632235ba571ef800b95e6bc18d00a30aa1d57 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 23:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 23:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 100c394 Oct 2, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21448-list-at-scalar-operator branch October 2, 2026 23:30
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 protocol:data size/xl tests tooling

Projects

None yet

2 participants