Skip to content

feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar - #20414

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20336-numeric-comparand-verdict
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20336-numeric-comparand-verdict

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20336
Clause-②: yes

What this adds

This is lane (1) of the split triage routed on #15661's precedent: the contract for the number-comparand declared-type door, plus the platform's one numeric grammar for a string. It adds one module to @objectstack/spec/data, filter-number-comparand-declared-type.ts, beside filter-text-operator-declared-type.ts, together with its test, the barrel line, the regenerated api-surface/data.json and export-origins/data.json, and a minor changeset. No door is written here, and no evaluator changes. The engine door and its three-driver, REST and per-aggregation filter pins are #20351's, which stays open and is Blocked-by: this card. The write side's string half adopts the same grammar under #20309, which also stays open.

The new exports (27, all additive; api-surface/data.json +27 / -0):

  • The grammar
    • NUMERIC_STRING_PATTERN.
    • parseNumericString(s) returns the number, or undefined.
    • readNumericString(s) returns the number, or one of eight named forms (NON_NUMERIC_STRING_FORMS).
    • NUMERIC_STRING_GRAMMAR_CASES is the table of forms: 41 rows, each with its reason.
  • The verdict
    • numberComparandFieldVerdict(field) returns judged / not-judged / deferred.
    • numberComparandDoorVerdict(field, comparand) returns one of:
      • door-refusal (INVALID_FILTER / 400, with a form);
      • narrows (with the number);
      • passes;
      • deferred.
    • NUMBER_COMPARAND_DOOR_JUDGED_TYPES is NUMERIC_VALUE_TYPES itself, by identity.
    • NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS / NUMBER_COMPARAND_DOOR_LIST_OPERATORS.
  • The words: numberComparandRefusalMessage(site, context?). It names the field, the declared type, the bounded comparand, its position and what is wrong with the string, all ahead of the remedy.
  • For the engine suite
    • NUMBER_COMPARAND_DOOR_FIXTURE has one field per FieldType, plus the formula variants.
    • NUMBER_COMPARAND_DOOR_CASES has 136 derived cases: 52 refusals, 23 narrowings, 60 passes, 1 deferred.

