Skip to content

fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) - #20545

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20502-number-comparand-non-string
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20502-number-comparand-non-string

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20502
Clause-②: no (narrowing)

What this changes

One verdict, widened, as triage directed on the card: the published number-comparand contract now refuses any comparand compared against a declared numeric field that is neither a number nor a string the numeric grammar admits. The engine door that consumes it is unchanged in behaviour and carries no second rule.

  • The verdict, packages/spec/src/data/filter-number-comparand-declared-type.ts. numberComparandDoorVerdict(field, comparand) answers door-refusal (INVALID_FILTER / 400) for a boolean, a Date and an array at a judged position, beside the non-numeric string it already refused. The string rule is byte-for-byte what PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414 published. What still passes: a number, a bigint (the comparand-type door narrows it one call later), null (the null test), and every value outside the comparand-type door's accepted set (undefined, a plain object, a { $field } reference, a Map), which that door already refuses on every field in its own words (see Objects below).
    • Three additive exports: NON_NUMERIC_VALUE_FORMS (boolean, date, array) and the types NonNumericValueForm / NonNumericComparandForm. The refusal's form and the refusal site's value widen to carry a non-string.
    • Three refusal clauses, each stating only what was measured; a Date renders as Date(ISO) so it does not read as the quoted string the grammar refuses.
    • The derived case table gains a value group: false at $gt on every judged field, true and a Date at every judged position of f_number, an array at every judged position except the equality slots (implicit, $eq, $ne, where the comparand-shape door refuses an array one door earlier), and four passing rows (the null test twice, a boolean on a boolean field, a Date on a datetime field). filterAt now gives each filter its own copy of a Date or an array.
    • One table-derivation fix the engine suite caught: the table's unjudged rows carry the flag operators' booleans ($null: true, $exists: false, $empty: true), which the door never hands to the verdict. The table now asks whether the slot is judged before consulting the verdict, so those rows stay passes. The engine never refused them; only the table's label would have been wrong.
  • The door, packages/objectql/src/number-comparand-declared-type-door.ts: the refusal site carries value: comparand instead of comparand as string, plus comments. No rule was added or changed; the compiled walk is the same.
  • Tests: the spec contract suite, the objectql door suite and the REST three-dialect suite (below).
  • Generated: packages/spec/api-surface/data.json and export-origins/data.json, three added lines each, regenerated by gen:api-surface / gen:export-origins after check:generated named exactly those two.
  • Changeset: .changeset/20502-number-comparand-non-string.md, @objectstack/spec and @objectstack/objectql minor, BREAKING banner, a FROM → TO line, the ADR-0087 disposition not-required (no-migration-prescription).

Before and after

A declared number field, rows 5, 12 and 30. Measured through engine.find / engine.aggregate and POST /api/v1/data/:object/query (the two doors agree), on InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16. Before: base 4a1df19656. After: this branch at 0dc69019da.

position comparand before: memory · SQLite · PostgreSQL after, all three
where $gt true (the card) no rows · every row · 500 DATABASE_ERROR 400 INVALID_FILTER
where $gt false no rows · every row · 500 400
where $eq true / implicit true no rows · no rows · 500 400
where $ne true every row · every row · 500 400
where a $in member true no rows · no rows · 500 400
where $between [true, 20] no rows · two rows · 500 400
where (in-process) $gt / $eq / implicit / a $in member, a Date no rows · no rows · 500 400
where $gt [1], $lt [100] 400 in each driver's own words (SQL withholds the detail) 400 in the contract's words
where a $in member [1] no rows · driver 400 · driver 400 400
per-aggregation filter $gt true / $gt [1] count 3 on all three 400
per-aggregation filter $gt a Date count 0 on all three 400
having on sum(amount) $gt true / $gt [1] every group on all three 400
having on sum(amount) $gt a Date no group on all three 400
all three positions $gt 10 (the numeric control) 2 rows / count 2 / both groups the same
where $gt {"a":1}, a $in member {"a":1} 400, the comparand-type door the same, same words
where $eq null no rows the same

The per-aggregation filter and having rows are the engine's own evaluator on every driver, which is why they agree across drivers before the change: JS coercion read true and [1] as 1.

