Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21426-native-number-comparand
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21426-native-number-comparand

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21426
Clause-②: no (narrowing)

What this changes

NativeSQLStrategy compiles its own SQL past the engine's field-aware filter walk, so it skipped the spec's number-comparand verdict (numberComparandDoorVerdict, @objectstack/spec/data). It now runs that verdict as the second arm of the one walk that the boolean arm added (#21376, PR #21424). The arm runs at the same three positions:

  • the query's where, with the dataset door's runtimeFilter merged into it;
  • each measure's own filter;
  • the dataset's own scope.

Details:

  • One walk, two arms. judgedBooleanComparands / narrowBooleanComparands are renamed judgedComparands / narrowComparands. A walk named for booleans would lie once it also judges numbers. Each arm is a ComparandArm row with three parts, and nothing in it is copied from the spec (no table, regex or refusal words):

    • its spec field verdict (numberComparandFieldVerdict / booleanComparandFieldVerdict, judged alone);
    • its spec operator lists (NUMBER_COMPARAND_DOOR_*_OPERATORS / BOOLEAN_COMPARAND_DOOR_*_OPERATORS);
    • its judge.

    The two classes are disjoint, so at most one arm judges a member. The number arm is asked first, which is the engine walk's order.

  • Refusal envelope. The arm throws invalidFilterError (from the strategy's filter-normalizer.ts): INVALID_FILTER / 400, the same constructor the boolean arm throws through. The message is numberComparandRefusalMessage's sentence behind the [analytics] prefix. Every native position is bound by the driver (a measure filter compiles into its conditional aggregate's bind), so the sentence uses the spec's default, driver-bound reading.

  • Narrowing. A numeric string narrows to the number the verdict names, so the native statement binds what the engine hands its driver: 12, never "12". It is copy-on-write: a subtree that nothing narrowed is returned by reference. The pins deep-freeze every filter handed in and every registered dataset.

  • Member reader. Unchanged. The declared type comes from the host's declaredFieldType hook, for the (object, column) pair that resolveStorageTarget returns. So currency and percent are judged, like every NUMERIC_VALUE_TYPES member. A relationship-path member is judged at the related object's declared column. A formula reaches the walk with no returnType (the plugin relays none), so both verdicts answer deferred for it.

  • { amount: true }. The boolean arm never touched it: its field verdict is not-judged for a number column. The number arm refuses it as the boolean form.

No packages/spec edit and no objectql-strategy.ts edit. windowClauseSql is untouched.

Measured

Setup:

  • Data: three rows, with amount 5, 12 and 30.
  • Composition: AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver.
  • Drivers: SQLite in memory, and a live PostgreSQL 16.14 server started for this run (since stopped, with its data directory deleted).
  • Doors: AnalyticsService.query (what POST /api/v1/analytics/query relays) and AnalyticsService.queryDataset with a runtimeFilter (what POST /api/v1/analytics/dataset/query relays).
  • Faces: each door was asked on the native face and on the engine-aggregate face.
where native, SQLite, base 3a6d92f78 native, PG, base engine face, SQLite and PG native, this PR, SQLite and PG
{ amount: "abc" } 200, 0 500 DATABASE_ERROR 400 INVALID_FILTER 400 INVALID_FILTER
{ amount: { $lte: "9999-12-31" } } 200, 3 (bound nothing) 200, 3 400 400
{ amount: { $ne: "abc" } } 200, 3 500 400 400
{ amount: true } 200, 0 (bound 1) 200, 0 400 400
{ amount: 12 } (the control) 200, 1 200, 1 200, 1 200, 1
{ amount: "12" } 200, 1, bound "12" 200, 1, bound "12" 200, 1, driver got 12 200, 1, bound 12
  • Dataset door: the runtimeFilter answered identically on every cell.
  • Registered datasets: a dataset's own scope { amount: "abc" } and a measure filter { amount: { $ne: "abc" } } answered 200 on SQLite and 500 on PG on the native face, and 400 on the engine face. Both answer 400 now.
  • The card's counts: the card measured $lte and $ne at count 2 over different rows. The shape is the same: 200 where the engine refuses.

Pins: native-sql-number-comparand-door.test.ts

The file mirrors native-sql-boolean-comparand-door.test.ts. Each parity cell asks both faces, at the cube read and at the dataset door. It asserts:

  • the engine face's answer, and the native face's equality with it;
  • which strategy answered;
  • for a refusal, that no statement ran and that the message names the member and where.

What it covers:

  • 24 parity cells:
    • the card's four cells;
    • the numeric controls;
    • narrowed strings on number, currency and percent;
    • refusals under $or, as a list member, a blank string and "+5";
    • the null tests.
  • Spellings: the FilterArray spelling and the cube-qualified member.
  • A narrowing pin: the native face's bound values equal what the engine handed its driver, for example [12, 30] for $in ["12", 30].
  • Registered datasets: one dataset's own scope and measure filter that narrow, and three that are refused. All four are frozen.

The PostgreSQL cell runs where OS_TEST_POSTGRES_URL is set, and is a named skip otherwise, as in the boolean twin. It ran here against the live server: SQLite plus PG is 114 tests.

Two cells are pinned on the native face alone, because the engine face does not reach this verdict there:

  • A relationship path (account_credit, the cube dimension over account.credit). The engine face refuses every cross-object filter (INVALID_FIELD / 400). The native face joins and judges the related object's number column: "abc" is refused, and { $gt: "100" } binds 100.
  • { amount: { $gt: [10] } }. The shared analytics lowering hands the engine only the list's first member, so the engine face answers 200, 2. The spec's verdict refuses a list where one number belongs, and the native face now does too. See the acceptance notes.

Ablations (one-shot and restored; nothing left in the tree)

How each leg ran:

  • Mutation: native-sql-strategy.ts was mutated through scripts/ablation-replace.mjs. Its anchor must hit exactly once.
  • Landing proof: the anchor count and the blob hash.
  • Restore proof: the blob equals HEAD and git diff HEAD is empty. Each leg also ran inside a script with an EXIT/INT/TERM restore trap.
  • No rebuild needed: the test imports ../plugin.js by relative path, so it reads the source, not dist/.

The predicted direction was named before each run. The observation matched the prediction on every leg, on SQLite and PG:

leg predicted observed
number arm removed (comparandArmFor never returns NUMBER_ARM) 31 per driver: the 11 refusal cells red at both doors, plus FilterArray, the narrowing bind, 3 native-only and 4 registered tests. Boolean twin stays green 62 failed, 120 passed of 182 (number and boolean files); the boolean file stayed green
narrowing removed (a narrows verdict read as passes) 6: only the bind assertions (the narrowing pin, the relationship-path bind, the registered narrowed dataset). Every count stays green 6 failed, 108 passed
measure-filter position's call removed 6: two registered measure refusals and the registered narrowed bind 6 failed
dataset-scope position's call removed 4: the registered scope refusal and the registered narrowed bind 4 failed
where position's call removed 54: 22 parity refusals, FilterArray, the narrowing pin and 3 native-only cells 54 failed

The narrowing leg's first attempt did not run. ablation-replace refused it because the replacement text contained the anchor (count 1 → 1), restored the file, and ran no test. The row above is the re-run with a non-overlapping replacement.

Gates

Patch round (declaration only), at HEAD 7a626eb09. The only change is the changeset; no code moved.

  • Derived gates: dispatch-gates --commands derived the same 63 commands as round 1. All 63 ran in a freshly recreated worktree (full turbo run build, 72 of 72 tasks from cache, first) and exited 0. --ran reconciled 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
  • check-adr-0087-registration --base origin/main: reads the changeset as [BREAKING+clause-②-narrowing] and accepts the disposition not-required (no-migration-prescription).
  • check-changeset-no-major: no major bump. Its level axis needs a pull_request payload, so it was also driven offline with --event carrying this body (see the report on the card).
  • check:changeset-gate-self-tests: exit 0.

Round 1, at HEAD a17f21b80:

  • Derived gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 63 commands. All 63 ran and exited 0. --ran reconciled 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm check:dual-build-cjs-loads: the first attempt answered PREREQUISITE NOT MET (exit 3, no dist/ for unbuilt packages). It was re-run after a full turbo run build (72 of 72 tasks, 71 cached) and exited 0.

For @objectstack/service-analytics:

  • typecheck: exit 0, and tsc --listFiles includes both edited files.
  • Full test: 170 files and 4065 tests passed, with OS_TEST_POSTGRES_URL pointing at the live server. This ran at d77d545a1; the later commit adds only the changeset.

Docs

I grepped content/docs/** (outside releases/) and skills/** for the analytics filter's comparand handling. No sentence describes the native face's number comparands, so none became false.

Acceptance notes


Generated by Claude Code

claude added 3 commits October 2, 2026 16:53
…erdict as the walk's second arm

The native strategy compiles its own SQL past the engine's field-aware
walk, so a comparand against a declared number column reached the driver
as written: "abc" counted no row on SQLite and was a 500 on PostgreSQL,
true bound 1, and a bare-day $lte met the window rule and counted every
row, where the engine door answers INVALID_FILTER / 400. The boolean walk
becomes one walk with two arms (number first, the classes are disjoint),
each reading its own spec verdict and operator lists, at the same three
positions: where (runtimeFilter merged), each measure filter, the dataset
scope. A numeric string narrows to its number, copy-on-write.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…he engine door's answers

Each cell is asked at the cube read and the dataset door on both faces
(native statement, engine aggregate), on SQLite and on live PostgreSQL
where OS_TEST_POSTGRES_URL is set: the card's four cells refuse
INVALID_FILTER / 400 before any statement runs, a number is the control,
and a numeric string binds the number the engine handed its driver. A
registered dataset's own scope and measure filters are judged too, frozen
so narrowing must be copy-on-write.

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

16 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
  • 2 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 — 10 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 b79301000c85fd5986c0656bde27bb7a70eadf60 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…t-set narrowing

The native face now refuses INVALID_FILTER / 400 where it answered 200
(a 500 on PostgreSQL), the same class of change the boolean arm declared:
minor, Clause-② no (narrowing), a BREAKING banner and an ADR-0087
not-required disposition stating this change's facts.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 18:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 18:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 086ad0a Oct 2, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21426-native-number-comparand branch October 2, 2026 18:42
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