Decisions this contract takes (each argued in the module header; the four-axis reasons are below)

  1. The grammar is a JSON number literal that names a finite double. Its forms were checked one by one against the card's A2 list (NUMERIC_STRING_GRAMMAR_CASES is the record):

    • Admitted: "12", "-3", "12.5", "0.10", "1e3", "2.5E+3", "1e-7", "1e+21".
      • Every String(n) for a finite n is admitted and parses back to n. This is pinned as a round-trip property over 418 numbers.
      • So a URL query is never refused: ?amount=5 is lowered to an implicit { amount: "5" } in metadata-protocol's findData.
      • Neither is URLSearchParams, nor a plain CSV cell.
      • Exponents are admitted because String(1e-7) is "1e-7".
    • Refused, and Number() would have read them: "", " ", " 12 ", "0x10", "0o17", "0b101", "+5", ".5", "5.", "007".
    • Refused: "Infinity", "NaN", "1e400", "1,000", "1.000,5", "1_000", "1 000", full-width digits, U+2212, and every {placeholder}.
  2. The door narrows a numeric string to its number, copy-on-write.

    • This is the pattern the comparand-type door already uses for an exact-range bigint, and the write side's "store the parsed number".
    • Left as a string, "12" is read three ways:
      • JS loose equality and relational comparison coerce it (driver-memory's matcher, read at source).
      • SQLite applies numeric affinity.
      • driver-mongodb compares by BSON type: its filter compiler coerces temporal comparands only (read at source).
  3. summary is judged. Its column is numeric on every SQL dialect: numericColumnFor is defined for every judged type, and that is pinned. The write door exempts summary through COMPUTED_VALUE_TYPES, but that set answers who writes, not what may be compared. This answers A1.

  4. formula is judged by its returnType, through the text door's own FORMULA_RETURN_TYPE_AS_FIELD_TYPE:

    • number is judged;
    • text, boolean and date are not number fields;
    • an absent returnType is deferred.

    As with the text door, no formula filter reaches the engine seam, because the unmaterializable-field door refuses every one of them first with INVALID_FIELD. The case table says the engine suite must partition those rows out. This also answers A1.

  5. Judged positions: implicit equality; $eq / $ne / $gt / $gte / $lt / $lte; and each member of $in / $nin / $between.

    • Pinned: these, the seven text operators, and $null / $exists partition FieldOperatorsSchema's keys exactly.
    • Only string comparands are judged. A number, null, a Date, a boolean or a bigint passes.
  6. A {placeholder} against a number field is refused, not stepped around.

    • Every filter token resolves to a user or organization id, a YYYY-MM-DD day or an ISO instant (resolveFilterToken in @objectstack/core). None of these is numeric, so a resolved token reaches PostgreSQL as the same 500.
    • The field-aware doors run before the token resolver.
  7. A blank string is refused.

  8. The words and the envelope (A4): no new error code. INVALID_FILTER / 400 is spelled as a literal, as filter-comparand-type.ts does, and pinned to StandardErrorCode. Each form's clause states only what holds for that form, so "+5" is not claimed to diverge between backends. Every refusal in the case table fits inside the 500-character client bound, and that is pinned.

Four-axis reasons (for the grammar and the narrowing)

Premise check (rule 6)

  • normalizeFilterComparandTypes takes no schema. This was confirmed at ab6fb027, which is why this is a new module (the triage retriage route).
  • The precedent's shape matches A1: sets referenced by identity, a pure verdict, a fixture, and a derived case table.
  • A3 holds: the verdict needs no spec change beyond the new module. @objectstack/objectql imports @objectstack/spec/data already. Its build closure builds against this spec (turbo, 14/14 tasks at b49a49b0), and check:lean-entry-closure holds its admitted set (15 packages).
  • No package imports the new module yet. git grep -l "filter-number-comparand-declared-type" -- packages finds only the barrel, the module's own test and the generated export-origins/data.json shard.

Tests (at b49a49b0 unless noted)

  • pnpm --filter @objectstack/spec typecheck: exit 0. check:test-typecheck OK, with the new test file compiled and no debt added.
  • vitest run --project local (spec test): 562 files, 16551 tests passed (1 todo).
  • vitest run --project repo (spec test:repo): 35 files, 634 tests passed. It also passed at a5680789, before the main merge.
  • The new test file: 34 tests. It pins these, in both directions:
    • the grammar table;
    • the round-trip property;
    • the stricter-than-Number() list;
    • the FieldType census;
    • summary's numeric column;
    • the operator partition;
    • the envelope literal;
    • that every form's words differ and carry no tracker number;
    • the 500-character bound and front-loading;
    • fixture legality (FieldSchema / ObjectSchema);
    • the case-table agreement with the verdict;
    • parseFilterAST accepting every case.
  • pnpm --filter @objectstack/spec check:generated at b49a49b0, after rebuilding from the merged tree: all 15 artifacts are up to date.

Gates (dispatch-gates --commands --repo objectstack-ai/objectstack at b49a49b0: 85 derived)

  • 83 exited 0, and 2 are NOT MEASURED (exit 3, PREREQUISITE NOT MET):

    • check:dual-build-cjs-loads: needs every package built.
    • check:type-check-debt: needs the full build closure.

    CI runs both.

  • dispatch-gates --ran: 85 derived families are accounted for (83 run, 2 NOT-MEASURED, derived from the recorded exit 3), 0 UNRUN.

  • For comparison, the dispatch list derived at ab6fb027 had 72 lines. The diff and changeset added 13 families, all of them run above:

    • check-empty-changeset ×2
    • release-rehearsal-clone --self-test
    • spec check:generated
    • check:engine-double-contract
    • check:objectql-double-limit
    • check:objectui-changeset
    • check:pm-changeset-deadline-census
    • check:pm-widening-tells
    • check:query-options-erasure
    • check:type-check-coverage
    • check:type-check-debt
    • check:where-matcher
  • On the first pass these gates needed a prerequisite and were rerun green:

    • check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched at depth 1 on this shallow clone.
    • check:doc-formula-expressions and check:lean-entry-closure needed the @objectstack/objectql closure built.

Acceptance notes


Generated by Claude Code

…meric-comparand-verdict

# Conflicts:
#	packages/spec/src/data/index.ts
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

10 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 NumberComparandDoorRefusalCase; a string literal in NumberComparandDoorVerdict; a string literal in numberComparandDoorVerdict))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in NumberComparandDoorRefusalCase; a string literal in NumberComparandDoorVerdict; a string literal in numberComparandDoorVerdict))
  • content/docs/data-modeling/field-types.mdx (via returnType (symbol, a field of const object NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS; a field of interface NumberComparandDoorCaseBase; a field of interface NumberComparandDoorFieldMeta; a field of interface NumberComparandDoorFixtureField; a field of interface NumberComparandRefusalSite), summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/data-modeling/fields.mdx (via summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/data-modeling/formulas.mdx (via returnType (symbol, a field of const object NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS; a field of interface NumberComparandDoorCaseBase; a field of interface NumberComparandDoorFieldMeta; a field of interface NumberComparandDoorFixtureField; a field of interface NumberComparandRefusalSite))
  • content/docs/data-modeling/validation-rules.mdx (via returnType (symbol, a field of const object NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS; a field of interface NumberComparandDoorCaseBase; a field of interface NumberComparandDoorFieldMeta; a field of interface NumberComparandDoorFixtureField; a field of interface NumberComparandRefusalSite), summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/getting-started/common-patterns.mdx (via returnType (symbol, a field of const object NUMBER_COMPARAND_DOOR_FIXTURE_FIELDS; a field of interface NumberComparandDoorCaseBase; a field of interface NumberComparandDoorFieldMeta; a field of interface NumberComparandDoorFixtureField; a field of interface NumberComparandRefusalSite), summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in NumberComparandDoorRefusalCase; a string literal in NumberComparandDoorVerdict; a string literal in numberComparandDoorVerdict))
  • content/docs/protocol/objectql/schema.mdx (via summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/protocol/objectql/types.mdx (via summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))

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

  • content/docs/releases/v16.mdx (via summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/releases/v17/17-0.mdx (via summaryOperations (symbol, a field of interface NumberComparandDoorFixtureField))
  • content/docs/releases/v17/17-1.mdx (via INVALID_FILTER (literal, a string literal in NumberComparandDoorRefusalCase; a string literal in NumberComparandDoorVerdict; a string literal in numberComparandDoorVerdict))
  • content/docs/releases/v17/17-4.mdx (via INVALID_FILTER (literal, a string literal in NumberComparandDoorRefusalCase; a string literal in NumberComparandDoorVerdict; 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
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 12 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 — 136 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 40b315b03345e334069dd454aecaf7016adbea4f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 40b315b03345e334069dd454aecaf7016adbea4f

⚠️ 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 40b315b03345e334069dd454aecaf7016adbea4f → 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: 93ec07654bed8f640cde61920756fb0f74d10d85
Local-runs: none

Read for this record: card #20336 (body and all eight comments, the seat's ACCEPT 5868030228 included, treated as a claim to test), PR #20414 (body, the 6-file list, the net diff against main at the head), the head's check-runs, the linked cards #20309 and #20351 with their threads, and origin/main for the precedent (filter-text-operator-declared-type.ts), the sets and schemas the diff references. Nothing was built, run or re-run; the check-runs on the head are the gate verdicts.

① Derived judgments

Public surface and accept sets.

  1. Twenty-seven exports are added to @objectstack/spec/data, all additive: api-surface/data.json +27/-0 and export-origins/data.json +27/-0, each row resolving to src/data/filter-number-comparand-declared-type.ts. The module has exactly 27 export declarations, so the generated files are a projection of the module, not a hand edit. Main's [finding] An RLS predicate comparing two fields of different comparison classes (text vs number, text vs image) passes os validate; on driver-sql the using read answers 400 while the check insert is admitted and stored #20347 rows are retained. RIGHT.
  2. No accept set moves in this release: no door, evaluator, validator or driver reads the module; parseFilterAST, normalizeFilterComparandTypes and record-validator.ts are untouched, and no package imports the new file but its own test and the barrel. The changeset says so in its own words. RIGHT, and section ② rests on it.

The contract the module declares, decision by decision.

  1. The grammar: a string is numeric when its whole content is a JSON number literal (RFC 8259 §6) naming a finite double; parseNumericString answers Number(s), which equals JSON.parse(s) for every admitted string (pinned). The String(n) round-trip is pinned over 418 values, so no caller that stringifies a legal number is refused. NUMERIC_STRING_PATTERN is linear (each digit run is closed by a literal or the anchor). RIGHT.
  2. Exponent forms (1e3, 2.5E+3, 1e-7, 1e+21) are admitted. RIGHT; the "plain decimal" wording is answered under ③.
  3. Refused, each with a named form and a remedy: blank and whitespace-only (empty), surrounding whitespace (padded), 0x/0o/0b (radix-prefix), Infinity/NaN/1e400 (non-finite), locale grouping and decimal commas (digit-separator), +5/.5/5./007 (non-json-spelling), and everything else (not-a-number). The exact list of strings Number() reads as finite that this grammar refuses is pinned by membership, so a later loosening goes red on purpose. Hex and padded strings are refused by name in record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309's direction 1; the class value contract is z.number().finite(). RIGHT.
  4. A {placeholder} is refused, not stepped around. classifyFilterToken names any brace-wrapped token; resolveFilterToken in packages/core/src/utils/filter-tokens.ts answers a user id, an organization id, a calendar day or an instant, or throws for a record-context token. No token names a number, so a resolved token is the same PostgreSQL bind failure as "abc". The temporal door records that it runs before resolveWhereTokens, so the sibling door meets the token unresolved; were objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351 to seat it after resolution, the resolved id would fall to not-a-number and still be refused. Either order refuses. RIGHT.
  5. A numeric string is narrowed to its number, copy-on-write. The comparand-type door already narrows an exact-range bigint this way (kind: 'narrow' in filter-comparand-type.ts), and record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309's census answer 5863923799 chose "store the parsed number" for the write side, so one string has one reading at both doors. Left a string, "12" is read by JS coercion on memory, by affinity on SQLite and by BSON type on MongoDB. RIGHT. Consequence for objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351: the door must hand the driver c.expectedFilter() and its pins must assert the rewritten filter, not only the 400s.
  6. The judged fields are NUMERIC_VALUE_TYPES by identity (pinned by reference equality), summary included. Read at origin/main, NUMERIC_COLUMN_REPRESENTATION.summary is EXACT, so the column is numeric on every SQL face and a non-numeric bind fails the same way; the text door refuses text operators on summary through the same set; COMPUTED_VALUE_TYPES is the write door's who-writes axis. autonumber is outside the set and passes: its stored value is a formatted string. RIGHT.
  7. formula is judged as the FieldType its returnType names, through the text door's own FORMULA_RETURN_TYPE_AS_FIELD_TYPE; an absent or unreadable returnType is deferred. Precedent-identical, unreachable at the seam because The FILTER axis has no unmaterializable verdict: a where on a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296 refuses every formula filter first, and the module, the case table and the barrel comment all say so. RIGHT.
  8. Judged positions: the implicit comparand, $eq / $ne / $gt / $gte / $lt / $lte, and each member of $in / $nin / $between; not judged: $null / $exists (boolean flags), the seven text operators (refused one door earlier over a numeric field, [Decision] refuse a text operator ($contains family) over a field whose DECLARED type is not textual — INVALID_FILTER 400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661), a { $field } reference and a dotted key. FieldOperatorsSchema at origin/main has exactly those 18 keys (6 + 3 + 7 + 2), so the partition pin is exact and a later operator fails it loudly. RIGHT.
  9. Only string comparands are judged; a number, null, a Date, a boolean or a bigint passes. RIGHT for the card, whose title and direction are about strings. The boolean and Date gap is carried under ③.
  10. The envelope is the existing INVALID_FILTER / 400, spelled as a literal (the api/ imports data/ reason filter-comparand-type.ts records) and pinned to StandardErrorCode.enum.INVALID_FILTER; no code is minted. RIGHT (ADR-0112 class 1).
  11. The refusal words name the field, its declared type (and a formula's return type), the bounded comparand via shapePreview, the path, the form's clause and then the remedy; the 500-character REST bound is pinned over every refusal in the table and the front-loading is pinned at 40-character names; every form's words differ and carry no tracker number. Each form's clause claims only what holds for that form. RIGHT.
  12. The fixture holds one field per FieldType member plus four typed formulas and one untyped, each a legal FieldSchema input and the object a legal ObjectSchema input (pinned); the 136 cases are derived by calling the verdict. Because caseFor calls numberComparandDoorVerdict, the "every verdict agrees" pin is tautological; what carries the weight is the hand-kept 41-row NUMERIC_STRING_GRAMMAR_CASES with its expected values and forms, the census-shape pins (refusals are exactly the numeric class plus f_formula_number, one deferred row, no narrowing), the three-way coverage of every judged position, the narrowed filters being the original with one comparand replaced, and parseFilterAST accepting every filter. That is the precedent's shape and it is sufficient. RIGHT.
  13. The census comparands for the temporal fields ("2026-01-01", "12:00") are chosen so no other door on the seam refuses them. That is a design claim for objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351 to measure, and the module says so. RIGHT to leave it there.
  14. The barrel: nine lines, one comment block and one export *, placed after filter-cross-field-comparison-class by the hand-resolved merge; the diff against main on that file is exactly those lines. RIGHT.
  15. The escape-materialisation deviation: the net diff spells U+00A0, U+202F, U+FF11, U+FF12 and U+2212 as backslash-u escapes and carries no raw code point of any of them (checked byte-wise over the whole diff). RIGHT.
  16. readNumericString's order (pattern, empty, padded, placeholder, radix, non-finite word, separator, JS spelling, none): a padded non-numeric string names its inner form and 0bad names radix-prefix. Form naming only; every such string is refused. RIGHT.

Wrong: none found.

Not verified here, and not load-bearing: the reading of the sibling repository that viewFilterFold.ts and ListView.convertFilterGroupToAST drop blank rows before they reach the server. A blank that does reach the door is refused loudly, which is the direction's answer, and today it is PostgreSQL's 500.

② Semver level

.changeset/20336-number-comparand-declared-type-contract.md: @objectstack/spec minor, body carrying Clause-②: yes with no arm, not BREAKING, no ADR-0087 marker. That matches what the diff publishes: 27 additive exports are a public-surface change, so yes, which takes at least minor (AGENTS.md, changeset rule 3); no accept set narrows in this release, so (narrowing), which would make the changeset BREAKING, is rightly absent. The narrowings land with #20351 and #20309's string half, whose changesets carry Clause-②: yes (narrowing) as BREAKING minor (triage note 3 on #20351, direction 4 on #20309). A changeset that declares no breaking change needs no ADR-0087 disposition (check-adr-0087-registration keys off the declaration). Check Changeset and Lint & Repo Gates are success on the head. The card's first grade (5861215026) said yes (narrowing) for the whole card; after the split (5861636857) the claim (5864659116), the PR body and the changeset all say yes for the contract half, which is the right value for it.

Clause-②: yes

③ Boundary flags

Dev open_questions[0] (three readings) and the seat's ACCEPT flags 1 and 2, which are the same two.

Dev out_of_scope_findings[0]: a boolean or a Date compared against a number field is not judged, and PostgreSQL's answer is not measured. ESCALATED as a follow-up for the engine lane, not blocking this card: by reading, { amount: { $gt: true } } and a Date bound against a numeric column are the same bind-failure class on PostgreSQL. #20351's pins should carry a boolean and a Date comparand as measured cells beside the numeric control, and a card is filed only if they answer 500. Nothing is filed here; this record is the reviewer's one write.

Observation, no action asked. The platform now has three numeric-string readers: this grammar (the filter door and the record write arm), @objectstack/formula's NUMERIC_STRING_RE (a JS spelling, trimmed, for expression operands at evaluation) and rest's parseNumberCell (currency symbols, %, thousands separators, for import cells). The module names the import tolerance as deliberate, and the formula hydration is a different seam; the "one grammar" claim is scoped to the read door and the write arm and holds there.

Deviations reported. Main merged twice through os-regen-merge.sh, the barrel conflict resolved by keeping both blocks, and the generated files rebuilt from the merged tree (the barrel diff is this card's nine lines; the JSON deltas are +27/-0 each with main's rows retained): accepted. Escape spellings restored: verified in judgment 17. The test:repo timeouts, the shallow-clone fixture fetch, the attribution form (a session footer, model-free trailers) and zero labels written by the dev: process notes with no bearing on the diff, accepted.

Gates and shape. At my first read mergeable_state was unstable while a Check Changeset re-run, started at 10:22Z by the label write, was in progress; on re-read the head has 39 check-runs, 33 success, 6 skipped (the opt-in and path-gated jobs), 0 failures, none in progress. Six files, +1228/-0, all inside the claim's surface: no packages/objectql, no record-validator.ts. Fixes #20336 is the only closing keyword; #20351 and #20309 are named in prose only and stay open. The head branch is the one the claim names, and the PR assignee is the card's.

Implemented-by: claude/issue-20336-numeric-comparand-verdict
Reviewed-by: session_01B3TqpoQbTAfG7G74GMDWNW

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20336-numeric-comparand-verdict branch September 28, 2026 14:35
This was referenced Sep 28, 2026
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…e expansion (objectstack-ai#20311) (objectstack-ai#20442)

Fixes objectstack-ai#20311
Clause-②: yes

Declares the emptiness operator `$empty: boolean` in
`@objectstack/spec`. Its description is ruling B's per-type table. The
one expansion every compile surface will call is exported beside it. The
operator is **staged the way `$like` was**: it is declared, but
deliberately absent from `FILTER_OPERATORS`, and the `is_empty` /
`is_not_empty` lowering still emits `$null`.

Ruling-ref: 5861435168 (ruling B on objectstack-ai#20311), 5865693155 (ruling A on
objectstack-ai#20399, the spelling), 5868169573 (the maintainer's amendment, 「照 $like
先例分阶段」).

## What changed

- **`FieldOperatorsSchema` and `SpecialOperatorSchema`** (the enforced
copy and the documentation copy, one shared constant) gain `$empty:
z.boolean().optional()`. The description is the ruled table: text-like
(`STRING_VALUE_TYPES`) = null or `''`; multi-value (`isMultiValueField`:
multiselect, checkboxes, tags, and select / radio / lookup / user / file
/ image with `multiple: true`) = null or `[]`; every other type = null
only; `false` is the exact complement; a face with no field declaration
judges by value. It also says in plain words that the operator is staged
and that no face answers it yet.
- **New module `packages/spec/src/data/filter-empty-operator.ts`**,
published on the data entry: `expandEmptyOperator(field)` keys on the
field DEFINITION (type plus `multiple`) and returns one of the frozen
`EMPTY_OPERATOR_ARMS` rows `{ arm, emptyString, emptyList }`.
`isEmptyFilterValue(value, expansion?)` is the value-level half: with an
expansion it applies the declared row; without one it applies the
by-value reading for the formula matcher and `having`. The result is
surface-neutral, deliberately not a `FilterCondition`, because `[]`
stays refused as an equality comparand (ruling 乙 on objectstack-ai#19757, untouched).
- **`FILTER_OPERATORS` is unchanged.** Its docblock gains a `$empty`
staging paragraph with the measured per-face table below. The flip card
is named as the one that adds the operator.
`filter-operator-vocabulary.test.ts`' `STAGED_AHEAD_OF_BACKENDS` becomes
`['$empty', '$ilike', '$like']`.
- **Two reconciliation pins that derive from `FieldOperatorsSchema`'s
keys**, kept green the way `$like`'s staging kept them:
- the comparand-type face's scalar set (`filter-comparand-type.ts`)
gains `$empty`;
- the save door's boolean-flag set (`filter-save-door-refusals.ts`)
gains `$empty`. A non-boolean `$empty` is then refused at save, as
`$null` / `$exists` already are, with the flags' first sentence and an
`$empty`-specific prescription.
- **Changeset** `@objectstack/spec` `minor`, `Clause-②: yes (widening)`.
It says plainly that authoring `$empty` today is refused at query time.

## Why the expansion is its own module, not in `filter.zod.ts`

The first version put the functions in `filter.zod.ts`, importing the
sets from `field-value.zod.ts`. `gen:skill-refs` then rewrote
`skills/objectstack-query/references/_index.md` and
`skills/objectstack-api/references/_index.md`: that import pulled
`field-value.zod.ts`, `field.zod.ts` and four shared modules into those
skills' transitive reference lists. Any `skills/**` path would make this
PR Tier H. The two files also meet in the `field.zod` import cycle. So
the expansion sits in a sibling module, the precedent being
`filter-text-operator-declared-type.ts`, which reads the same sets from
the same position. `filter.zod.ts` takes no new import. At the final
head `check:skill-refs` is green with zero `skills/**` changes. The
description names its type lists literally, and
`filter-empty-operator.test.ts` pins each list to the sets the function
reads, so the two cannot drift.

## A1: what every compile surface does with a hand-authored `$empty`
(measured)

Probe: a scratch script run once and not committed. It drove each
surface with `{ f: { $empty: true } }`, `{ f: { $empty: false } }` and
`{ $and: [{ g: 'x' }, { f: { $empty: true } }] }`, after this change was
built. Two controls ran beside it: the declared `{ f: { $null: true }
}`, and an undeclared `{ f: { $bogus: true } }`.

| surface | `$empty` (all three shapes) | control `$null` | control
`$bogus` |
|---|---|---|---|
| (1) driver-sql `applyFilterCondition`, via `SqlDriver.find` on
better-sqlite3 (driver-sqlite-wasm and driver-turso local inherit this
compiler; not driven separately) | REFUSED `INVALID_FILTER` / 400 |
answered `['2']` | REFUSED `INVALID_FILTER` / 400 |
| (2) driver-turso `RemoteTransport.buildWhereSQL`, via `find` | REFUSED
`INVALID_FILTER` / 400 | compiled `IS NULL` | REFUSED `INVALID_FILTER` /
400 |
| (3) service-analytics `compileScopedFilterToSql` | REFUSED
`READ_SCOPE_COMPILE_FAILED` / 500 (fail-closed) | compiled `IS NULL` |
same 500 |
| (4) service-analytics `lowerAnalyticsWhere` | passes the condition on
unchanged (a lowering, not an executor); the compile right after it,
`normalizeAnalyticsFilterTree`, REFUSES `INVALID_FILTER` / 400 | lowered
to `notSet` | same 400 |
| (5) formula `matchesFilterCondition` | NOT LOUD: answers `false` for
every record, with flag `true` and with flag `false` | answered the null
row | same silent `false` |
| objectql `applyHaving` / `matchesHaving` | REFUSED `INVALID_FILTER` /
400 | answered | REFUSED |
| driver-memory `find` and `match` (`checkCondition`) | REFUSED
`INVALID_FILTER` / 400 | answered `['2']` | REFUSED |
| driver-mongodb `translateFilter` (`translateFieldOperators`) | REFUSED
`INVALID_FILTER` / 400 | `{ f: { $eq: null } }` | REFUSED |

**Conclusion per surface, for this card: explicitly out of scope; each
gets its `$empty` arm from its lane card.** No surface drops the
predicate, so nothing widens. Two readings differ from premise A1, "the
staging leaves every surface loud":

- **formula is not loud.** It answers `false` for any operator it has no
arm for: its decided fail-closed posture (objectstack-ai#6520), identical for the
undeclared `$bogus`, and unchanged by this PR. It denies a write-side
`check` rather than widening anything. But its own docblock says a
DECLARED operator must not get that silent `false` ("the same defect
under a new name"), and `$empty` is now declared. So that claim is stale
until formula's lane card lands. `$like` got its formula arm in the PR
that declared it; this card is barred from formula by the order and by
the amendment's placement of arms in lane cards. Flagged in the report
as an open question for the seat.
- **read-scope-sql refuses with `READ_SCOPE_COMPILE_FAILED` / 500**, not
`INVALID_FILTER` / 400. It is loud and fail-closed, and it does the same
for every unknown operator. The description and the changeset say
"refuse", not "400".

## A2 to A4

- **A2.** Two in-package pins derive from `FieldOperatorsSchema`'s own
keys and saw `$empty`: `filter-comparand-type.test.ts` (judged set) and
`filter-save-door-face-parity.test.ts` (`BOOLEAN_SLOTS`). Both are
updated, with the source sets they reconcile.
`filter-operator-vocabulary.test.ts` records the staging. Suites that
enumerate `FILTER_OPERATORS` (`filter-view-operator-parity`,
`page-component-filter-record-to-rule-array`, service-analytics' echo
coverage) are untouched, because the array is. No test outside
`packages/spec` enumerates `FieldOperatorsSchema`'s keys (repo grep:
only prose mentions). No gate needed an edit outside `packages/spec`.
- **A3, re-measured at base `50e273fd7`:** `STRING_VALUE_TYPES` has the
14 text types; `isMultiValueField` = `MULTI_OPTION_TYPES` or a
`MULTI_CAPABLE_TYPES` member with `multiple: true`. The sets are
disjoint, a pin asserts it, and `lookup` vs `lookup` with `multiple:
true` land on different rows.
- **A4:** `isEmptyFilterValue(value)` with no expansion is the
declaration-free reading (null, `undefined`, `''`, `[]`). It differs
from the declared table only on a non-text column holding `''`, and a
pin shows exactly that divergence.

## Pins (`filter-empty-operator.test.ts`)

1. Both copies' description equals the ruled table; each type list it
names equals the set the expansion reads.
2. `{ tags: { $empty: true } }` parses at `FieldOperatorsSchema` (kept,
not stripped), at `FilterConditionSchema` (any depth) and through the
normalized AST. `{ tags: [] }` and `{ tags: { $eq: [] } }` are still
refused (issue at the slot; the query face answers `INVALID_FILTER` /
400). A non-boolean `$empty` is refused at the slot and at the save
door.
3. `expandEmptyOperator` returns the text, multi-value and null arms for
the three kinds, and every `FieldType` lands on its value class's arm.
`isEmptyFilterValue` answers each arm, plus the by-value reading.
4. `$empty` is ABSENT from `FILTER_OPERATORS`; a comment names the flip
card as the one that adds it. `is_empty` / `isempty` / `is_not_empty` /
`isnotempty` still lower to `$null`.

## Verification (final head `32e926db1`)

- `packages/spec` full local suite: `vitest run --project local`, 563
files, 16536 passed, 1 todo. `pnpm --filter @objectstack/spec
typecheck`: exit 0 (tsc, scripts typecheck, test typecheck).
- **Ablation**, run against the committed tree through
`scripts/ablation-replace.mjs`. It added `'$empty'` to
`FILTER_OPERATORS`: anchor count 1 to 0, blob `0912c2776e5e` to
`74b0e4d23fe0`. The run went red on exactly the two staging pins
(`filter-empty-operator` §4 and `filter-operator-vocabulary`), with 2
failed and 23 passed. The file was restored to blob == HEAD with `git
diff HEAD` empty. The second ablation (dropping `$empty` from the
save-door flag set) was **not run**: the verify-lock queue timed out.
- **Consumer suites** (filter / operator files per package, positional
vitest filters; spec and each package's upstream closure rebuilt from
this branch first). Direction: consumers of `@objectstack/spec`, i.e.
downstream.

| package | files | tests |
|---|---|---|
| formula | 16 | 570 passed |
| driver-memory | 10 | 392 passed |
| driver-mongodb | 6 (+1 skipped) | 219 passed, 38 skipped |
| objectql (filter / operator / having) | 16 | 624 passed |
| driver-sql | 14 | 331 passed, 4 skipped |
| driver-turso | 4 | 170 passed |
| metadata-protocol | 4 | 51 passed |
| lint | 2 | 76 passed |
| service-analytics (filter / operator / read-scope) | 42 | 951 passed |
| rest | 6 | 109 passed |
| plugin-sharing (criteria / sharing-rule) | 6 | 146 passed |

- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 108 commands at this head (a
superset of the dispatch list's 72). All were run, and `--ran`
reconciled: 106 exited 0; 2 are NOT MEASURED (exit 3, PREREQUISITE NOT
MET) because they need every package built. Those two are
`check:dual-build-cjs-loads` and `check:type-check-debt`, and CI runs
them. `check:skill-examples` first exited 3 (client-react unbuilt) and
exited 0 after building it. `pnpm --filter @objectstack/spec
check:generated`: every artifact current.
- **Lint, a declared narrowing:** `eslint --no-inline-config --format
json` over the 9 changed TypeScript files reported 9 files, 0 errors, 0
warnings. The population is `eslint.config.mjs`'
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` block. That config never enables
type-aware linting (its own note says so), so this diff cannot move a
verdict on any untouched file. The full `pnpm lint` is CI's.

## Patch round: reconciled with objectstack-ai#20414 (head `d581aed71`)

- **Why.** PR objectstack-ai#20414 (objectstack-ai#20336) landed as `b28550818`. Its partition pin
holds `FieldOperatorsSchema`’s keys equal to its judged positions, the
text operators and `$null` / `$exists`. With `$empty` added, that pin
went red in every merge group carrying both PRs (audit `5871530372`; the
diagnosis is on objectstack-ai#20455). This PR is the later lander, so it carries the
reconciliation.
- **What changed in objectstack-ai#20336’s files.**
- `$empty` joins the flag operators in the partition; the test title now
says "three flag operators".
- The module’s "Not judged" docblock sentence and its unjudged-positions
item name `$empty`.
  - One unjudged case row is added.
- The door’s verdict logic is unchanged, and no count pin on
`NUMBER_COMPARAND_DOOR_CASES` exists.
- **Merge.** `origin/main` `b28550818` was merged through
`os-regen-merge.sh` (`43801c9ed`) and regenerated in `859d9bd82`. Both
PRs’ exports are present, once each, in `api-surface/data.json` and
`export-origins/data.json`, and `filter.mdx` carries `main`’s
frontmatter and the `$empty` rows.
- **Verified at `d581aed71`.**
  - Passed:
    - spec `test` (local): 566 files, 16687 passed;
    - spec `typecheck`: exit 0;
    - `check:generated`: exit 0;
    - formula: 592 passed; driver-memory: 392 passed.
- Dispatch-gates: 108 derived, 106 exit 0. Two are NOT MEASURED
(`check:dual-build-cjs-loads`, `check:type-check-debt`): both need the
whole-repo build.
- Spec `test:repo` ran in 9 shards: 8 passed, and one was cut by the
local time cap, so it is NOT MEASURED locally. CI’s `Test Core`, which
runs `test:repo`, is green on this head.

## Stored sharing rules (ruling B, parameter 3)

- objectstack `50e273fd7`: the criteria sharing rules in `examples/`
that use emptiness = 0.
- objectui `b45d463a9`: none either; the 22 emptiness hits in
sharing-rule-related files are builder code and tests, not stored rules.
- This PR changes no lowering, so no stored rule changes result.
- Production rules are NOT MEASURED; the changeset says so.

## Acceptance notes

- PR objectstack-ai#20414’s number-comparand partition pin now names `$empty` among
the boolean flag operators (see the patch round above). The flip card
objectstack-ai#20446 must keep it there when `$empty` enters `FILTER_OPERATORS`.

- formula's docblock claim ("the silent answer is reachable only for a
name the protocol does not declare") goes stale with this declaration.
Carrier: formula's lane card, which gives it the `$empty` arm (by value,
`isEmptyFilterValue(value)`).
- read-scope-sql answers every unknown operator with
`READ_SCOPE_COMPILE_FAILED` / 500 rather than the ADR-0112
`INVALID_FILTER` / 400 the other faces use. It is pre-existing,
fail-closed and loud. Carrier: service-analytics' lane card.
- `packages/spec/src/ui/view-grouping-query.ts` says the empty-group
predicate and the view filter's `is_empty` agree on what "empty" means.
That stays true while the lowering is `$null`. Carrier: the flip card.
- `is_empty` still means null-only on every face until the flip card:
the gap ruling B closes is still open for users, by design of the
staging.

The patch-round section and the first acceptance note were added by the
`domain:spec` seat 1 (`session_01B3TqpoQbTAfG7G74GMDWNW`) from the dev’s
round report.

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…rammar and stores its number (objectstack-ai#20309) (objectstack-ai#20496)

Fixes objectstack-ai#20309

Clause-②: no (narrowing)

A number, currency, percent, rating, slider or progress field now reads
a **string** by the platform's one numeric grammar, `parseNumericString`
from `@objectstack/spec/data` (landed with PR objectstack-ai#20414), instead of
`Number()`-finite, and **stores an admitted string as the number it
denotes**. A string the grammar does not read answers `400
VALIDATION_FAILED` / `invalid_number` on every write door, with nothing
written. This is the card's string half. The non-string half (arrays,
booleans, objects) landed as PR objectstack-ai#20370 (`db74b169dc`), so this PR
completes the card.

Measured head: **`6bf61e75a`** (this branch after a true merge of
`origin/main` `fc0db22bc`).

## What changes (read from the code at that head)

- **`packages/objectql/src/validation/record-validator.ts`**
- The number arm (`NUMERIC_VALUE_TYPES` minus `COMPUTED_VALUE_TYPES`,
now spelled once as `isJudgedNumberType` and shared with the rewrite
below) judges a string by `parseNumericString`. ⛔ No private grammar:
the spec's case table `NUMERIC_STRING_GRAMMAR_CASES` decides hex,
padded, exponent and every other form, and this PR pre-decides none of
them. `min`, `max`, `scale` and `precision` read the parsed number. The
existing code and message key (`invalid_number`) are reused.
- New `normalizeNumericStringValues`, beside `normalizeBlankTypedValues`
and with its contract (one record or an array of them; pure; the same
reference back when nothing changed, else a shallow copy). An admitted
string on a field the arm judges becomes its number. It touches only the
fields `validateRecord` walks (never a `SKIP_FIELDS` name, a `system` or
a `readonly` field), never `summary` or another computed type, never a
non-string, and never a string the grammar refuses.
- **`packages/objectql/src/engine.ts`**: the rewrite runs right after
`normalizeBlankTypedValues` at its three call sites: `insert()`,
`update()` (by id and by predicate) and `validate()` (the dry run). So
the middleware, the caller snapshots, the hooks, the `readonlyWhen`
locks and the validator all see the number. Nothing else in `engine.ts`.
The blank rule and its `COMPUTED_VALUE_TYPES` exemption are untouched.
- **Tests** (test side only): the three pin files of this card gain the
string half, each driven by the spec's own
`NUMERIC_STRING_GRAMMAR_CASES`.
- **`.changeset/20309-number-arm-numeric-string-grammar.md`**:
`@objectstack/objectql` `minor`, BREAKING banner, `Clause-②: no
(narrowing)`, ADR-0087 `not-required (no-migration-prescription)`, the
disposition PR objectstack-ai#20370's changeset took for this arm.

## Measured, base to head (H1, H3, H4)

Instrument: a scratch script, not committed, booting the real
`ObjectQL`, `ObjectStackProtocolImplementation` and `RestServer` from
the built packages, once on `InMemoryDriver` and once on `SqlDriver`
over better-sqlite3 in memory. Types: the six judged types. Doors:
engine `insert`, `insertMany`, `update` by id, `update` by predicate;
REST `POST /data/:object`, `createMany`, batch create, `PATCH
/data/:object/:id`, batch update, `updateMany`, and `/import` (JSON
rows). Each cell records the answer, the physical cell (memory's own
store; on SQLite the column and its `typeof()`) and `engine.findOne`.
Base `851af0c27` (the branch point), head `c67623f22` (the validator and
engine code measured here is what `6bf61e75a` carries, plus the date arm
that arrived from `main`). 20 inputs x 6 types x 11 doors x 2 drivers =
**2640 cells**.

| input | base, memory | base, SQLite | head, both drivers, every door
but `/import` |
|---|---|---|---|
| `'12'`, `'12.5'`, `'-3'`, `'-0'`, `'0.10'`, `'1e3'` | accepted,
**stored the string**, read back a string | accepted, stored a number by
column affinity | accepted, **stored the number**; the SQLite cell is
byte-identical to base |
| `'0x10'` | accepted, stored the string | accepted, stored the **TEXT**
`'0x10'`, read back as `16` | `invalid_number`, nothing written |
| `' 12 '`, `'12\n'` | accepted, stored the string | accepted, stored
`12` | `invalid_number`, nothing written |
| `'+5'`, `'.5'`, `'5.'`, `'007'` | accepted, stored the string |
accepted, stored `5` / `0.5` / `5` / `7` | `invalid_number`, nothing
written |
| `'1,000'`, `'Infinity'`, `'NaN'`, `'1e400'`, `'abc'` |
`invalid_number` | `invalid_number` | unchanged |
| `''` | `null` (blank rule) | `null` | unchanged |
| `12` (a number) | stored `12` | stored `12` (`real`; `integer` on
`rating`) | unchanged |

Of 2640 cells, **1200 moved**, exactly 60 per moved input (6 types x the
10 non-`/import` doors). The `/import` door moved **0** of its 240
cells: its own cell reader turns a numeric cell into a number before the
engine sees it (below). Refused cells answer `400 VALIDATION_FAILED`
with the field code `invalid_number` on POST and PATCH, a
`VALIDATION_FAILED` row on batch / `createMany` / `updateMany`, and
leave an existing cell unchanged on every update door.

**H4, the narrowing.** Read off the spec table rather than listed by
hand, the strings `Number()` read as finite that the grammar refuses are
exactly `' 12 '`, `'12\n'`, `'\t-3'`, `'0x10'`, `'0X1A'`, `'0o17'`,
`'0b101'`, `'+5'`, `'.5'`, `'5.'`, `'007'` (pinned in
`record-validator.number-value.test.ts`). The changeset names them, with
the before and after answer and the fix (send a JS number or its plain
JSON spelling).

**H5.** Bounds, `scale` and `precision` read the parsed number, so a
string answers byte-for-byte as its number does (pinned over 14 cases):
`'12.50'` passes `scale: 1` and is stored as `12.5`; `'12.55'` and
`'1e-7'` are `max_scale`; `'150'` over `max: 100` is `max_value` (on
`progress` too); `'1234.5'` at `precision: 5, scale: 2` is
`max_precision`; a fraction-stored `percent` keeps its `scale + 2`
allowance. There is no integer check on `rating`, before or after:
`'3.5'` on a `rating` passes unless it declares `scale: 0`.

## The server `/import` route and the grammar (H3)

The route's cell reader, `parseNumberCell`
(`packages/rest/src/import-coerce.ts`), coerces a numeric cell to a JS
number before the write, so the engine's grammar never sees a string
from it on a typed field. Over the 41 rows of
`NUMERIC_STRING_GRAMMAR_CASES` the two **agree on 33** (every admitted
row reads to the same number; hex, octal, binary, non-finite,
placeholders, `'5.'`, `'1_000'`, `'1 000'` refused by both) and
**disagree on 8**, each one the import reader accepting what the grammar
refuses: `' 12 '` / `'12\n'` / `'\t-3'` (it trims), `'1,000'` (it strips
commas), `'1.000,5'` (read as `1.0005`), `'+5'`, `'.5'`, `'007'`. That
is the import route's own documented tolerance and is not changed here
(not in this card's surface).

## Tests, all at `6bf61e75a` unless noted

- `pnpm --filter @objectstack/objectql test`: 329 files, **6584
passed**.
- `pnpm --filter @objectstack/rest test`: 218 files, **4160 passed**, 34
skipped.
- `pnpm --filter @objectstack/objectql --filter @objectstack/rest
typecheck`: exit 0, both test layers OK (`tsc --listFiles` over each
`tsconfig.test.json` includes the edited test files).
- Downstream sweep at `b78c66612` (before the merge):
`@objectstack/service-automation` 149 files, 1837 passed;
`@objectstack/metadata-protocol` 189 files passed, 3 skipped, 2750 tests
passed.
- Pin files: `record-validator.number-value.test.ts` 369 tests,
`engine-number-value-door.test.ts` 275, `rest-data-number-value.test.ts`
277.
- ESLint, narrowed and declared: the 5 changed `.ts` files, `eslint
--no-inline-config --format json`: 5 files linted, 0 errors, 0 warnings.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move any
untouched file's verdict. The repo-wide `pnpm lint` is CI's.

**Ablations** (each through `scripts/ablation-replace.mjs`, which proved
the anchor 1 to 0 and the blob change on disk and restored with blob ==
HEAD and an empty `git diff HEAD`; objectql rebuilt and
`ablation-dist-preflight` confirmed the marker in 4 built files before
each run, and absent from all 14 after each restore rebuild, with a
clean tree):

- **A, the arm reads strings by `Number()` again**
(`parseNumericString(value)` replaced). Predicted 201 reds: 68
validator, 67 engine, 66 REST. Measured objectql **135** failed of 644
(68 + 67) and REST **66** failed of 277, all in the named narrowed
strings, the table-parity and named-narrowing tests, and the dry-run
test.
- **B, the rewrite made a no-op.** Predicted 79 objectql reds and **0**
REST reds, because SQLite's column affinity stores the plain numeric
strings as numbers anyway. Measured objectql **79** failed of 644 (60
driver-payload cases, the hook test, 4 rewrite tests, 14 H5 cases) and
REST **0** failed of 277. So on SQLite the physical-cell pin cannot see
the rewrite; the engine pin on the driver payload is what covers memory
(and MongoDB, which stores the payload as given).
- After both restores: the three pin files 644 / 644 and 277 / 277.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `6bf61e75a`: 66 commands, each run with
its exit code recorded before any pipe. **64 exit 0.** 2 are **NOT
MEASURED** with exit 3 (PREREQUISITE NOT MET, both need every package
built; CI runs them): `pnpm check:dual-build-cjs-loads`, `pnpm
check:type-check-debt`. `--ran` reconciliation: 66 derived, 64 run, 2
NOT-MEASURED, 0 UNRUN.

## Acceptance notes

- **Producer census (triage direction 2).** The seat measured it (answer
5863923799 on the card, objectui source): every interactive form widget
(`NumberField`, `CurrencyField`, `PercentField`, `RatingField`,
`SliderField`, grid inline edit) sends a JS number or `null`, and the
kanban quick add a number or a blank that the blank rule turns into
`null`. One shipped path sends numeric **strings** to the record write
door: objectui's CSV import wizard, legacy per-row fallback
(`plugin-grid/src/ImportWizard.tsx`, `legacyImport` via `validateRow`),
used only when the client cannot reach the server `/import` route.
Re-read in this run at the local objectui checkout `b8e09415c9`: it
posts the raw cell after a client check `!isNaN(Number(value))`, which
covers `number` / `currency` / `percent` only (a `rating`, `slider` or
`progress` cell reaches the server unchecked), and its parser
(`importParsers.ts` `parseDelimited`, and the xlsx reader) trims every
cell. So of the refused forms it can send the radix literals and the
non-JSON spellings (`'0x10'`, `'+5'`, `'.5'`, `'5.'`, `'007'`), and
those rows now fail per row with `invalid_number` where they used to
store a string. Prescription (in the changeset): import through the
server `/import` route, the wizard's default path. The in-repo rows
(example seeds and defaults, flow templates, the `/import` route, the
client SDK, driver read-back) send numbers, as PR objectstack-ai#20370 recorded.
- **`/import` and the grammar disagree on 8 rows** (above). Not changed
here. One of them is reported to the seat as a finding: a decimal-comma
cell is misread at the `/import` door, measured through the route on
both drivers: `'3,14'` stored `314`, `'1,5'` stored `15`, `'1.000,5'`
stored `1.0005`, `'1,2,3'` stored `123`, each with `ok: 1, errors: 0`.
- **The earlier pending changeset**
`.changeset/20309-number-arm-non-string-refused.md` says a string "is
still judged by `Number()` and stored as sent. Which strings a number
field accepts is a separate change." This PR is that separate change,
and its own changeset says so. The earlier file is left untouched:
editing another PR's pending changeset is refused by
`check:empty-changeset` (the foreign-changeset rule) unless confirmed as
a deliberate correction.
- **The dispatch asked for a "FROM → TO" line** in the changeset. With
that label `check-adr-0087-registration` reads a migration prescription
and refuses `not-required (no-migration-prescription)`, the disposition
PR objectstack-ai#20370 used for this arm. The changeset carries the same mapping as a
"before → after" line with the fix, the spelling the sibling value
narrowing `20386-progress-min-max-enforced.md` uses. Nothing authored
moves, so there is no ledger row to register.
- **Memory stores `-0` for `'-0'`**, the grammar's own value; SQLite
stores `0`.
- **Hooks now see the number.** A `before*` hook reading a numeric field
that a caller sent as a string sees a JS number (pinned). A value a hook
itself writes after the door is not rewritten; the arm still judges it
by the same grammar.
- `driver-memory` is measured at every REST door (the table above) and
pinned at the engine door on the driver payload, not with a new REST
test consumer: `check:driver-memory-census` refuses one without a ruling
(the constraint PR objectstack-ai#20370 met).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… field at the engine's filter door, and narrow a numeric one (objectstack-ai#20351) (objectstack-ai#20501)

Fixes objectstack-ai#20351
Clause-②: no (narrowing)

## What this adds

Lane (2) of the two-lane route objectstack-ai#20336 took on objectstack-ai#15661's precedent: the
engine door that consults the contract PR objectstack-ai#20414 published in
`@objectstack/spec/data` (`filter-number-comparand-declared-type.ts`).
The contract half is untouched; `packages/spec` is not in this diff.

- **The door**,
`packages/objectql/src/number-comparand-declared-type-door.ts`, beside
the text-operator and temporal doors. For each comparand at a judged
position on a declared numeric field it asks
`numberComparandDoorVerdict` and routes the answer:
- `door-refusal`: throws `INVALID_FILTER` / 400 (the existing
`invalidFilterError` envelope) in the contract's words,
`numberComparandRefusalMessage`, before any driver is resolved;
- `narrows`: rewrites the numeric string to its number, copy-on-write
(the caller's filter is never edited, and a filter with nothing to
narrow comes back by reference);
  - `passes` / `deferred`: leaves it alone.
  
The door reads no string itself. The grammar, the judged types
(`NUMERIC_VALUE_TYPES` by identity), the judged operators
(`NUMBER_COMPARAND_DOOR_SCALAR_OPERATORS` /
`NUMBER_COMPARAND_DOOR_LIST_OPERATORS`) and the words are all the
spec's.
- **Its calls in `engine.ts`, at the collection point only**, fifth
after the temporal door in the same order everywhere:
- `lowerWhereFilterArray`, object form (before
`normalizeFilterComparandTypes`) and array form (on the lowered
condition). So `find` / `findOne` / `count` / `aggregate` / `update` /
`delete` and the judge-only `judgeFilter` (`judgeWhereAdmission` calls
the same function) all inherit it;
- each per-aggregation `filter`, rooted at `aggregations[i].filter`,
against the object's declared fields;
- `having`, after the temporal `having` door, over the columns
`aggregatedRowColumnClasses` classes `numeric` (`count` / `sum` / `avg`,
and a groupBy or `min` / `max` of a numeric field).
  
The `judgeWhereAdmission` docblock's pipeline list names the new door
(comment only).
- **A changeset**, `.changeset/20351-number-comparand-door.md`:
`@objectstack/objectql` `minor`, BREAKING, `Clause-②: no (narrowing)`, a
FROM → TO line, and the ADR-0087 disposition `not-required
(no-migration-prescription)` in the form PR objectstack-ai#20469 and PR objectstack-ai#20370 used.
`@objectstack/objectql`'s root exports are unchanged: the door module is
not re-exported from `index.ts` or `core.ts`, like its two siblings.

## What it does to the card's three answers

Measured through `engine.find` / `engine.aggregate` and `POST
/api/v1/data/:object/query`, three rows (5, 12, 30), on InMemoryDriver,
SqlDriver on SQLite and SqlDriver on a local PostgreSQL 16.13 server:

| position | comparand on a `number` field | base `3062e5001`: memory ·
SQLite · PostgreSQL | this branch, all three |
|:--|:--|:--|:--|
| `where` | `$gt` / `$eq` / implicit / a `$in` member `"abc"` | 200 no
rows · 200 no rows · 500 `DATABASE_ERROR` | 400 `INVALID_FILTER` |
| `where` | `$ne "abc"` | every row · every row · 500 | 400 |
| `where` | `$eq ""` | no rows · no rows · 500 | 400 |
| `where`, REST | `$gt "{current_user_id}"` (resolved to the user's id)
| no rows · no rows · 500 | 400 |
| per-aggregation `filter` | `$gt "abc"` / `$ne "abc"` | count 0 / count
3, on all three | 400 |
| `having` on `sum(amount)` | `$gt "abc"` / `$ne "abc"` | no group /
every group, on all three | 400 |
| `where` | `$gt "12"` / `$eq "12"` | **no rows** · 1 row · 1 row | 1
row on all three |
| all three positions | `$gt 10` (the numeric control) | 2 rows / count
2 / both groups | the same |

The last-but-one row is the narrowing's point: InMemoryDriver compared
`"12"` as a string and matched nothing.

## Premise check, and the order's hypotheses

- **H1 holds, reproduced at `3062e5001`** (the table above). SqlDriver's
server-side log line on PostgreSQL reads `(22P02) … invalid input syntax
for type numeric: "abc"`.
- **H2: the collection point is where the order says**, and the new door
sits after the temporal door at each call. `judgeFilter` passes through
it: `judgeWhereAdmission` calls `lowerWhereFilterArray` (pinned:
`judgeFilter` answers `INVALID_FILTER` / 400 for `"abc"` and `{ ok: true
}` for `"12"`). **RLS / sharing / tenant predicates do NOT pass through
it at runtime.** The middleware chain composes them onto the AST after
this seam, and `plugin-security`'s `judgeCompiledComparands` runs only
the two field-agnostic faces (`rls-compiler.ts`, the `[objectstack-ai#20212]` block).
A policy predicate reaches this door at authoring instead:
`validateRlsPredicateEnforceability` asks the engine's `judgeFilter`
when the host hands the rule a judge.
- **H3 holds.** The verdict is `numberComparandDoorVerdict` over
`NUMBER_COMPARAND_DOOR_JUDGED_TYPES` with the scalar and list operators,
and the words are `numberComparandRefusalMessage`. A numeric string is
**narrowed** to its number (the verdict's `narrows`, as the contract
review's judgment 7 asks). The pins assert the rewritten filter the
driver receives, not only the 400s.
- **H4: MySQL is NOT MEASURED.** No MySQL server is available in this
container. The REST suite carries a MySQL cell, a named skip without
`OS_TEST_MYSQL_URL`.
- **H5: neither consults the same verdict everywhere.**
- `service-analytics`: the ObjectQL strategy sends the caller's `where`
into `engine.aggregate` and asks `judgeFilter` about the read scope
(`assertReadScopeAdmittedByEngine`), so both inherit the door. The
**NativeSQL strategy's decline** (`NativeSQLStrategy.canHandle`)
declines a cross-field reference and an uninterpretable temporal
comparand, but does not consult the number verdict. So a raw-SQL
deployment compiles `amount > 'abc'` itself (read at source, not
measured).
- **The metadata save door:** RLS `using` is judged through
`judgeFilter`, as above. No lint rule reads `numberComparandDoorVerdict`
(`git grep` over `packages/lint/src` finds zero hits), so a stored view
or report filter comparing a number field with a non-numeric string
saves clean and is refused at query time.
  
  Both are reported as findings below and are not edited here.

## The staged `$empty` row: pinned at the door alone

`NUMBER_COMPARAND_DOOR_CASES` carries PR objectstack-ai#20442's `unjudged` `$empty`
row. The engine suite partitions it out of the end-to-end drive and pins
it at the door alone: `findNonNumericComparand` answers `null`, and
`narrowNumberComparands` returns the same reference. A partition guard
asserts the table is split exactly. So the row can neither turn this
suite red for a reason that is not the door's, nor vanish unnoticed.

The contract's `formula` rows are partitioned the same way the text
door's suite does it: they are pinned in the direction they answer
(`INVALID_FIELD` / 400 from the objectstack-ai#8296 materializable door, one door
earlier). The door's own walk is pinned to judge `f_formula_number` by
its `returnType`.

## Tests (at `09da7a4cc`, the merged head, unless noted)

- **New:
`packages/objectql/src/engine-number-comparand-declared-type-door.test.ts`,
29 tests.** It drives the contract's case table through a real
`ObjectQL` and a recording driver, per the contract header:
- of the table's 137 cases, 51 refusals (the 52nd is the
`f_formula_number` row), asserting `code` + `status` + `httpStatus`,
every `mustMention` substring, and no driver read. All 8 refusal forms
and every judged position are covered, both ways;
- 23 `narrows` cases, asserting the driver receives `c.expectedFilter()`
and the caller's filter is untouched;
  - 57 `passes` cases, reaching the driver unchanged;
  - the formula (5) and `$empty` (1) partitions above.
  
  Beside the table:
  - every verb (read and write, no read and no write on refusal);
  - `FilterArray` sugar, both refused and narrowed;
  - `$and` / `$or` / `$not`;
  - a placeholder refused unresolved;
  - `judgeFilter`;
- the per-aggregation `filter`, refused at its path, with numeric
strings counting what their numbers count;
- `having` on `count` / `sum` / a numeric `min`, refused, narrowed, and
a placeholder on `count`;
- the four `findData` doors (`where` object, `$filter`, filter AST,
implicit query parameter), both ways;
- the registry-less, unknown-key, by-reference and
unrecognised-combinator guards.
- **New: `packages/rest/src/data-number-comparand-door.test.ts`.** It
runs `POST /api/v1/data/:object/query` and `engine.find` /
`engine.aggregate` over SqlDriver, with a cell per dialect:
  - `where`: 9 refused spellings;
  - the per-aggregation `filter`;
- `having` on `sum` and `max(currency)`, on the native and the rows
path;
- numeric-string controls, equal to their numbers at all three
positions.
  
The SQLite cell always runs. The PostgreSQL cell ran against the local
server: 3/3 passed at `09da7a4cc`. ⚠️ **No CI job provisions
`OS_TEST_POSTGRES_URL` for `@objectstack/rest`.** The `Temporal
Conformance (live PG + MySQL)` job runs `driver-sql`'s suite,
`metadata-protocol`'s `live-*` files and one `runtime` file, and a
`driver-sql`-only pin cannot reach an engine door. So the live cells are
red-capable and un-run in CI; the local run above is their measurement.
- **Re-pinned, test side only.** Four existing pins asserted the old
silent answer for a string on a numeric column:
- `engine-aggregate-having-temporal-door.test.ts`: the three "a string
on sum / count / avg keeps no group" rows move to a refusal pin in the
number door's words;
- `engine-aggregate-positions.test.ts`: the "unknown token on count" row
moves to a text column, which neither field-aware door judges, and the
count-column case is pinned in the new suite;
- `rest-aggregate-numeric-having.test.ts`: three rows move from `KEPT`
to a `REFUSED` table, SQLite and PostgreSQL both run locally;
- `data-query-having-temporal-door.test.ts`: "a string on sum" becomes a
number control plus a refusal pin.
- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: 330 files, 6118 tests passed. `--project repo`: 1 file,
5 passed.
- `pnpm --filter @objectstack/rest exec vitest run --project local
--maxWorkers=2`: 219 files, 3930 passed, 40 skipped. `--project repo`: 1
file, 8 passed.
- The live PostgreSQL run of the two PostgreSQL-capable REST files: 30
passed (15 live-postgres), 15 skipped (MySQL).
- `pnpm --filter @objectstack/objectql typecheck` and `pnpm --filter
@objectstack/rest typecheck`: exit 0. `check:test-typecheck` is OK for
both, with no debt added (objectql 40 files / 234 errors held; rest 0 /
0).

## Ablation (reverse verification)

The mutation is in the door's walk, which every position routes through:
`if (!meta || numberComparandFieldVerdict(meta) !== 'judged') continue;`
→ `if (meta || 'ABLATION_20351') continue;`. It is made with
`scripts/ablation-replace.mjs`: anchor 1 → 0, and blob `aa4a3247` →
`56a5bdf6`.

- **Mutated leg:** after `pnpm --filter @objectstack/objectql build`,
`ablation-dist-preflight` found the marker in 4 built files. The
objectql door suite went **20 failed / 8 passed**; the 8 are the guards
and partitions that do not depend on the door firing. The REST door
suite went **6 failed / 3 skipped**. The SQLite cell answered `200` with
`records: []`, the PostgreSQL cell `500 DATABASE_ERROR`, and the
per-aggregation `$in ["5","30"]` counted 0 instead of 2: the card's
defect, back.
- **Restore leg:** the blob is back to `aa4a3247` = HEAD and `git diff
HEAD` is empty. After a rebuild, `ablation-dist-preflight --absent`
found the marker absent from all 14 built files and the tree clean. Both
suites passed again (28/28 and 6 + 3 skipped at that commit,
`872d7708b`).

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at **`09da7a4cc`** derives 65 commands, the
same list as at the first merged head. All 65 ran with each exit code
recorded before any pipe:

- 63 exited 0 on the first pass;
- `check:dual-build-cjs-loads` and `check:type-check-debt` answered exit
3 (PREREQUISITE NOT MET) until the whole workspace was built (`turbo run
build --filter=!@objectstack/docs`, 72/72), then exited 0.

`dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. The
branch merged `origin/main` twice with true merge commits, no rebase and
no force-push; the last merge base is `45f428d8f`.

## Acceptance notes

- **`having` words.** A numeric aggregated column has no declared
`FieldType`, so the door hands the verdict `number` (the member of the
numeric class the column holds). The spec's words then read "compares a
declared number field against … at having.total.$gt". The `not-a-number`
clause ("backends answer it differently (PostgreSQL with a server
error)") is the `where` fact: `having` is evaluated by the engine on
every driver, and there it kept no group, or every group under `$ne`.
The words are the contract's, and the path names the position.
- **Out of the contract, measured, unchanged:** a boolean or a `Date`
compared against a number field is not judged (the contract judges
strings). `$gt true`: no rows on memory, every row on SQLite, 500 on
PostgreSQL. A `Date`: no rows · no rows · 500. Both hold on the base and
on this branch. Handed to the seat below.
- **Not measured:** MySQL (no server in this container);
`driver-mongodb` (the door sits in front of it); the NativeSQL analytics
path (read at source).
- **Line budget:** n/a (no `skills/**` path in the diff).

## Out of scope, handed to the seat (not filed by this dev)

1. **Class (a), reach measured at REST.** A boolean or a `Date`
comparand against a number field answers `500 DATABASE_ERROR` on
PostgreSQL. It is `POST /api/v1/data/:object/query` with `where: {
amount: { $gt: true } }` against a `number` field, on a local PostgreSQL
16 server, on the base and on this branch. The contract review of PR
objectstack-ai#20414 said to file this only if it answered 500; it does. Dedupe words:
`boolean comparand number field postgres 500` · `Date comparand numeric
column database_error` · `non-string comparand declared number type`.
2. **Carrier: none. Noted, not filed (read at source, reach not
measured).** `NativeSQLStrategy.canHandle` does not consult the number
verdict, so a raw-SQL analytics deployment does not fall through to this
door. Dedupe words: `native sql decline number comparand` · `analytics
raw sql non-numeric string`.
3. **Carrier: none. Noted, not filed (read at source, no named
producer).** No authoring rule reads `numberComparandDoorVerdict`, so a
stored view or report filter with a non-numeric string on a number field
saves clean and is refused at query time. Dedupe words: `stored view
filter non-numeric number field lint` · `authoring number comparand
verdict`.
4. **Carrier: none. Noted, not filed.** The runtime RLS compile
(`judgeCompiledComparands`) does not consult the number verdict. The
authoring judge does, when present. Dedupe words: `rls compiled
predicate number comparand` · `policy using string against number
field`.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ield is refused like a non-numeric string (objectstack-ai#20502) (objectstack-ai#20545)

Fixes objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

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/xl tests tooling

Projects

None yet

2 participants