Premise check, and the dispatch's hypotheses

  • H1 holds. The card's table reproduces at 4a1df19656 on all three drivers (rows above), through REST and engine.find. PostgreSQL ran on a private PostgreSQL 16 cluster in this container (the same major as the Temporal Conformance (live PG + MySQL) job's postgres:16 service), started for this run and stopped after it.
  • H2 holds: the door had no second rule. Its only routing is the verdict (judgeComparand calls numberComparandDoorVerdict and turns the answer into an outcome); the one assumption it carried was the type cast comparand as string, erased at compile time. Widening the verdict alone reaches where (both spellings), the per-aggregation filter and having: the door file's diff is that cast plus comments, and the after-state above is uniform.
  • H3, the producer census: no real producer. git grep over examples/** and packages/** for a filter comparing with a boolean, a Date or an array (filter rules, FilterArray triples, $op literals), then each named field's declared type: every non-test hit names a boolean field (active, is_active, archived, ...) or a date field, or sits in driver-internal code. The one number-field hit, amount: { $gt: new Date(0) } in driver-sql's sql-driver-cross-field-reference.test.ts, calls SqlDriver directly, beneath the engine door, and stays green (driver-direct callers keep native binding, as the changeset says). No fixture needed triage.

Objects: served by the comparand-type door, deliberately

Triage's list names objects. Measured on the base, an object comparand against the number field is already refused with INVALID_FILTER / 400, naming the path, on all three drivers and at all three positions, by the comparand-type door (normalizeFilterComparandTypes), and at the REST ingress with VALIDATION_FAILED. The widened verdict passes such a value instead of refusing it a second time, for one measured reason: the engine runs the two doors in a different order per position (this door first on the object spelling of where and on a per-aggregation filter; the comparand-type door first on the FilterArray spelling and on having). A second refusal here would answer one mistake with two sets of words depending on where it was written, and would change the comparand-type door's pinned words for every numeric field. The engine suite pins it: for a plain object, undefined and a Map, the engine's message equals the comparand-type door's own message, on both spellings.

Tests (at 829106fd06, the final head, unless noted)

Every package run below is at 829106fd06 (this branch merged with origin/main 31d281d3b2 through scripts/pm/os-regen-merge.sh, whole workspace rebuilt: 72/72). Each run went through scripts/pm/os-verify-lock.sh; exit codes are the lock's VERDICT command-exit lines.

  • @objectstack/spec: filter-number-comparand-declared-type.test.ts 41/41. --project local 573 files, 16842 passed, 1 todo. --project repo: 39 files, 701 passed.
  • @objectstack/objectql: engine-number-comparand-declared-type-door.test.ts 34/34. --project local 332 files, 6641 passed. --project repo 1 file, 5 passed.
  • @objectstack/rest, with OS_TEST_POSTGRES_URL set: data-number-comparand-door.test.ts 8 passed, 4 skipped (the MySQL cell); the SQLite and PostgreSQL cells both ran. --project local 222 files, 4256 passed, 22 skipped. --project repo 1 file, 8 passed.
  • @objectstack/driver-sql, the whole suite under TZ=America/New_York against the live server set to Asia/Shanghai (the Temporal Conformance job's provisioning): 206 files, 3997 passed, 93 skipped (3 files skipped).
  • @objectstack/driver-memory: 59 files, 1419 passed.
  • Typecheck: pnpm --filter @objectstack/spec typecheck, @objectstack/objectql and @objectstack/rest: exit 0, and check:test-typecheck holds each ledger with no debt added (spec 53 files / 251 errors, objectql 40 / 234, rest 0 / 0).
  • Lint, a proven narrowing (the repo-wide pnpm lint is CI's): ① population from eslint.config.mjs itself: all 5 touched lintable files answer isPathIgnored false; the other 3 changed files are JSON / Markdown, outside its globs. ② eslint --no-inline-config --format json over them: 5 files, 0 errors, 0 warnings. ③ The config never enables type-aware linting (no parserOptions.project, no projectService), so this diff cannot move any untouched file's verdict.
  • Before/after table: a scratch script against the built packages, not a committed test; the after column was taken at 0dc69019da (this change before the later case-table fix, which changes test data only) and is re-pinned at the final head by the REST suite's SQLite and PostgreSQL cells and the objectql suite.

Ablation (reverse verification)

From the committed head 829106fd06, with scripts/ablation-replace.mjs in wrap mode (the mutation lives only while the tool's trap is armed). The mutation removes the widening at its one routing line in the verdict: if (form === null) return { verdict: 'passes' }; becomes the same line with || globalThis.ABLATION_20502 === undefined added to its condition (spelled with a cast to a string-keyed record so the DTS pass type-checks it), so every non-string comparand passes again. The string rule is untouched.

  • Mutated leg: anchor 1 → 0, blob fdb42a2a → 55919fda. After pnpm --filter @objectstack/spec build, ablation-dist-preflight found the marker in 4 built files (exit 0). Result: the spec suite had 5 failed, 36 passed; the objectql door suite 4 failed, 30 passed; the REST door suite 2 failed, 6 passed, 4 skipped, one failure each for the SQLite and PostgreSQL cells. Every failure is a new non-string pin, or the objectql guard that counts the refused forms. Every string pin and every numeric control stayed green.
  • Restore leg: the blob is back to fdb42a2a (the HEAD blob), git diff HEAD is empty and git status --porcelain is clean. After a rebuild, ablation-dist-preflight --absent found the marker absent from all 224 built files and the tree clean. The three suites passed again: 41/41, 34/34, and 8 passed with 4 skipped.
  • A first attempt that did not count. It spelled the marker as a constant, || 'ABLATION_20502'. The suites went red the same way, but ablation-dist-preflight answered exit 1 because the marker was absent from dist/: spec's tsup config runs with treeshake: true, and the bundler folded the constant condition away together with its marker. The mutation was therefore not proven to have reached the built artifact, so that reading is discarded. The redo above uses a globalThis property read, which the bundler cannot fold because the read could have side effects. I checked that it survives an esbuild bundle even with minifySyntax, and the preflight found it in dist/.

Gates

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 829106fd06 derives 88 commands (the 86 derived before the merge, plus check:generated and check:pm-widening-tells, which the merged gate map added). All 88 ran, and each exit code was captured before any pipe: 88 exited 0. That includes check:dual-build-cjs-loads and check:type-check-debt, which answered exit 3 (PREREQUISITE NOT MET) before the whole workspace was built. Reconciliation with dispatch-gates --ran (recorded as command :: exit N): 88 derived, 88 run, 0 NOT-MEASURED, 0 UNRUN, and that zero is derived from the recorded codes. check:adr-0087-registration reads the changeset as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). check-changeset-no-major: no major bump.

Acceptance notes

  • InMemoryDriver. Its cell is pinned by construction in the objectql door suite: the door answers before any driver is resolved, and the suite's recording driver asserts no read. Its live before/after rows above come from a scratch run against the built packages. A permanent real-InMemoryDriver cell would need a new dependency edge (@objectstack/rest or @objectstack/objectql on @objectstack/driver-memory), which this change did not add.

  • PostgreSQL in CI. No CI job provisions OS_TEST_POSTGRES_URL for @objectstack/rest, so the REST suite's PostgreSQL cell is red-capable and un-run in CI (the predecessor PR recorded the same). It ran locally here, against the private cluster.

  • MySQL: NOT MEASURED. No MySQL server in this container; the REST suite's MySQL cell is a named skip.

  • having words. A numeric aggregated column has no declared type; the door hands the verdict number, so the words read "compares a declared number field" for having.total.$gt. Inherited from the predecessor, unchanged.

  • The flag operators. $null / $exists / $empty take a boolean and are never judged; pinned in both the spec suite and, through the table, the engine suite.

  • Out of scope, measured and handed to the seat, not filed here: a plain object with no $ key in a scalar field's value position, where { amount: { "a": 1 } }. This is filter STRUCTURE (a nested-relation / deep-equality condition), which no field-aware door descends into. On the base it answers 200 with no rows on InMemoryDriver and a driver 400 on SQLite and PostgreSQL, through POST /api/v1/data/:object/query and engine.find. It is not specific to numeric fields, and this change leaves it as it was.

  • The save-time door, one family over. No lint or save rule reads the number verdict, so a stored filter comparing a number field with a string, and now also with a boolean or an array, saves clean and is refused at query time. The predecessor's report already carries that family. This change widens its population, and the census above found no in-repo producer of the new shapes.


Generated by Claude Code

…nd an array against a numeric field (wip)

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
… judges

A flag operator's boolean ($null / $exists / $empty) is never handed to the
verdict; the widened verdict refuses a boolean, so the table derives the
row's answer from the position first.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 22 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))
  • content/docs/deployment/validating-metadata.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))

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

  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))
  • content/docs/releases/v17/17-5.mdx (via INVALID_FILTER (literal, a string literal in numberComparandDoorVerdict))

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
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json) — pages documenting those are invisible to this run
  • 1 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 — 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 288611e3e58f6d8af50b889abcdde3e413e0eaa0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 288611e3e58f6d8af50b889abcdde3e413e0eaa0

⚠️ 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 288611e3e58f6d8af50b889abcdde3e413e0eaa0 → 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: 829106fd061463f52c1c56b91883690d390d078b
Local-runs: none

Read: card #20502 (body, triage grade 5877498426, claim 5878166283, os-dev-report 5882074829), card #20510 (body and triage 5879512564), PR #20545 (body, comments, file list, the 8-file net diff against the merge-base), the source at the head that the diff touches or calls (packages/spec/src/data/filter-number-comparand-declared-type.ts, filter-comparand-type.ts, filter-comparand-shape.ts, filter.zod.ts, packages/objectql/src/number-comparand-declared-type-door.ts, engine.ts, the three test files, .github/workflows/ci.yml / lint.yml / pr-automation.yml), and the check-runs on the head, read once at 2026-09-29T01:55Z. Rendered at 2026-09-29T01:58Z.

① Derived judgments

  1. The narrowing, at the one rule. numberComparandDoorVerdict(field, comparand) now answers door-refusal (INVALID_FILTER, 400) with form boolean / date / array for a boolean, a Date (valid or invalid) or an array, on a judged field only: numberComparandFieldVerdict runs first and a not-judged field answers passes before the comparand is looked at, so every non-numeric field type is untouched (pinned across BOOLEAN_VALUE_TYPES, CALENDAR_DATE_TYPES, INSTANT_TYPES, STRING_VALUE_TYPES with true, a Date and [1]). The string arm is the same readNumericString path as before, byte for byte, so a numeric string still narrows and a non-numeric string is refused with its old form. Still passes: a number, a bigint, null, undefined, a plain object, a { $field } reference, a Map, a symbol, a function. Right.

  2. Positions and the flag operators. The objectql door's judgeFieldSpec hands the verdict the implicit comparand, the six scalar operators from NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS, and each member of $in / $nin / $between; every other operator key is continued, so $null / $exists / $empty booleans never reach the verdict and are not refused. A $between [true, 20], a $in [10, true] and a $in [[1], 10] are refused member by member. Right (objectql suite drives the table's unjudged rows end to end and they pass; the REST suite drives the list shapes over the wire).

  3. One rule, no second rule, no driver change. The objectql door file's diff is the cast comparand as string → value: comparand plus comments; judgeComparand still routes on numberComparandDoorVerdict alone and turns narrows / door-refusal into an outcome. No file under any driver package is in the diff. Right.

  4. Every judged position routes through the door, read off engine.ts at the head: object-form where (narrowNumberComparands at :1024, then the comparand-type door at :1034), FilterArray where (:1110, after parseFilterAST, which itself ends in normalizeFilterComparandTypes at filter.zod.ts:2776), the per-aggregation filter (:16362 rooted at aggregations[i].filter, then the type door at :16387), having (narrowHavingNumberComparands at :16495 over the columns aggregatedRowColumnClasses classes numeric, after the type door at :16470 and the shape door at :16453). All six verbs and judgeFilter share lowerWhereFilterArray. Right.

  5. The dev's open question (objects, undefined, a Map), answered from the code. The comparand-type door refuses each at every literal position: judgeLiteralComparand refuses undefined by name, and refuses anything that is neither in isAcceptedFilterComparand's set nor an array nor a { $field } reference (a Map is a comparand there, not structure: isFilterNode is prototype-based). It runs on the object-form where, inside parseFilterAST for the FilterArray spelling, on the per-aggregation filter and on having, and the door ORDER really differs per position exactly as the dev says (number door first on object-form where and the per-aggregation filter; type door first on FilterArray and having). So a second refusal in the number verdict would answer one mistake in two vocabularies depending on where it was written. The objectql suite pins that the engine's message EQUALS normalizeFilterComparandTypes' own for a plain object, undefined and a Map, on both spellings, with no read. Option A is sound; no comparand position lets an object through. The one object shape that passes every door is { amount: { "a": 1 } } (a plain object with no $ key in the field's value position): that is filter STRUCTURE, which no field-aware door descends into, pre-existing, not numeric-specific, and unchanged by this diff (the dev's out-of-scope finding; ③6 below).

  6. Arrays at the equality slots. The comparand-shape door (filter-comparand-shape.ts, the implicit / $eq ruling and the $ne arm) refuses an array one door earlier at every position; the verdict still answers door-refusal for it (one rule), and the derived table omits those rows via isEqualitySlot so the engine suite pins the door that actually speaks. Right.

  7. The case-table change. caseFor now consults the verdict only for a judged slot (isJudgedSlot); without it, the widened verdict would have labelled the unjudged rows ($null: true, $exists: false, $empty: true) door-refusal while the engine never judges them. A correctness fix in the table derivation inside the claimed file, not a rule. Pinned three ways: the spec suite's per-case verdict check now asks the position first, a new spec test pins the three flag rows as passes while the same boolean at a judged slot is refused, and the objectql suite drives the passes rows to the recording driver unchanged ($empty at the door alone, as before).

  8. Words and rendering. comparandPreview renders a Date as Date(ISO) / Date(Invalid Date) and everything else through the shared shapePreview (JSON, 60-char bound); mustMention and the message use the same renderer; the 500-char bound is re-pinned with the longest non-string values. The three new clauses carry no tracker number (pinned). Right.

  9. Public surface. Three additive exports (NON_NUMERIC_VALUE_FORMS, NonNumericValueForm, NonNumericComparandForm) and three widened members (NumberComparandDoorVerdict.form, NumberComparandRefusalSite.value: unknown from string, .form; NumberComparandDoorRefusalCase.form). api-surface/data.json and export-origins/data.json each carry exactly the three new names, in sort order; the widened members keep their names so no other entry moves. Consumer census of the widened value at the head (packages, services, apps; tests excluded): no reader outside the two claimed files, so nothing downstream stops compiling. Right.

  10. Pins, cell by cell. Spec suite: the verdict for every judged type × 7 non-string values, the pass list, the non-numeric field types, rendering, table shape, per-filter copies of a Date / an array. Objectql suite (recording driver, InMemoryDriver's answer by construction since the refusal precedes driver resolution and zero reads are asserted): the whole table end to end; true / false / a Date / [10] at the per-aggregation filter ($gt, $lte) and at having over count / sum / a numeric min; FilterArray sugar and $and / $or / $not; judgeFilter; the numeric control at all three positions (driver receives $gt 10, count 2, groups r2 / r3). REST suite: 9 JSON-carried non-string shapes at where over the wire, true / a Date / [1] in-process at where, the per-aggregation filter and having (native and rows paths × total / top), the wire boolean at filter and having, zero reads, and the numeric control unchanged. Each new pin asserts code AND status, the field, and the position path. Which cells run in CI: SQLite, in Test Core. PostgreSQL: a named skip in CI for @objectstack/rest (ci.yml provisions OS_TEST_POSTGRES_URL for driver-sql, metadata-protocol and one runtime file only), so the PostgreSQL row's evidence is the dev's local PostgreSQL 16 run at this head; the cell is red-capable wherever the variable is set. MySQL: a named skip everywhere, NOT MEASURED. Memory: by construction only (no real InMemoryDriver cell; packages/rest and packages/objectql declare no driver-memory edge, as the dev says).

② Semver level

  • @objectstack/spec minor: three additive exports (minor on their own) and a published verdict that narrows (BREAKING, shipped minor under the launch-window convention that check-changeset-no-major applies to a declared (narrowing)). Right.
  • @objectstack/objectql minor: its source change is a cast, but its published behaviour narrows (the engine refuses more at every position); a patch would understate a declared narrowing and major is the launch-window guard's refusal. Right.
  • Not skip-changeset: both packages publish and both carry the changeset. Right.
  • Clause-②: no (narrowing), in the PR body and the changeset: clause ② is a new key on a published payload; three TS exports are not one, and the arm names the accept-set narrowing. Right. The fix(spec)!: title and the **BREAKING** banner agree with it.
  • ADR-0087 not-required (no-migration-prescription): nothing an author writes is removed or renamed (the verdict function, the table and the words keep their names; exports are only added), no stored row is read or rewritten, and a stored filter that compares a number field with true cannot be rewritten by a ledger entry because the number it meant is unknowable. The other categories are closed on facts as the marker says. The FROM → TO line is a before / after of the ANSWER, not a rewrite of a spelling, and check-adr-0087-registration (run by Check Changeset on this head, success) reads it that way, as it did for the predecessor door's changeset on main. Right.

③ Boundary flags

  1. Objects left to the comparand-type door (dev open question, options A / B): answered in ①5, option A is sound and pinned. Not escalated.
  2. Memory cell by construction: accepted. The door answers before any driver is resolved and the suite asserts zero reads, so InMemoryDriver's row is structurally every driver's; a real InMemoryDriver cell would test the driver, not the door, and would need a dependency edge neither package has. Not escalated.
  3. Case-table fix inside the file surface: a correctness fix, pinned (①7). Not escalated.
  4. PostgreSQL not in CI for @objectstack/rest, MySQL not measured: verified from ci.yml; the PostgreSQL row rests on the dev's local run at this head plus the fact the refusal precedes the driver. Named, not a FAIL: the shape the predecessor landed with, and nothing in this diff binds anything to a driver.
  5. The having and per-aggregation filter words, and card spec: the number-comparand refusal says "a declared number field" and "PostgreSQL with a server error" at having and the per-aggregation filter, where the column is aggregated and the engine evaluates the clause on every driver #20510. This diff does NOT do what spec: the number-comparand refusal says "a declared number field" and "PostgreSQL with a server error" at having and the per-aggregation filter, where the column is aggregated and the engine evaluates the clause on every driver #20510 asks, in either part: the refusal at having still reads compares a declared number field (the objectql suite pins that phrase at having.total.$gt), and the two NEW clauses (boolean, date) carry (PostgreSQL with a server error) at every position, including having and the per-aggregation filter, where the engine evaluates the clause; only the array clause is free of it. So spec: the number-comparand refusal says "a declared number field" and "PostgreSQL with a server error" at having and the per-aggregation filter, where the column is aggregated and the engine evaluates the clause on every driver #20510 proceeds after this landing, with its population grown by the two new clauses, and its fix will move the objectql having pin and the REST where phrase pin. Not this card's scope (triage made spec: the number-comparand refusal says "a declared number field" and "PostgreSQL with a server error" at having and the per-aggregation filter, where the column is aggregated and the engine evaluates the clause on every driver #20510 serial after it); not a FAIL here.
  6. Out-of-scope finding handed to the seat, not filed: where { amount: { "a": 1 } } answers 200 with no rows on InMemoryDriver and a driver 400 on SQLite / PostgreSQL through REST and engine.find, measured on the base and unchanged at the head. Filter structure, not a comparand, not numeric-specific. It is a class (a) defect with a measured reach, so it is the seat's to file (or dedupe), per Prime Directive chore: version packages #10. Not escalated; the seat owns the filing.
  7. The save-time door (no lint or save rule reads the number verdict; the population now includes a stored boolean or array): the family the predecessor's report already carries, noted.
  8. Generated artifacts and merges: the only generated files the diff touches are the two spec artifacts (①9), both in sort order; the second merge went through os-regen-merge.sh. Lint & Repo Gates is CI's reading of that family and had not concluded at the read.
  9. Check-runs on the head at the read: 33 runs, 25 concluded (22 success, 3 skipped: Build Docs, Console Pin Gate, Packed-tarball smoke), none red. Answered green: Build Core, Check Changeset (check-empty-changeset, check-adr-0087-registration, check-changeset-no-major), Type Check source gates / consumer gates / debt ledger, Spec property liveness, Governed Surface Queue Guard, Temporal Conformance (driver-sql on live PostgreSQL / MySQL, not this diff's REST cells), Dogfood Regression Gate 1–3 and Dogfood Verify CLI, Check PR Size, Check Documentation Links, the claim / single-writer / part-of guards, Auto Label, Flag docs. Not concluded at the read, not presumed green: Lint & Repo Gates (eslint and the repo check:* family, including the generated-artifact checks), Test Core (1/6) through (6/6) (the spec, objectql and REST SQLite pins in CI), Type Check · workspace. Their conclusions are the gate verdicts the seat reads before enqueue.

Implemented-by: claude/issue-20502-number-comparand-non-string
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 02:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit b057434 Sep 29, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20502-number-comparand-non-string branch September 29, 2026 02:24
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… commits that decided them (stage 4) (objectstack-ai#20548)

Part of objectstack-ai#20234
Clause-②: no

## What changed

This is stage 4 of the staged sweep: the `data/` remainder. It covers
the six `packages/spec/src/data/` files stage 3 (PR objectstack-ai#20533, landed
`03b19d9cfd`) left out because an open PR held them, and nothing else.
They are `object.zod.ts`, `filter-logic-conformance.ts`,
`object.form.ts`, `data-engine.zod.ts`, `data-engine.test.ts` and
`hook.form.ts`. Later stages cover the other areas, so this PR says
`Part of`.

The census below measured all six. Three of them carry comment or
docblock sites that cite a tracker number answering 404.
`data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts` carry
none, so they are not in the diff.

Every such site has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **19 sites on 19 lines in 3 files,
covering 9 numbers**. Each rewritten line now cites the commit in
`origin/main` history that decided what the line describes, and it says
in its own words what that commit decided. Where a PR number was already
on the line (`PR objectstack-ai#13529`), it stays beside the commit as the link.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 9 numbers: a search for each
number, with and without `#`, finds nothing there. So every anchor is a
commit: **9 distinct shas**. Stage 3 had already read these commits and
recorded them in PR objectstack-ai#20533's body. They were not copied from there. Each
one was re-read against the current line it anchors: its own message or
diff names the number it replaces, and it made the change the line
describes. `object.zod.ts` and `filter-logic-conformance.ts` moved on
`main` after stage 3 read them (PRs objectstack-ai#20521 and objectstack-ai#20523). Each site was
therefore re-read at this base, `03b19d9cfd`.

Only comments changed. Every source file keeps its line count (20 lines
out, 20 in, over 3 files), so no line citation into these files moves.
One of the 20 lines held no dead citation:
`filter-logic-conformance.ts:249`, the first half of a sentence reflowed
onto `:250`. No code token moves (see the guard below).

**No tracker number is added.** Every tracker number on an added line
was already in the hunk it replaces. `PR objectstack-ai#13529` stands on three added
lines, and on the three removed lines of the same hunks. It is the link
beside commit `9dac1ae01`, which stage 3 recorded the same way.

No reference page under `content/docs/references/` moved: none of the
rewritten docblocks projects into one (`check:docs` at the head: `226
generated files in sync`). The PR adds one `patch` changeset for
`@objectstack/spec` (see Changeset below).

## Census: the six files, before and after

**Instrument.** This is the instrument of stages 1 to 3. It sends REST
`GET /repos/objectstack-ai/objectstack/issues/N` without following
redirects, for every distinct number cited in `packages/spec/src/data`.
The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`,
`objectstack-ai/objectstack`, `framework`, `pre-` or `post-`;
- widened case-insensitively to `Pre-`, `POST-` and `Framework`, as in
stage 3;
- N of 100 or more, excluding `summon` heads.

Each site is classified by the TypeScript parser as a line comment, a
docblock, a block comment or a string.

Two cross-checks close the population. First, a raw `#N` count in each
of the six files equals the census rows plus the cross-repo rows in five
files. In the other two it is one higher, and the extra is a second
number after a slash inside a string (`objectstack-ai#5322/objectstack-ai#5134` in a `note`,
`objectstack-ai#6262/objectstack-ai#6433` in a test title). Both answer 200. Second, no spelled
citation (`issue N`, `PR N`, `card N`) occurs in any of the six.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end: 24 of 24 lit (200)
and 24 of 24 dead (404) over 8 checkpoints in the base run, and 21 of 21
lit and 21 of 21 dead over 7 checkpoints in the head run.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites, all of `data/` | dead sites, the six files | lines | files |
numbers |
|---|---|---|---|---|---|---|---|---|---|---|
| before | base `03b19d9cfd`, probed 2026-09-29T01:11:59Z to 01:15:49Z |
601 | 572 | 29 | 0 | **77** | 19 | 19 | 3 | 9 |
| after | head `53c9070dfd`, probed 2026-09-29T01:25:55Z to 01:29:35Z |
597 | 572 | 25 | 0 | **58** | 0 | 0 | 0 | 0 |

The head probe found no number newly dead since the base probe: the same
572 numbers answer 200. The base reading of 77 equals stage 3's after
reading at `96fd49caa2`.

**Per file.** Cited sites here are every in-repo citation the population
reads, live or dead.

| file | cited sites (base) | dead sites before | by class | dead sites
after |
|---|---|---|---|---|
| `object.zod.ts` | 120 | 15 | 8 docblock, 7 line comment | 0 |
| `filter-logic-conformance.ts` | 97 | 3 | 2 docblock, 1 line comment |
0 |
| `object.form.ts` | 31 | 1 | 1 line comment | 0 |
| `data-engine.zod.ts` | 48 | 0 | | 0 |
| `data-engine.test.ts` | 29 | 0 | | 0 |
| `hook.form.ts` | 0 | 0 | | 0 |

None of the 19 sites is a string, so this stage leaves no string token
behind.

## Per-number table

| number | sites / lines | anchor: what it decided |
|---|---|---|
| `objectstack-ai#8772` | 4 / 4, `object.zod.ts:2718`, `:2731`, `:2744`, `:2910` |
`75b7c240a`: Direction 2 of the 2026-08-16 maintainer ruling.
`ObjectSchema.create()` forces `required: true` on a `master_detail`
reference under `controlled_by_parent` and refuses an explicit
`required: false`. Raw parse stays tolerant, and runtime tolerance is
the ruling's other half. Its changeset records the measurement that only
the security gate closed that shape while the declaration surface
accepted it (`:2731`). ADR-0055 stays cited beside it. It is the same
anchor stage 3 gave `object.test.ts` |
| `objectstack-ai#10165` | 2 / 2, `object.zod.ts:818`, `:1036` | `801296050`:
`ttl.onlyWhen` with the canonical null predicate (maintainer ruling
2026-08-20, option A). One shared `onlyWhen` union, and both of
`retention.onlyWhen`'s conflicts mirrored. Its diff wrote both
`[objectstack-ai#10165]` blocks |
| `objectstack-ai#10347` | 3 / 3, `object.zod.ts:1006`, `:1042`, `:1049` |
`530c1df65`: the Archiver honours a declared `ttl`. It selects by the
ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at` /
`archive.after` otherwise (maintainer ruling 2026-08-20) |
| `objectstack-ai#10527` | 1 / 1, `object.zod.ts:1005` | `5649efbf9`: refuses a
diverging retention + ttl + archive triple at parse time. Its diff wrote
this very paragraph |
| `objectstack-ai#11195` | 1 / 1, `object.zod.ts:1791` | `b37231883`:
`UserActionsConfigSchema` adopts `group` / `hideFields` / `rowColor`
(the "last three" the line names) |
| `objectstack-ai#11408` | 1 / 1, `object.zod.ts:2189` | `f11fc61c5`: declares
`editMode` on the object document (maintainer ruling 2026-08-24, the
`objectstack-ai#10144` declare-or-rule-out family, which stays cited) |
| `objectstack-ai#13608` | 3 / 3, `object.zod.ts:2317`, `:2354`, `:2366` |
`fc9ba76a5`: `publicSharing.eligibility` is held at redemption, not only
at mint, fail-closed, with the undifferentiated `null` refusal. Its
changeset heads with objectstack-ai#13608. It is the same anchor stage 1 gave
`contracts/share-link-service.ts` |
| `objectstack-ai#13195` | 3 / 3, `filter-logic-conformance.ts:190`, `:250`, `:525` |
`9dac1ae01`, PR objectstack-ai#13529's squash commit, which stays as the link:
`$exists` means has-a-value on driver-memory's live mingo path, its
analytics face and driver-mongodb's `translateFilter` (the "last three
key-presence exits") |
| `objectstack-ai#12868` | 1 / 1, `object.form.ts:256` | `c459da6bc`: narrows the
per-option `default` key out of the form-view options vocabulary, which
offered a key nothing on that surface read. Commit `e808890958`, which
wrote this line, names objectstack-ai#12868 as the same offer-vs-door class |

The shas were checked at the base and again at `origin/main`
`288611e3e5`. Every one matches exactly one commit (`git rev-parse
--disambiguate`, count 1). Every one is an ancestor (`git merge-base
--is-ancestor`, exit 0 for 9 of 9). The control leg `e9584681a4` also
exits 0, and the repository is not shallow. For each commit, a grep of
its own message or diff finds the number it replaces. Seven of the nine
name it in the message. `fc9ba76a5` names it in its diff (20 lines,
including its changeset heading), and so does `c459da6bc` (8 lines,
including its changeset heading).

Wordings to check, each true of its commit:
- `object.zod.ts:2731` now reads 「closes that shape, and commit
75b7c24 records that the declaration and the enforcement disagree」.
The measurement was the card's. The commit's changeset records it: "only
the security gate closed that shape while the declaration surface
accepted it".
- `object.zod.ts:2189` reads 「Declared here by commit f11fc61's
maintainer ruling」, and `:2744` reads 「the other half of commit
75b7c24's ruling」. This is stage 3's wording for the same relation
(`object.test.ts`, 「the other half of commit 75b7c24's ruling」): the
commit that landed the ruling and quotes it.
- `object.zod.ts:1049` reads 「That is the whole of what [commit
530c1df] changed here」. Commit `52db1d1f2a` wrote the paragraph.
`530c1df65` is the change it describes.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `03b19d9cfd`
against head `53c9070dfd`. It uses the TypeScript parser's leaf tokens
(TypeScript from the head's lockfile), so template literals are scanned
in context, and it excludes JSDoc nodes. It ran over all 3 touched `.ts`
files. It is the stage-3 instrument, unchanged.

- Real run: 13,624 base tokens (object.zod.ts 8,774, object.form.ts
3,226, filter-logic-conformance.ts 1,624), **0 files with a token
change** (exit 0).
- Comment-insertion control (`object.form.ts`): 0 files changed, as
expected (exit 0).
- Positive control (a declaration inserted into `object.zod.ts`): 1 file
reads DIFFER at token 1629 (exit 1).
- Positive control (one digit changed inside the `objectstack-ai#5322/objectstack-ai#5134` `note`
string in `filter-logic-conformance.ts`): 1 file reads DIFFER at token
889 (exit 1).

Line balance: `object.zod.ts` +15 / -15, `filter-logic-conformance.ts`
+4 / -4, `object.form.ts` +1 / -1. Line counts are equal at base and
head: 3,240, 621 and 751.

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.
`Clause-②: no`: no export, key, value or type moves (the guard above).

Measured on the head's built package: `object.zod.ts` is
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
comments also reach `dist`:
- `9dac1ae01` appears in `dist/data/index.d.ts` (the
`filter-logic-conformance.ts` docblock) and in 4 bundled `.js` files;
- `fc9ba76a5`, `f11fc61c5` and `b37231883` each appear in 22 bundled
`.js` files, and `c459da6bc` in 12;
- the positive control, the pre-existing `object.zod.ts` sentence
「Fail-CLOSED at both points」, appears in 11 bundled `.js` files.

## Gates (head `53c9070dfd`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs` exits
0. The self-test passes 73 cases in 7 batteries. The live run judged 6
citations across 3 files: 3 resolve (`objectstack-ai#9138` twice, `objectstack-ai#11410`) and 3
resolve as a pull request (`objectstack-ai#13529`, the link).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at the head derived 79 families, and
all 79 exit 0. `--ran` reports 79 run, 0 NOT MEASURED, 0 unrun, and
exits 0. A full `turbo run build` of `./packages/*` ran first, under the
shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate
met an unbuilt prerequisite.
- `pnpm --filter @objectstack/spec run check:generated`: under the lock
against that build, `All 15 generated artifacts are up to date`, VERDICT
command-exit 0.
- **Tests and typecheck:**
- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
src/data` under the lock: Test Files 107 passed (107), Tests 3527
passed, 1 todo (3528), VERDICT command-exit 0. It covers every test in
`data/`, among them `object.test.ts`, which reads these schemas.
- The 13 spec suites outside `src/data` that read the touched files'
source text or pin their line numbers, under the lock: Test Files 13
passed (13), Tests 544 passed (544). They are stage 3's 12
(`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`,
`src/system/constants/platform-object-names.test.ts`,
`src/type-alias-convention.pin.test.ts`, `src/ui/dashboard.test.ts`)
plus `src/shared/union-author-message-pins.test.ts`, which pins
`data/object.zod.ts:855`.
- `pnpm --filter @objectstack/spec typecheck` under the lock exits 0,
including `check:test-typecheck` (53 files, 251 errors, 138 pinned
signatures held).
- **Lint, as a proven narrowing at the head:** `eslint
--no-inline-config --format json` over the 3 touched `.ts` files gives 3
files, 0 errors and 0 warnings. All 3 are in eslint's own population
(`isPathIgnored` is false for each). `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, which its own line 328
states), so a comment edit here cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base.** The branch forked from `03b19d9cfd`, stage 3's landing.
`origin/main` then moved two commits (`05077d4c26`, PR objectstack-ai#20532, and
`288611e3e5`, PR objectstack-ai#20536), and neither touches `data/`. `dispatch-gates`
flagged its derivation as stale because `scripts/regen-artifacts.mjs`
had moved, so `origin/main` was merged in (`53c9070dfd`, a clean merge
with no driver-deferred path) before the gates ran. The PR's delta
against `origin/main` is exactly its 4 files. `origin/main` has since
moved two more commits: `7e36a3cd7c` (PR objectstack-ai#20531) and `ba5927f714` (PR
objectstack-ai#20460). Neither touches `data/` or anything the gate derivation reads,
and a re-derivation prints the same 79 commands. A no-driver
`merge-tree` of the head onto `ba5927f714`, from a bare shared clone,
exits 0. So there is no second merge.
- **Open PRs, re-read at 2026-09-29T02:01Z:** 9 open PRs, and none
touches any of the six files. The `data/` files open PRs touch are
objectstack-ai#20458's `analytics*` files, objectstack-ai#20504's `driver/turso.*`, and objectstack-ai#20545's
`filter-number-comparand-declared-type.*`, which is disjoint. Since the
claim, PR objectstack-ai#20460 has landed (`ba5927f714`) without touching
`filter-subtree-provenance.ts`. That file's 3 dead sites are outside
this claim's fence, so they are left for a later stage.
- **The rung.** Two anchored changes also have ADR-0087 entries in
`packages/spec/src/migrations`: `cbp-master-detail-required-forced` for
objectstack-ai#8772, and `form-view-option-default-retired` for objectstack-ai#12868. The second
entry's own header names commit `c459da6bc`. This PR takes the commit
rung, as stages 1 to 3 did. The D3 id is the more durable in-repo
record, if the ruling's first rung is later read to include those
entries.
- **What stays in `data/` after this stage: 58 dead sites.**
- **12 comment sites in files other open work still holds.**
`analytics.zod.ts`, `analytics-strictness-batchd.test.ts` and
`analytics-date-range-two-bound-window.test.ts` hold 5 (objectstack-ai#20300, PR
objectstack-ai#20458). `driver/turso.zod.ts` and `driver/turso.test.ts` hold 4
(objectstack-ai#20437, PR objectstack-ai#20504). `filter-subtree-provenance.ts` holds 3. It was held
by objectstack-ai#20367 and is now free (see above).
- **3 comment sites stage 3 left on purpose.** They are the test-read
`[objectstack-ai#6259]` marker at `api-derivation.ts:163`, the test comment at
`api-derivation.test.ts:232` that names it, and `field.zod.ts:370`,
whose `objectstack-ai#6111` is objectui's number.
- **43 string sites**, left as tokens: 41 test strings (2 of them in the
held analytics and turso test files) and the 2 exported
`AGGREGATION_CASES` note strings in `aggregation-conformance.ts`
(`:398`, `:407`, objectstack-ai#11065), which objectstack-ai#20489's claim holds.
- **Outside `data/`,** the card's other remaining items are unchanged:
the migrations and ui areas, the `liveness/**` notes, the `why` strings,
the `PROVENANCE_WAIVERS` reason, and `rest-server.zod.ts`.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`. No
test file is touched here, so all 3 touched files are in its judging
population.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…L only at `where` (objectstack-ai#20577)

Fixes objectstack-ai#20510

Clause-②: no

## What changed

Two false phrases in the number-comparand refusal, at two positions:

1. **`having` said "a declared number field".** The column at `having`
(`total`, an aggregation alias or a groupBy projection) has no declared
   `FieldType` of its own — the door hands the verdict a synthesized
   `{ type: 'number' }` for whatever the engine classes numeric
(`aggregatedRowColumnClasses`). It now says "a numeric aggregated
column".
2. **The `not-a-number` / `boolean` / `date` clauses named PostgreSQL's
server
error at every position.** Only `where` (both spellings) ever reaches a
live driver bind; `having` and the per-aggregation `filter` are
evaluated
   by the engine itself, in JS, before any row is read, on every driver
alike — no driver ever sees the comparand there. The clause now appears
   only where the driver actually binds it.

**Measured, and the card's own claim corrected:** the per-aggregation
`filter`'s column is a real declared field, not an aggregated one — that
position narrows the object's RAW source rows *before* aggregation runs,
against the object's declared field map (confirmed by both the door's
own
header and the existing pin at `aggregations[1].filter.f_number.$gt`,
which
already reads `'f_number' compares a declared number field`). So only
its
PostgreSQL clause moves; its "declared … field" wording is unchanged.

### Before / after

| position | column | before | after |
|:--|:--|:--|:--|
| `where` | declared field | `… compares a declared number field against
"abc" …, which is not a number: it has no numeric reading, and backends
answer it differently (PostgreSQL with a server error).` | unchanged,
byte for byte |
| per-aggregation `filter` | declared field | `… compares a declared
number field against "abc" …: it has no numeric reading, and backends
answer it differently (PostgreSQL with a server error).` | `… compares a
declared number field against "abc" …: it has no numeric reading.` |
| `having` | aggregated column | `… compares a declared number field
against "abc" at having.total.$gt, which is not a number: it has no
numeric reading, and backends answer it differently (PostgreSQL with a
server error).` | `… compares a numeric aggregated column against "abc"
at having.total.$gt, which is not a number: it has no numeric reading.`
|

The accept set is unchanged — no refusal added or removed, words only.

### Mechanism

`NumberComparandRefusalSite` (`@objectstack/spec`) gains two optional
fields,
both defaulting to the pre-existing `where` behaviour so an existing
site (or
one built before this change) renders identically:

- `aggregated?: boolean` — the column is an aggregated-row column, not a
  declared field. Only `having` sets it.
- `boundByDriver?: boolean` — this position reaches a live driver bind.
Only
  `where` sets it `true`; default is `true` for backward compatibility.

The engine door (`@objectstack/objectql`) passes both at its three call
sites — it already knew which position it was building a refusal for; no
second rule, and no driver-level change.

## Acceptance notes

- Out of scope, noted only: a `{ $field }`-adjacent structural filter
shape
(`where { amount: { "a": 1 } }`) was already flagged by PR objectstack-ai#20545's
at-tier
review as a class (a) finding for the filing seat to pick up; this PR
does
  not touch it.
- `packages/rest`'s `data-number-comparand-door.test.ts` asserts no
phrase at
`having` / the per-aggregation `filter` (only status/path), and its
`where`
phrase pin is unaffected — left untouched, per the claimed file surface.

## Tests (all green, fresh builds)

- `@objectstack/spec` build, then `vitest run
filter-number-comparand-declared-type.test.ts`
— **47/47 passed**, including new cases for `aggregated` /
`boundByDriver`
and a byte-for-byte pin that the pre-existing `where` message is
unchanged;
  `pnpm typecheck` — clean (no new errors).
- `@objectstack/objectql` build, then `vitest run
engine-number-comparand-declared-type-door.test.ts
engine-aggregate-having-temporal-door.test.ts` — **85/85 passed**; `pnpm
typecheck` — clean.
- `@objectstack/rest` build, then `vitest run
data-number-comparand-door.test.ts`
— **4 passed, 8 skipped** (PostgreSQL/MySQL cells skipped as before; no
code
  change was needed there).
- `check:api-surface`, `check:dual-source-exports`,
`check:entry-nameability`,
`check:exported-any`, `check:lean-entry-closure` — all green against the
fresh build (the widened `NumberComparandRefusalSite` type doesn't move
the
  name-level api-surface snapshot).
- Derived gate sweep (`dispatch-gates.mjs`, 86 commands): all green
except 3
  that need the WHOLE workspace built (`check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:type-check-debt --re-measure`) —
out
  of this diff's local scope, left to CI.

Full command list and exit codes are in the report comment on the issue.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ongs is refused INVALID_FILTER / 400 on every driver (objectstack-ai#20546) (objectstack-ai#20744)

Fixes objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#5240 words | 400, the objectstack-ai#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:

```text
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 objectstack-ai#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 fbec216 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 objectstack-ai#20501 / objectstack-ai#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

- **Out of scope, reported to the PM, not filed:** the two controls
still answer two ways, because this card's direction keeps them
accepted. `{ owner: { region: 'NA' } }` on a `lookup` gives memory 200
with no rows and SQL 400. `{ meta: { a: 1 } }` on a `json` field gives
memory 200 with 1 row and SQL 400. So does the undeclared `id` key (`{
id: { a: 1 } }`: memory 200 no rows, SQL 400), because the registry's
declared map carries no `id`. Spec's `FilterCondition` declares the
nested-relation form, but no data-path driver serves it. objectstack-ai#20546 is not
the card for that.
- File and media fields keep the objectstack-ai#8371 carve-out and stay unjudged. `{
photo: { url: 'x' } }` answered memory 200 with no rows (on fresh rows)
and SQL 400. A legacy inline value could still match on memory.
- At `where`, a `{}` under a judged column is now answered in the
engine's words instead of each driver's objectstack-ai#5240 words, with the same
`INVALID_FILTER` / 400 envelope. Under a column this arm does not judge,
`{}` keeps the drivers' refusal.
- `findNonNumericComparand` (internal, tests only) still answers the
number arm alone. When the walk's first refusal is the new arm's, it
answers `null`, and its docblock says so.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ainst a boolean field is refused like a non-boolean string (objectstack-ai#21382) (objectstack-ai#21404)

Fixes objectstack-ai#21382
Clause-②: yes (narrowing)

## What this changes

One verdict, widened, as triage directed on the card (grade
`5949762416`, inheriting the number door's direction `5877498426`): the
published boolean-comparand verdict now refuses any comparand against a
declared `boolean` / `toggle` field (or a `formula` returning `boolean`)
that is outside its accepted set. The engine's boolean arm consumes that
verdict and nothing else; it carries no second rule. This is the boolean
twin of PR objectstack-ai#20545 (`b05743433b`) on the number door, and it follows that
PR's shape and words where the two contracts have the same parts.

- **The verdict**,
`packages/spec/src/data/filter-boolean-comparand-declared-type.ts`.
`booleanComparandDoorVerdict(field, comparand)` answers `door-refusal`
(`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`,
`0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list
member. The string rule is byte-for-byte what PR objectstack-ai#21372 published. The
accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`,
`"false"`, `"1"`, `"0"`; `null` is still the null test.
- **A `bigint` is read as the number it names**: `1n` / `0n` narrow like
`1` / `0`, and any other `bigint` is refused as a number. See *The
bigint reading* below for why this is the only reading that gives one
answer at every position.
- **What still passes**: a boolean, `null`, a `{ $field }` reference,
and every value outside the comparand-type door's accepted set
(`undefined`, a plain object, a `Map`). That door already refuses those
on every field, in its own words (see *Objects* below).
- Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`,
`array`) and the types `NonBooleanValueForm` /
`NonBooleanComparandForm`. The refusal's `form` widens from
`NonBooleanStringForm` to `NonBooleanComparandForm`, on
`BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and
`BooleanComparandDoorRefusalCase`. The site's `value` was already
`unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s
signature is unchanged (relevant to objectstack-ai#21376, which will consume it).
- Three refusal clauses. None names a backend, because PostgreSQL's
server error was measured at `where` only. The engine evaluates the
per-aggregation `filter` and `having` itself, and this contract has no
driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite
number by name, not as JSON (`NaN` would otherwise print as `null`, the
null test).
- The derived case table gains a `value` group: `-1` at `$ne` on every
judged field, and `2` and a `Date` at every judged position of
`f_boolean`. An array sits at every judged position except the equality
slots, where the comparand-shape door refuses one first. Beside them are
four passing rows. The `2` / `-1` reading rows, and a new `0.5` row, now
derive refusals. `freshComparand` copies a `Date` and an array per
filter, as the number table does. No `bigint` row is in either table, so
every filter a suite builds survives `JSON.stringify`.
- **The arm**,
`packages/objectql/src/boolean-comparand-declared-type-door.ts`:
comments only. The walk already routed every comparand to the verdict
and carried the refused value as written.
`number-comparand-declared-type-door.ts` (the shared walk) is untouched.
- **Tests**: the spec contract suite, the objectql arm suite, and a new
REST three-dialect cell
`packages/rest/src/data-boolean-comparand-door.test.ts`, beside
`data-number-comparand-door.test.ts`. In that cell SQLite always runs,
and PostgreSQL / MySQL are named skips without their URL.
- **Generated**: `packages/spec/api-surface/data.json` and
`export-origins/data.json`, three added lines each, regenerated by
`gen:api-surface` / `gen:export-origins` after `check:generated` named
exactly those two.
- **Changesets**, one per published package moved, both BREAKING `minor`
with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker
(`not-required (no-migration-prescription)`):
`.changeset/21382-spec-boolean-comparand-non-string.md` and
`.changeset/21382-objectql-boolean-comparand-non-string.md`.

## Clause-②, measured

`node scripts/pm/check-widening-tells.mjs` on `git diff
69a12a0...HEAD`:

- `--declaration no`: exit 4, three T3 tells. These are the three new
rows in `packages/spec/api-surface/data.json` (lines 469, 505, 507).
- `--declaration yes`: exit 0.

So this PR declares `Clause-②: yes (narrowing)`. It adds an export
listing row, and what it changes in behaviour is a narrowing. No T1 / T2
/ T4 tell fired.

## Before and after

A declared `boolean` field, two rows (`rt` true, `rf` false), through
`engine.find` / `engine.aggregate`. The probe is a scratch script
against freshly built dists, not a committed test. A `where` cell reads
implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base
`69a12a0952`. After: this branch, spec and objectql source as at
`196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in
this container, started for the run and stopped after.

| position | comparand | before: memory · SQLite · PostgreSQL 16 |
after, all three |
|:--|:--|:--|:--|
| `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row,
both, the false row · the same · **500 `DATABASE_ERROR` at every slot**
| 400 `INVALID_FILTER` at every slot |
| per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three |
400 |
| `having` on a groupBy of the field | the same | no group, no group,
both, the false group on all three | 400 |
| `where` | an array `[true]` as a `$in` member (the card) | **the false
row (200)** · a driver 400 · a driver 400 | 400, in the contract's words
|
| per-aggregation `filter` / `having` | the same | count 1 / the false
group on all three: the member silently dropped | 400 |
| all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the
comparand-shape door) | the same |
| `where` | `2n` (in-process) | no row · no row · 500 | 400 |
| `where` | `1n` (in-process) | **no row · the true row · the true row**
(`$ne 1n`: both rows on memory) | the true row on all three |
| all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same,
same words |
| all three | `true`, `1`, `"true"` (the controls) | the true row, count
1, the true group | the same |

## Premise check, and the dispatch's hypotheses

- **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null`
for every non-string except `1` / `0`, the verdict answered `passes`,
and the reading rows read `unread(2)` / `unread(-1)`. The card's table
reproduces on memory, SQLite and PostgreSQL (rows above). The number
contract's non-string branch was the template.
- **H2 holds: no walk change.** `judgeBooleanComparand` already passed
`value: comparand` and `form: verdict.form` straight through, so
widening the verdict reaches `where` (both spellings), the
per-aggregation `filter` and `having` with no code change in objectql.
The shared walk file is not in the diff.
- **H3**: the after-column is re-measured on memory, SQLite and
PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server
is available in this container. The REST cell's MySQL leg is a named
skip.
- **H4 holds.** A `formula` returning `boolean` is still refused one
door earlier, by the unmaterializable-field door, with `INVALID_FIELD` /
400. The arm suite's NAMED DIVERGENCE test drives every formula row,
including the new `value` row on `f_formula_boolean`.

### The bigint reading

The ruling does not name `bigint`. Measured on the base, `1n` against
the boolean field gave different answers per driver at `where`: no row
on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave
PostgreSQL's 500. That is the card's defect class, through an in-process
caller. The comparand-type door rewrites a `bigint` to its number. It
runs AFTER this door on the object spelling of `where` and on a
per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling
and at `having`. So:

- passing a `bigint` leaves the divergence;
- refusing every `bigint` would refuse `1n` on one spelling and narrow
it on the other;
- reading it as the number it names gives one answer at every position.

This is not a widening. A `bigint` passed the verdict before, so it was
accepted, and now it is either narrowed correctly or refused. The arm
suite pins it on both spellings and at all three positions.

### Objects: served by the comparand-type door, deliberately

Triage's list names objects. On the base, an object comparand against
the boolean field is already refused with `INVALID_FILTER` / 400 by the
comparand-type door, on all three drivers and at all three positions
(table above). The widened verdict passes such a value rather than
refusing it a second time. The reason is the one PR objectstack-ai#20545 measured: the
engine runs the two doors in a different order per position, so a second
refusal would answer one mistake with two sets of words. The arm suite
pins it: for a plain object, `undefined` and a `Map`, the engine's
message equals the comparand-type door's own message.

## Tests (at `196afd75cd`, the final head)

Each heavy run went through `scripts/pm/os-verify-lock.sh` with exit
codes from the lock's `VERDICT command-exit` lines.

- **`@objectstack/spec`**:
`filter-boolean-comparand-declared-type.test.ts` 34/34. `--project
local`, run as two `--shard` halves to fit the container's foreground
cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599
files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck`
holds its ledger (52 files / 246 errors, none added).
- **`@objectstack/objectql`**:
`engine-boolean-comparand-declared-type-door.test.ts` 34/34, and
`engine-number-comparand-declared-type-door.test.ts` still green (its
boolean-arm partition reads the widened verdict). `--project local` as
two shards: 182 files / 3583 passed, and 182 files / 3777 passed.
`typecheck` exit 0 (ledger 40 / 234, none added).
- **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with
`OS_TEST_POSTGRES_URL` set: 6 passed, 3 skipped (the MySQL leg). The
SQLite and PostgreSQL legs both ran. `--project local` (no live URL):
255 files, 4808 passed, 322 skipped. `typecheck` exit 0 (test layer 0 /
0).
- **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's):
1. Population, from `eslint.config.mjs` itself: all 5 touched lintable
files answer `isPathIgnored` false. The other 4 changed files are JSON /
Markdown, outside its globs.
2. `eslint --no-inline-config --format json` over them: 5 files, 0
errors, 0 warnings.
3. The config never enables type-aware linting (no
`parserOptions.project`, no `projectService`), so this diff cannot move
any untouched file's verdict.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran`
reconciles 90 derived / 90 run / 0 NOT-MEASURED.
`check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET,
because the workspace was not fully built. It was re-run after a full
`turbo run build`, and exited 0.

## Ablation (reverse verification)

From the committed head `196afd75cd`, with
`scripts/ablation-replace.mjs` in wrap mode. The mutation lives only
while the tool's trap is armed. It removes the widening at its one
routing line in the verdict: `if (form === null) return { verdict:
'passes' };` gains `|| (globalThis as Record[string,
unknown]).ABLATION_21382 === undefined` (angle brackets spelled as
square brackets here), so every non-string passes again. The string rule
is untouched.

- **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After
`pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found
the marker in 4 built files (exit 0). Results:
- spec suite: **3 failed** / 31 passed (the non-string verdict, the
bigint reading, the `value` group);
- objectql arm suite: **5 failed** / 29 passed (the GUARD's form set,
and the `where`, per-aggregation `filter`, `having` and bigint tests);
- REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on
both the SQLite and PostgreSQL legs).
- The control tests stayed green on every suite. The direction is the
expected one: red.
- **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff
HEAD` empty, and `git status --porcelain` empty for the whole tree.
After a rebuild, `ablation-dist-preflight --absent` found the marker
absent from all 230 built files (exit 0). Results: spec 34/34, objectql
34/34, REST 6 passed / 3 skipped.

## Acceptance notes

- `comparandPreview` is a private copy in the boolean module. The number
module's copy is private, and PR objectstack-ai#21390 holds that file, so this PR does
not touch it. Moving both copies into `filter-comparand-refusal-text.ts`
is a consolidation for whichever PR next touches both. No carrier is
named.
- Two comments outside this claim's file surface still describe the
string half only: `engine.ts` around the `narrowNumberComparands` call
("any other string is refused"), and the shared walk's `[objectstack-ai#21333]` header
section. Both are incomplete, not false. No carrier is named.
- The REST cell's live legs are red-capable and un-run in CI (no job
provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the
number cell. This PR carries their local PostgreSQL run.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

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 protocol:data size/l tests tooling

Projects

None yet

2 participants