Skip to content

fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) - #20370

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20309-number-arm-coercion
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20309-number-arm-coercion

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Part of #20309

Clause-②: no (narrowing)

A number, currency, percent, rating, slider or progress field now refuses an array, a boolean or an object with 400 VALIDATION_FAILED / invalid_number, on every engine and REST write door. At base [500] answered 201 and SQLite stored the TEXT '[500]'. A number is judged and stored exactly as before. A string is unchanged: it is still judged by Number() and written as sent. That is the half this PR leaves open, which is why the first line says Part of: see "The string half" below.

What changes (read from the code at the head below)

Measured, base to head (H1, H2)

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: number, currency, percent, rating, slider, progress. Doors: engine insert, engine update, REST POST /data/:object, REST PATCH /data/:object/:id, REST batch create, REST batch update. Each cell records the door's answer, the physical cell (the memory driver's own store; on SQLite the column and its typeof()), engine.findOne and REST GET. Base is c74de10a94. Head is this branch at bf83ded05c (the code at the head below is the same). 1016 cells: 14 inputs x 6 types x 6 doors x 2 drivers, plus the 4 H6 cells.

input base, memory base, SQLite head, both drivers
[500] accepted, stores the array, reads [500] 201, stores TEXT '[500]', reads "[500]" invalid_number (REST 400; batch row VALIDATION_FAILED), nothing written
[] accepted, stores [] 201, stores TEXT '[]' invalid_number, nothing written
true / false accepted, stores the boolean 201, stores 1 / 0 (real; integer on rating) invalid_number, nothing written
[5, 7], {}, 'Infinity' invalid_number invalid_number unchanged
500, 12.5 stored as the number stored real (integer for 500 on rating) unchanged
'0x10' stored the string stored TEXT '0x10', read back as 16 unchanged (the string half)
' 12 ', '12', '1e3' stored the string stored as a number (column affinity) unchanged (the string half)
'12.5' stored the string stored real 12.5 unchanged (the string half)

Of 1016 cells, exactly 288 moved: [500], [], true and false, 72 cells each (6 types x 6 doors x 2 drivers). The other 728 are byte-identical base to head in all five columns.

H2. At base the arm judged Number(value) and the driver received value: the table's [500] row is that gap, read from the raw column. At head a non-string never reaches the driver. A number arrives as the same number: engine-number-value-door.test.ts asserts Object.is on the driver-facing payload for insert and update. A string still has the gap, and that is the open half.

The string half, and why this PR does not close the card

The seat's update after dispatch set two branches. (a) If no shipped producer sends a numeric string to a number-typed field, refuse every string. (b) If one does, refuse only the non-strings, leave strings exactly as base, open the PR as Part of, and sequence the string half after #20336's grammar.

The in-repo census (below) finds no producer that sends a numeric string. objectui's form widgets, the main row, are NOT MEASURED: this session has no read access to objectstack-ai/objectui (REST GET answered 403, "GitHub access to this repository is not enabled for this session"), and the request to attach it was refused by the session's permission classifier. So branch (a) cannot be established. This PR takes branch (b): it is the one that refuses no form a shipped producer might send. The string half is an open question in the report, not a guess here. ⛔ No numeric-string grammar is authored in packages/objectql.

Producer census (H3)

Every row is a shipped producer of values for a number-typed field, and what it sends.

producer file what it sends measured how
Example seed records examples/app-crm/src/data/index.ts, examples/app-showcase/src/data/** JS numbers only: 35 values (crm) and 124 (showcase) on numeric fields; app-todo and app-multi-package seed none each example's objectstack.config.ts imported with tsx and every seed record walked against its object's field types
Numeric defaultValues the example objects JS numbers only: 4 (crm), 11 (showcase), 2 (todo) same walk
Example flow create_record / update_record nodes examples/app-todo/src/flows/task.flow.ts, create_next_task one value on a numeric field: recurrence_interval: '{completedTask.recurrence_interval}', a single-token template. interpolateString (packages/services/service-automation/src/builtin/template.ts) returns the resolved raw value for a single token, so this sends the stored number same walk; the template rule read at source
Flow templates in general template.ts a single token keeps its type; an EMBEDDED template (text around a token) is stringified. No shipped flow puts one on a numeric field read at source
CSV / JSON import packages/rest/src/import-coerce.ts (parseNumberCell), called by import-runner.ts before the engine a JS number, or the row's own invalid_number refusal read at source
REST batch, createMany, updateMany, import doors packages/rest pass the caller's JSON through to the engine; a door, not a producer measured above
@objectstack/client packages/client/src/index.ts serialises the caller's record as JSON; no value stringification read at source
Read-modify-write through driver-sql packages/drivers/driver-sql/src/sql-driver.ts (numericValueFields) numeric columns are presented as JS numbers on every dialect, so a record read back and written again carries numbers read at source
objectui form widgets objectstack-ai/objectui NOT MEASURED (no read access in this session). Indirect only: PR #20340 measured that a cleared number box sends null, not '' none

Runtime sweep at head: @objectstack/service-automation (147 files, 1767 tests), @objectstack/rest and @objectstack/objectql all pass with the refusal in place.

No collateral (H4)

Declaration (H5)

Clause-②: no (narrowing), the claim's line. [500], [], true and false answered 201 at base on memory and SQLite and are refused at head. The census names no shipped producer that sends an array, a boolean or an object to a number-typed field, so no producer's form is refused. The objectui row is NOT MEASURED, as above.

H6, for the seat (nothing changed for it)

Base c74de10a94, REST POST /data/:object, one field each, bounds declared on the field. The four cells are the same on SQLite and memory, and the same at head.

field value answer stored
progress, max: 100 150 201 150 (SQLite real)
progress, min: 0 -5 201 -5 (SQLite real)
number, max: 100 (control) 150 400 VALIDATION_FAILED / max_value no row
number, min: 0 (control) -5 400 VALIDATION_FAILED / min_value no row

It reproduces: progress stores a value outside the min / max it declares.

Tests

New files:

  • packages/objectql/src/validation/record-validator.number-value.test.ts: the refusal set on the six judged types, insert and update, as the envelope (VALIDATION_FAILED plus invalid_number); parity with the spec's valueSchemaFor over every non-string input; a characterization that the string half is unchanged, which turns red when it lands; and the blank and summary controls.
  • packages/objectql/src/engine-number-value-door.test.ts: what the driver receives on insert, insert([...]), insertMany, update by id and update by predicate, plus the dry run; a refused value never reaches the driver.
  • packages/rest/src/rest-data-number-value.test.ts, on SQLite: POST, batch create, PATCH, batch update and updateMany, with the physical cell and its typeof().

Suites run at c135a38d31 (this branch after merging origin/main 26daf0b036):

  • @objectstack/objectql, whole suite: 325 files, 6013 tests passed.
  • @objectstack/rest, whole suite: 211 files, 3856 passed and 2 skipped.
  • @objectstack/driver-memory: 57 files, 1374 passed.
  • @objectstack/driver-sql (no live PostgreSQL or MySQL; those files skip): 192 files passed and 11 skipped; 3157 tests passed and 176 skipped.
  • @objectstack/service-automation: 147 files, 1767 passed.
  • pnpm --filter @objectstack/objectql --filter @objectstack/rest run typecheck: exit 0, both test layers included.

Reverse verification

The guard was removed with scripts/ablation-replace.mjs (anchor hit once, blob changed), @objectstack/objectql was rebuilt, and ablation-dist-preflight found the marker in 4 built files. Predicted before the run: 68 objectql reds (42 per-input validator cases, the spec-parity case, 24 per-input door cases and the dry run) and 24 REST reds. Measured: objectql 68 failed of 151, REST 24 failed of 43, and every red was one of the predicted cases. Measured at bf83ded05c; the merge of origin/main that followed changed no file in packages/objectql or packages/drivers. The restore proved blob equals HEAD and git diff HEAD is empty; after a rebuild the --absent preflight passed with a clean tree, and the pins were green again (151/151 and 43/43).

Gates

Derived at c135a38d31 with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 64 commands, the same 64 as the dispatch list. Each was run at that head with its exit code recorded before any pipe.

  • --ran reconciliation: 64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED.
  • 62 exit 0, including check:driver-memory-census, check:engine-double-contract, check:objectql-double-limit, check:test-source-alias, check:cross-package-test-inputs, check:nul-bytes, check:issue-citations and check:type-check-coverage.
  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt (exit 3, PREREQUISITE NOT MET). Both need every package built, and CI runs both.
  • node scripts/check-issue-citations.mjs --base 26daf0b036 (the merge base): exit 0, 4 citations resolve.

Acceptance notes


Generated by Claude Code

The arm judged Number(value) and the write carried value, so [500], [],
true, '0x10', ' 12 ' and '12' passed and reached the driver as sent.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…tring half waits on the spec grammar

Arrays, booleans and objects are refused with invalid_number; a string is
judged by Number() exactly as before (seat update: census branch b).

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via invalid_number (literal, a string literal in validateOne))
What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 29720975b6775390d7c54cb3e51f0d70d36c6cd7 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 29720975b6775390d7c54cb3e51f0d70d36c6cd7

⚠️ 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 29720975b6775390d7c54cb3e51f0d70d36c6cd7 → 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: c135a38d3138f4b64ec6d2c28c302dec8b4f315b
Local-runs: none

Inputs read: card #20309 body and all 6 comments (5860329599 triage, 5860927463 deferral, 5861525948 claim, 5862810526 os-dev-report, 5863869574 takeover claim, 5863923799 seat answer); PR #20370 body, 5-file list and the net diff origin/main...origin/claude/issue-20309-number-arm-coercion (merge base 26daf0b036, +538/−0); the 34 check-runs on the head (31 success, 3 skipped, 0 failure); record-validator.ts, engine.ts and field-value.zod.ts read at the head by git show only.

① Derived judgments

  1. Number arm, non-string refusal — RIGHT. On NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES (verified on packages/spec/src/data/field-value.zod.ts: exactly number, currency, percent, rating, slider, progress), a value whose typeof is neither number nor string returns invalid_number before the old finite check. Newly refused at head: [500], [], ['12'], true, false, a Date, a boxed Number, and (unlisted in the changeset, unreachable over JSON) a bigint. Every one is refused by the spec's stored schema z.number().finite(), so the door moves toward the declared contract and never past it. This is triage direction 1's non-string half and the A number field's declared scale is never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501 posture (refuse, never alter).
  2. null / undefined / blank never reach the guard — RIGHT. validateOne returns on isMissing(value) (undefined, null, or a string that trims empty) before every type arm, and record write door: '' skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308's normalizeBlankTypedValues has already made a blank null. typeof null === 'object' is therefore not a regression path; the blank controls in all three new files and record write door: '' skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308's three pin files re-assert it.
  3. Nothing else in the arm moved — RIGHT. Read function-scoped, the only non-comment change in record-validator.ts is the three-line guard; the finite check, progress's early return, min / max, scale and every message are byte-identical to origin/main.
  4. summary stays unjudged — RIGHT. Subtracted by COMPUTED_VALUE_TYPES (ruling 5860986842 on record write door: '' skips every type check, so a number, boolean, date, datetime or time column stores an empty string — normalise it to null at the door (seam from objectui#10813) #20308); formula and autonumber were never in the numeric set. The validator pin writes [500], true and a Date to summary and all are accepted, as at base.
  5. Strings unchanged — RIGHT as a Part of half. '12', '12.5', '0x10', ' 12 ', '1e3' are still judged by Number() and written as sent. This departs from triage direction 1 (hex and padded strings refused) and is the seat's branch (b); ruling 5863923799 ratifies it — a shipped producer (objectui plugin-grid/src/ImportWizard.tsx, the legacy per-row fallback) sends numeric strings, so direction 2's first arm applies and the string half is sequenced behind driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field — where { amount: { $gt: "abc" } } is DATABASE_ERROR / 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336's grammar — and states the effect on this PR is none. The characterization test in record-validator.number-value.test.ts pins the unchanged half and is written to flip when that half lands. The card correctly stays open.
  6. Wire surface unchanged in vocabulary — RIGHT. No new error code, message key or status: invalid_number already exists in packages/spec/src/api/errors.zod.ts and all four message bundles; REST answers 400 VALIDATION_FAILED with fields[].code = invalid_number, batch rows success: false. The Docs Drift flag on content/docs/api/error-catalog.mdx lands on a per-type-parse code LIST (line 803) that lists the code and states no trigger; nothing in it is falsified. docs/qa/platform-checklist/areas/records-forms.json (line 3260, "a non-finite value → invalid_number") stays true as a subset. No doc edit is owed by this PR.
  7. Reach — RIGHT. Every engine write door runs the validator: insert() (validateRecord at engine.ts:12623), insertMany() delegates to insert(), update() by id and by predicate (:13892, :14203), validate() dry run (:11853); there is no engine upsert (tombstoned, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057). REST POST / PATCH / batch / updateMany and the import route write through insert() / update(), the import route pre-coercing with parseNumberCell. The changeset's "every engine, REST, batch and import write door" holds.
  8. Stored rows — RIGHT. Write-time refusal only; nothing is rewritten; the changeset carries a read-only SQLite typeof() query for the operator.
  9. Pins — RIGHT. Validator (refusal set, spec parity over every non-string input, controls), engine driver-facing payload (stub driver captures every write call; Object.is control on insert and update), REST on SQLite with the physical cell and its storage class. Memory is pinned at the engine door and measured at REST (see ③). Check-runs: Test Core and its six shards success; Type Check · workspace / source gates / consumer gates success.
  10. File surface matches the claim — RIGHT. Validator arm, objectql and rest tests, one changeset. No packages/spec, no driver, no engine.ts, no governed surface (Governed Surface Queue Guard success).

② Semver level

Clause-②: no (narrowing)

  • .changeset/20309-number-arm-non-string-refused.md: @objectstack/objectql minor, a **BREAKING** banner, Clause-②: no (narrowing), and exactly one adr-0087 HTML-comment marker reading not-required (no-migration-prescription). The PR body's Clause-②: no (narrowing) matches the changeset and both claims (5861525948, 5863869574).
  • Level judged right. The diff narrows a published accept set (arrays, booleans and objects answered 201 at base on the public REST door and are refused at head) and widens nothing, so no (narrowing) is the truthful arm. Per AGENTS.md rule 3 a (narrowing) arm is BREAKING, and check-changeset-no-major ships a breaking change as minor during the launch window — a patch here would carry a breaking change unmarked. Breaking-ness is a property of the contract, not of the census; triage direction 4's conditional ("BREAKING minor if a shipped producer's form is refused") resolves as: no shipped producer sends an array, boolean or object, so no prescription is owed, which is exactly the declared ADR-0087 disposition not-required (no-migration-prescription). No authorable key is removed or renamed, so no FROM → TO mapping is owed.
  • packages/rest changes are test-only and publish nothing, so no second changeset is owed; skip-changeset is correctly absent.
  • Gate verdicts on the head: Check Changeset (pr-automation.yml: check-empty-changeset, check-adr-0087-registration, check-changeset-no-major at the merge base) success; Lint & Repo Gates success.

③ Boundary flags

Implemented-by: claude/issue-20309-number-arm-coercion
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 05:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit db74b16 Sep 28, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20309-number-arm-coercion branch September 28, 2026 05:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…er, and the per-aggregation filter refusals name aggregations[i].filter (objectstack-ai#20334) (objectstack-ai#20368)

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

## What this does

Two `where` behaviours the other filter positions of `engine.aggregate`
lacked, per triage 5861202636's three execution notes, and a third
change the seat's answer 5863946181 ordered as a patch round on this PR
(open question 1 = B).

1. **`having` resolves `{placeholder}` tokens through the resolver
`where` uses.** `ObjectQL.resolveWhereTokens` now takes the AST slot as
a parameter (`'where'` by default, `'having'` from `aggregate`), and
`aggregate` calls it once for `having`, after the per-aggregation
filters resolve and before the middleware chain. ⛔ No second resolver:
the call is the same method, through the same stage function
(`resolveWhereFilterTokens` → `@objectstack/core`'s
`resolveFilterTokens`) that `where` and the judge use. An unknown token
is `FILTER_TOKEN_UNKNOWN` / 400 and a context token with no value is
`FILTER_TOKEN_UNRESOLVED` / 400, in `where`'s words, before any driver
read. A known token compares as the value it names.
2. **The per-aggregation `filter`'s refusals name their position.**
`assertTemporalComparandsInterpretable` and
`assertTextOperatorTargetsAreStringCapable` take an optional `path`
(default `'where'`), and the per-aggregation loop passes ``
`aggregations[${i}].filter` ``, the root its list-shape and
comparand-type doors already pass.
3. **The `having` temporal refusal's remedy is `where`'s** (patch
round). `assertHavingTemporalComparandsInterpretable` now ends its
refusal in `REMEDY[hit.kind]`, the table the `where` and per-aggregation
refusals read, and the parallel `HAVING_REMEDY` table and its docblock
are deleted (net 1 line added, 16 removed). A string a `date` or
`datetime` column cannot read now gets the remedy naming the
relative-date placeholder (`"{30_days_ago}" / "{current_month_start}"`),
which `having` resolves from item 1 on. `DATE_YEAR_REMEDY` is untouched,
and the `time` kind's words are the same string as before
(`HAVING_REMEDY.time` was `REMEDY.time`). `REMEDY`'s own docblock names
no reader, so it does not misstate one and is not edited.

Files: `packages/objectql/src/engine.ts` (`resolveWhereTokens`,
`aggregate`), `temporal-comparand-door.ts` and
`text-operator-declared-type-door.ts` (the `path` parameter; in the
first, also the `having` remedy of item 3), two new pins
(`objectql/src/engine-aggregate-positions.test.ts`,
`rest/src/rest-aggregate-positions.test.ts`), one updated pin
(`objectql/src/engine-aggregate-having-temporal-door.test.ts`: the
unknown-token row replaced, the remedy case flipped),
`.changeset/20334-aggregate-positions.md` (new: `minor`, BREAKING
narrowing, ADR-0087 `not-required (no-migration-prescription)`), and a
DELIBERATE CORRECTION to
`.changeset/20263-having-temporal-comparand-door.md` (two sentences;
each rewrite is listed under Deviations). Both door functions are
package-internal: only `engine.ts` imports them, and neither
`src/index.ts` nor `src/core.ts` re-exports them.

## How `having` reaches the one resolver (H2)

- At base, `resolveWhereTokens(ast, execCtx)` read `ast.where` alone:
`if (!ast || ast.where == null) return; ast.where =
resolveWhereFilterTokens(ast.where, execCtx);`. The per-aggregation
`filter` resolved through its own call to core's `resolveFilterTokens`,
and `having` reached no resolver at all.
- **The resolver needs no field type.** Core's walk replaces values and
never reads keys (`for (const [k, v] of Object.entries(node)) out[k] =
walk(v)`), and a token resolves from the request context alone (`now`,
`timezone`, `userId`, `orgId`). So a `having` keyed by aggregate aliases
resolves exactly as a `where` keyed by fields does. The one
field-type-aware step, reading a resolved day by a column's storage
rule, is `applyHaving`'s, as it already is for a literal day.
- **Order against the temporal door.** On `where` the door runs inside
`lowerWhereFilterArray`, before `resolveWhereTokens`, and steps around
any `classifyFilterToken` hit (core's
`isUninterpretableTemporalComparand`). `having`'s door (objectstack-ai#20263) runs in
the same position relative to the new call: every `having` door first,
then resolution. So `{ last_placed: { $lt: 'not-a-date' }, first_opened:
{ $gte: '{not_a_token}' } }` is the temporal door's `INVALID_FILTER`,
and an earlier door's refusal (`totl`, objectstack-ai#20123) keeps its words. Both are
pinned. As on `where`, a resolved value is not judged again by the door.
- Both `applyHaving` doors (native `driver.aggregate()` and the rows
fallback) read `ast.having`, so one call covers both paths, before
`executeWithMiddleware` and any driver read. The caller's `having`
object is not written back: the resolver is copy-on-write, and the AST
is the engine's own object (pinned).

## The path (H3)

- At base the per-aggregation loop called
`assertTemporalComparandsInterpretable(object, 'aggregate', schema,
aggFilter)` with no path, and the walk rooted at its default `'where'`.
- The doors that already name their position there, quoted from the
base: the list-shape door, "Received string ("2026-01-10") at
aggregations[1].filter.placed_on.$in"; the comparand-type door, "Filter
comparand at aggregations[1].filter.amount.$eq is a plain object". The
walker's refusals name `` `aggregations[1].filter` ``, and the
materializable door names no path at all.
- **Partly falsified: a second door had the same defect.** The
text-operator declared-type door (objectstack-ai#15661) was also called with no path:
`$contains` on a number field in `aggregations[1].filter` said `at
where.amount.$contains` at base, on all three drivers and both doors. It
is fixed here the same way, as a bounded in-place fix (see Deviations).
Head: `at aggregations[1].filter.placed_on.$gt`, `at
aggregations[1].filter.$or[1].placed_on.$lt`, `at
aggregations[1].filter.amount.$contains`, and `at
aggregations[2].filter.…` when the filter sits on the third aggregation.

## Measured: base `26daf0b036` and head, three drivers, both doors, both
`having` paths

InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16.13 (a
private role and database on the system cluster, database timezone
`Asia/Shanghai`, process `TZ=America/New_York`), through
`engine.aggregate` and `POST /api/v1/data/:object/query` (JSON
round-tripped), four groups c1–c4 (`max(placed_on)` 2026-01-10 / 03-01 /
01-15 / 02-01, `sum(amount)` 500 / 1200 / 50 / 20). 432 cells per tree;
driver reads counted. The head runtime is the objectql source at
`f78b1e0c9d`. The later commits of the first round change tests,
changesets and one doc comment, and `engine.ts` is byte-identical (blob
`1c4f6d0a41c2`) at `a1a42d4c4a` and at the patch round's head
`8b950b8e`. The patch round changes one runtime string, the `having`
remedy, measured in its own section below. The three drivers and both
doors agree on every row below unless the row says otherwise.

| position · input | base | head | `where` twin |
|:--|:--|:--|:--|
| `having` `{ last_placed: { $gt: '{current_year_start}' } }` | 200, no
group | 200, c1–c4 (the literal `'2026-01-01'` twin: c1–c4) | 200, c1–c4
|
| `having` `{ last_placed: { $gte: '{not_a_token}' } }` | 200, no group,
1 read | `FILTER_TOKEN_UNKNOWN` / 400, 0 reads | `FILTER_TOKEN_UNKNOWN`
/ 400 |
| `having` `'{TODAY}'` (near miss), an unknown token on `count`, under
`$and` or `$or` | 200 (no group; `$or` kept c2) | `FILTER_TOKEN_UNKNOWN`
/ 400, 0 reads | — |
| `having` `{today}` `$lte` / `{30_days_ago}` `$gt` /
`{current_month_start}` `$lt` | c1–c4 / none / c1–c4 (text order) |
c1–c4 / none / c1–c4 (resolved; the same groups by this data) | resolved
|
| `having` `{7_months_ago}` `$gt`, `$between` `['{current_year_start}',
'2026-02-01']`, `{current_year_start}` `$gte` on `min(opened_at)`,
`$not` of a token | none, none, none, c1–c4 | c2; c1, c3, c4; c1–c4;
none | — |
| `having` `$or: [{ total: { $gt: 1000 } }, { last_placed: { $gt:
'{current_year_start}' } }]` | c2 | c1–c4 | — |
| `having` `{ customer_id: '{current_user_id}' }`, user c2 | none | c2 |
c2 |
| `having` the same with no user · `{current_org_id}` with no org
(engine) · `{record_id}` | none | `FILTER_TOKEN_UNRESOLVED` / 400, 0
reads (REST with no user: 401 before and after) |
`FILTER_TOKEN_UNRESOLVED` / 400 |
| `having` `{current_org_id}`, user c2, no org, both doors | none |
`FILTER_TOKEN_UNRESOLVED` / 400 | — |
| `having` `{ total: { $gt: '{today}' } }` on `sum` | none | none,
except PostgreSQL native: c1, c3 | — |
| `aggregations[1].filter` `{ placed_on: { $gt: 'not-a-date' } }` | 400
`INVALID_FILTER` `at where.placed_on.$gt` | 400 `INVALID_FILTER` `at
aggregations[1].filter.placed_on.$gt` | 400 `at where.placed_on.$gt` |
| `aggregations[1].filter` the same under `$or`, and the number for
10000-01-01 | `at where.…` | `at aggregations[1].filter.…` | `at
where.…` |
| `aggregations[1].filter` `{ amount: { $contains: '5' } }`, `{
placed_on: { $startsWith: '2026' } }`, and under `$or` | `at
where.amount.$contains` … | `at aggregations[1].filter.amount.$contains`
… | `at where.amount.$contains` |

The PostgreSQL native `sum` cell is not a new divergence. Its literal
twin `{ total: { $gt: 'TODAY-AS-A-DAY' } }` keeps c1, c3 there on the
base too (PostgreSQL's native aggregate returns `sum` as a string,
objectstack-ai#20307's out-of-scope finding 3, the region objectstack-ai#20335 holds). The resolved
token compares exactly as that literal does.

## Collateral (H4)

- **`where`: 48 of 48 cells byte-identical** base → head (status, code,
message, groups, reads): tokens resolved and refused, the temporal and
text-operator refusals, `{current_user_id}` with and without a user.
- **`having` without a token: 96 of 96 cells byte-identical** at
`a1a42d4c4a`, including the temporal refusals in their words (the patch
round then moves one thing among them, the `date` / `datetime` temporal
refusal's remedy, measured in its section below), a nested `$or`
refusal, `a{b}c` (braces inside a string, not a placeholder), a `{
$field }` reference and the literal twins.
- **Per-aggregation `filter`, every other door: 36 of 36
byte-identical** (list-shape, comparand-type, `$median`, `$field`, the
unknown-token refusal and a resolved token's counts).
- **The per-aggregation temporal and text-operator refusals: code,
status and reads unchanged**, and at the engine the message differs in
the `at where.` → `at aggregations[1].filter.` root alone (18 of 18
cells). Over REST the message is the engine's under the existing
500-character bound (`truncateClientMessage`), on every cell of both
trees. The two `'not-a-date'` refusals (489 and 496 characters at base)
now cross the bound and lose the end of their remedy
(`"{current_month_s…`). The year-class and text-operator refusals were
already cut at base. The changeset says so.
- **A known token now compares as its resolved value, not as text**:
pinned as "the token keeps exactly the groups the value written out
keeps" on both paths, for ten shapes (a comparand, `$in`, both
`$between` endpoints, a bare day on `min(datetime)`, `$and` / `$or` /
`$not`) and `{current_user_id}`.

## Declaration and who is reached (H5)

- The claim's `Clause-②: no (narrowing)` holds. An unknown token and an
unresolvable context token on `having` answered 200 and now answer 400,
which narrows the accept set. A known token that compared as text now
resolves, which is a changed answer rather than a narrowed accept set.
`node scripts/check-changeset-no-major.mjs --base origin/main`: "✓ This
diff introduces no `major` bump." `node
scripts/check-adr-0087-registration.mjs --base origin/main`: "✓ … 1
declared-breaking changeset(s), each carrying an ADR-0087 disposition",
`.changeset/20334-aggregate-positions.md
[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
- **Shipped authors of a `{…}` string on `having`: none.** There are
five `having` clauses in `content/docs` and `skills/`: `queries.mdx`
twice, `query-syntax.mdx` and
`skills/objectstack-query/rules/aggregation.md` twice. Each compares
`order_count` / `total_spent` / `total` with a number. Outside the
engine and the spec that declare it, no runtime code, example app or
`apps/` file composes a `having`. The control grep, a `where` / `filter`
carrying a token in `examples/`, hits 16.

## Patch round: the `having` remedy (seat answer 5863946181), measured

Before is `c599f758` (the merge of `main` at `c577e66635` into
`a1a42d4c4a`, `HAVING_REMEDY` still read). After is `8b950b8e`, whose
`temporal-comparand-door.ts` is the `db334ac4` blob `3ee7abf0c722`.

- **Where the words are built (H1): one site, as hypothesised.** It is
`assertHavingTemporalComparandsInterpretable` in
`packages/objectql/src/temporal-comparand-door.ts`, the only line
carrying "The `having` was NOT applied.", and it read
`HAVING_REMEDY[hit.kind]` once. It now reads `REMEDY[hit.kind]`, and
`git grep HAVING_REMEDY` answers 0 at `8b950b8e`. The year-class branch
still reads `DATE_YEAR_REMEDY`.
- **The pin sweep (H2), partly falsified.** Terms swept across this
repository (every tracked file) and `../objectui`: `HAVING_REMEDY`, "The
`having` was NOT applied", "a 200 indistinguishable from a real answer",
"keep no group or every group", the two old remedy strings, "names no
placeholder", "literal forms only", "remedy names none",
`assertHavingTemporalComparandsInterpretable`, and `{30_days_ago}` /
`current_month_s` in test files. Only one pin read the old remedy:
`engine-aggregate-having-temporal-door.test.ts`, whose remedy case
asserted `not.toContain('{30_days_ago}')`. It is flipped (Deviations).
`packages/rest/src/data-query-having-temporal-door.test.ts` asserts only
the column clause of the REST `error` (`toContain(column)`), which sits
before the remedy, and no remedy words, so it has nothing to flip. The
other having message-equality pins
(`engine-aggregate-positions.test.ts`,
`rest-aggregate-positions.test.ts`,
`engine-aggregate-having-comparand-shape.test.ts`) compare token or
walker refusals, not this door's. No doc, skill or `objectui` text
quotes the having remedy. Every rejection assertion for a genuinely
illegal shape is unchanged.
- **What moved, at the engine.** 16 `having` cells (object
`ledger_having`, SqlDriver on SQLite, both `having` paths) plus the two
`where` twins were each read at both commits. 26 of the 26 moved
cell-paths differ from before in the remedy alone: substituting the new
remedy for the old one in the before message gives the after message
byte for byte. The 8 unmoved cell-paths are the `time` column, the year
class and the `where` twins. Both paths give one message in every cell.
The `having` remedy equals the `where` twin's remedy byte for byte, on a
`date` and on a `datetime` column.
- **Over REST, the 500-character bound (H3), confirmed.** The bound
(`CLIENT_MESSAGE_MAX`, a message of 500 or more characters is cut to 499
plus `…`) is unchanged. A `date` column's fixed words plus its new
remedy take 409 characters, which leaves 90 for the object name, the
column, what it aggregates, the quoted comparand and the path. A
`datetime` column's take 502, so every such refusal is cut. "Whole"
below means the REST `error` equals the engine message byte for byte.

| `having` cell (`date` unless stated) | engine characters, before →
after | REST, before → after |
|:--|:--|:--|
| `'not-a-date'` `$lt` on `max(placed_on)` | 382 → 481 | whole → whole |
| `'+010000-01-01T00:00:00.000Z'` `$gt` on `max(placed_on)` | 399 → 498
| whole → whole (the longest measured) |
| an `$in` member, a `$between` endpoint, the implicit-equality slot,
under `$or`, under `$not` | 385, 390, 378, 389, 387 → 99 more each |
whole → whole |
| a `placed_on` groupBy key, a `day` bucket of `opened_at` | 391, 375 →
490, 474 | whole → whole |
| `'not-a-date'` `$lt` on `min(opened_at)` (`datetime`) | 480 → 576 |
whole → cut: `…epoch milliseconds, or a relative-date pl…` |
| `'not-a-date'` on an `opened_at` groupBy key (`datetime`) | 487 → 583
| whole → cut: `…milliseconds, or a relative-…` |
| `'last_30_days'` on `max(placed_on)` / `min(opened_at)` | 385 / 483 →
484 / 579 | `VALIDATION_FAILED` / 400 from the query schema, before and
after: REST never reaches the engine with a preset name |
| control: `'not-a-date'` / `'noon'` on `max(slot)` (`time`) | 412 /
406, unchanged | whole |
| control: the number for 10000-01-01 on `max(placed_on)` (year class) |
555, unchanged | cut, before and after |
| control, `where`: `'not-a-date'` on `placed_on` / `opened_at` | 485 /
578, unchanged | whole / cut at `…or a relative-date …`, before and
after |

The changeset states this: every measured `date` refusal arrives whole,
and a `datetime` refusal is cut inside the remedy, as `where`'s already
was.

- **Ablation of the flipped pin, at `8b950b8e`.** It ran through
`scripts/ablation-replace.mjs` (WRAP) under one verify lock. The anchor
`` The \`having\` was NOT applied. ${REMEDY[hit.kind]} `` was replaced
by the old literal-only remedy table, inlined behind the marker
`ABLATION-20334-LITERAL-ONLY`. The tool read the anchor x1 → x0, the
replacement x0 → x1, and the blob `3ee7abf0c722` → `bfe6275fe997`.
Result: `engine-aggregate-having-temporal-door.test.ts` had 1 failed /
50 passed, the failure being the flipped case: `AssertionError: expected
'Write a "YYYY-MM-DD" calendar day.' to contain '"{30_days_ago}"'`. The
direction is red, as predicted. The restore was proven: blob == HEAD
`3ee7abf0c722`, `git diff HEAD` empty, tree clean. The test imports
`./engine.js`, the source, so the leg needed no build. The built `dist/`
(from `db334ac4`, the same `objectql` source) carries 0 hits of the
marker. The whole `objectql` suite ran green again after the restore
(below).

## Tests and evidence, patch round (head `8b950b8e`, after merging
`main` at `c577e66635`)

`main` at `c577e66635` brought no change to `objectql`, `core` or the
drivers. It changed `packages/rest/src` (the draft-read builder gate),
`packages/core/src/qa` and `spec`. The merge is `c599f758`, a true merge
commit with parents `a1a42d4c4a` and `c577e66635`, through
`scripts/pm/os-regen-merge.sh`: no generated artefact was pending, and
main's side was taken for every `os-regen` path. The rest closure was
rebuilt from it (`turbo run build --filter='@objectstack/rest^...'`, 24
tasks, exit 0), and `objectql` was rebuilt after the fix (dist carries
the new remedy strings and 0 of the old).

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--project repo --maxWorkers=2`: 324 files, 5890 tests passed.
- `pnpm --filter @objectstack/rest exec vitest run --project local
--project repo --maxWorkers=2`: 213 files, 3845 passed, 2 skipped (main
added two files).
- `engine-aggregate-having-temporal-door.test.ts` alone: 51 passed. The
flipped remedy case is one of them, and it goes red under the ablation
above.
- `pnpm --filter @objectstack/objectql run typecheck`: exit 0, with the
test layer: "40 file(s) / 234 error(s) / 65 pinned signature(s) held",
unchanged. `tsc -p tsconfig.test.json --listFiles` lists
`engine-aggregate-having-temporal-door.test.ts` (1 hit). `pnpm --filter
@objectstack/rest run typecheck`: exit 0.
- ESLint, narrowed and proven, at `8b950b8e`: `eslint --no-inline-config
--format json` over the 6 `.ts` files this PR changes against the merge
base counts 6 files in the JSON, 0 errors and 0 warnings. The population
is read from `eslint.config.mjs` (`packages/**/*.{ts,tsx,mts,cts}`).
Invariance: the config "never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules)" (its own
note), so this diff cannot move a verdict on an untouched file.
- The REST measurement above ran as a scratch test file placed in
`packages/rest/src` for one run at each commit and deleted after it. It
was never committed, and the tree was clean after each run.

## Tests and evidence, first round (head `a1a42d4c4a`, after merging
`main` at `29720975b6`, which touched none of these packages)

- `pnpm --filter @objectstack/objectql exec vitest run --project local
--project repo --maxWorkers=2`: 324 files, 5890 tests passed.
- `pnpm --filter @objectstack/rest exec vitest run --project local
--project repo --maxWorkers=2`: 211 files, 3819 passed, 2 skipped.
- `pnpm --filter @objectstack/driver-memory exec vitest run
--maxWorkers=2`: 57 files, 1374 passed.
- `driver-sql`'s nine aggregate test files (`src/*aggregat*`), with
`OS_TEST_POSTGRES_URL` on the private PostgreSQL 16 database and
`TZ=America/New_York`: 9 files, 174 passed, 4 skipped. The suite's own
summary: `live postgres RAN`, `live mysql NOT RUN` (no MySQL here; CI's
Temporal Conformance job runs it).
- New pins: `engine-aggregate-positions.test.ts` 28 passed,
`rest-aggregate-positions.test.ts` 6 passed. The updated
`engine-aggregate-having-temporal-door.test.ts` passes 51 and
`data-query-having-temporal-door.test.ts` passes 11.
- `pnpm --filter @objectstack/objectql run typecheck` and `pnpm --filter
@objectstack/rest run typecheck`: exit 0, test layers included (objectql
debt held at 40 files / 234 errors, unchanged; rest 0). `tsc
--listFiles` on each `tsconfig.test.json` lists the new and updated test
files (objectql 2, rest 1).
- ESLint, narrowed and proven: `eslint --no-inline-config --format json`
over the 6 changed `.ts` files counts 6 files in the JSON, 0 errors and
0 warnings. The population is read from `eslint.config.mjs`
(`packages/**/*.{ts,tsx,mts,cts}`). Invariance: that config enables no
type-aware linting (its own note), so this diff cannot move a verdict on
an untouched file.

**Ablations, at `93170f5468`, through `scripts/ablation-replace.mjs`
(WRAP) under one verify lock each, with the restore proven.** Each leg
ran mutate → `objectql` rebuilt → `ablation-dist-preflight` marker
present in 4 built files → pins. Its restore leg ran blob == HEAD
`1c4f6d0a41c2` → `git diff HEAD` empty → rebuilt → preflight `--absent`
over all 14 built files → tree clean → pins green again (79/79 objectql,
17/17 REST). My first attempt at leg 1 never ran: its lock call timed
out (exit 99) before the build, and the tool restored the file. Only the
landing runs are reported.

| leg (anchor replaced by a marker) | objectql (2 files) | REST (2
files) | what went red |
|:--|:--|:--|:--|
| the `having` resolution call | 19 failed / 60 passed | 4 failed / 13
passed | every resolved and every refused `having` cell, plus the objectstack-ai#20263
file's unknown-token row; the door-order, braces, caller-copy and
per-aggregation cells stayed green |
| the temporal door's `path` argument | 4 failed / 75 passed | 1 failed
/ 16 passed | the three temporal rows and the `aggregations[2]` index |
| the text-operator door's `path` argument | 2 failed / 77 passed | 1
failed / 16 passed | the two text-operator rows |

## Gates, patch round (head `8b950b8e`)

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `8b950b8e` derives the same 65 commands,
against the same 8 paths versus merge base `c577e66635`. All 65 ran at
`8b950b8e`, each exit code written to a file before any pipe. `--ran`
reconciliation: "✓ dispatch-gates --ran: 65 derived famil(ies) accounted
for — 63 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)." 0 were
unrun, and 62 exited 0.

- Red by design: `node scripts/check-empty-changeset.mjs --base
origin/main` exits 1 on the DELIBERATE CORRECTION of
`.changeset/20263-having-temporal-comparand-door.md`. Its lines: "✓ No
empty-frontmatter changeset introduced by this diff (2 declaring
changeset(s) added)." and "… DELIBERATE CORRECTION -- your change may
have made this PENDING release note false, and you rewrote it in the
same stroke. Remedy: do NOT restore it -- say so on the PR and get it
confirmed …". This PR says so under Deviations.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt`, reason: PREREQUISITE NOT MET (exit 3). They need
the whole workspace's built `dist`, which this worktree does not hold
("Run `pnpm build` first"; "7 workspace dependenc(ies) of the ledgered
packages have no built type entry point on disk"). CI builds it.
- Two gates refused on this clone's shallow history
(`check-engine-split-ratio.mjs --days 90`, exit 2;
`check-plugin-teardown-shape.mjs --self-test`, exit 3, "cannot read the
positive control at 621a487"). The clone was deepened as they prescribe
(`git fetch --unshallow origin main`), and both were re-run at
`8b950b8e`: exit 0 each ("48 cases pass" for the self-test). The
reconciliation records those re-runs.
- `check-changeset-no-major.mjs --base origin/main`: "✓ This diff
introduces no `major` bump." `check-adr-0087-registration.mjs --base
origin/main`: "✓ check-adr-0087-registration: 1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition."
`check-issue-citations.mjs`: "✅ … every citation this change adds
resolves". `check:nul-bytes`: "OK (… no raw ASCII control bytes)".
`check:doc-authoring`: all clean.
- `origin/main` moved on during the round, to `db74b169d` (4 commits,
among them objectstack-ai#20370 in `objectql/src/validation/record-validator.ts`).
None of them touches a file this PR changes, and the branch is not
merged again. The gates read three-dot from the merge base `c577e66635`,
so the moving pointer did not enter their change set.

## Gates, first round

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `a1a42d4c4a` derives 65 commands, the
same 65 as the dispatch's list. All ran at `a1a42d4c4a`. The `--ran`
reconciliation found 65 derived, 63 run, 2 NOT-MEASURED and 0 unrun.

- NOT MEASURED: `pnpm check:dual-build-cjs-loads` and `pnpm
check:type-check-debt`, reason: PREREQUISITE NOT MET (exit 3). Both read
whole-workspace built artefacts this worktree does not hold, and CI
builds them.
- Red by design: `node scripts/check-empty-changeset.mjs --base
origin/main` exits 1 on the one DELIBERATE CORRECTION below. In its own
words, the fix is to "say so on the PR … and get it confirmed … this
gate stays red either way". Its other line: "✓ No empty-frontmatter
changeset introduced by this diff (2 declaring changeset(s) added)".
- `node scripts/check-issue-citations.mjs --base 2972097`: exit 0, "✅
… every citation this change adds resolves", 8 citations.
- Also run, as the derivation flagged their rosters under this diff's
directories: `node scripts/check-changeset-fixed.mjs`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, all exit 0.

## Deviations

- **DELIBERATE CORRECTION,
`.changeset/20263-having-temporal-comparand-door.md`**: two sentences,
numstat 2/2 against `main`. `Check Changeset` stays red by design. Every
rewrite, in order:
1. First round, the `What is judged` clause. Before (on `main`):
"`having` does not resolve placeholders, and did not before, so the
refusal's remedy names none." After (`a1a42d4c4a`): "`having` resolves
placeholders from the same release (objectstack-ai#20334), after this door, and the
refusal's remedy names none."
2. Patch round, the same clause again, because its last half is false at
`8b950b8e`. Before: the `a1a42d4c4a` text above. After: "`having`
resolves placeholders from the same release (objectstack-ai#20334), after this door,
so the refusal's remedy on a `date` or `datetime` column is the `where`
refusal's and names them, e.g. `{30_days_ago}` /
`{current_month_start}`."
3. Patch round, the note's own **Fix.** line gains the placeholder form.
Before: "**Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a
`datetime` column with an ISO-8601 instant, a bare day or epoch
milliseconds, and a `time` column with an `HH:MM` or `HH:MM:SS` wall
clock." After: "**Fix.** Compare a `date` column with a `YYYY-MM-DD`
day, a `datetime` column with an ISO-8601 instant, a bare day or epoch
milliseconds, either one with a relative-date placeholder the resolver
knows (`{30_days_ago}`, `{current_month_start}`; `having` resolves them
from the same release, objectstack-ai#20334), and a `time` column with an `HH:MM` or
`HH:MM:SS` wall clock."

Its "Unchanged" list ("`{today}`-style placeholders, known or not" and
"every existing `having` refusal, in its words") is a before-and-after
statement about objectstack-ai#20263's own door, so it stays TRUE and is not touched.
The sentence "The refusal follows the `where` door's words" is truer now
and is not touched either.
- **This PR's own `.changeset/20334-aggregate-positions.md`, patch
round** (not a correction: the file is new in this PR). Added: the
paragraph "**The `having` temporal refusal's remedy is `where`'s.** …",
which states the change and the REST bound reading in the patch-round
section above. Rewritten: "…keeps that refusal and its words." became
"…keeps that refusal, in that door's words.", and the Unchanged clause
"every `having` that carries no placeholder, its refusals in their
words;" became "every `having` that carries no placeholder, and its
refusals in their words other than the `date` / `datetime` temporal
refusal's remedy above;". Both old sentences read as "the words did not
move", which is false at `8b950b8e`.
- **Bounded in-place fix beyond the claimed surface:
`packages/objectql/src/text-operator-declared-type-door.ts`**, its
`path` parameter and the `path` field's doc line only. All four
conditions hold. ① It is ruling 2's defect class exactly: a
per-aggregation refusal naming `where`. ② The fix is mechanical and its
shape is pinned by the temporal door's. ③ No open PR touches the file (a
census of the 13 open PRs' file lists, including objectstack-ai#20309's branch;
objectstack-ai#20335's branch has no commits). ④ The same pins and gate families cover
it. The first round's claim did not list this file; the takeover claim
5863879760 now does (open question 2 = A).
- **`temporal-comparand-door.ts` beyond "its path parameter only"**: in
the first round the `HAVING_REMEDY` doc comment was corrected with no
runtime byte moved, and whether the remedy should name a placeholder was
put to the seat. The seat answered B (5863946181), so the patch round
deletes `HAVING_REMEDY` and its doc comment and the `having` refusal
reads `REMEDY`. This is item 1 of that answer, not a surface breach.
- **The objectstack-ai#20263 pin that held the defect is replaced, not re-spelled.**
Its row `'an unknown {placeholder} too'` asserted 200 with no group for
`{not_a_token}` on `having`, which is the branch this change removes. It
is now a case asserting `FILTER_TOKEN_UNKNOWN` / 400 with zero reads,
not the door's `INVALID_FILTER`. In the first round the remedy case's
title "having resolves none" was retitled with its assertion unchanged.
In the patch round the case is flipped and retitled "the remedy names
the placeholder the resolver knows, in the where twin's words". For a
`date` and a `datetime` column it asserts the envelope (`INVALID_FILTER`
/ 400 through `expectHavingRefusal`, both paths, empty or populated, no
read), the message's first sentence, `"{30_days_ago}"` in the remedy,
and the remedy byte-equal to the `where` twin's. The ablation below
turns it red.
- **"Pin on the three drivers"** (ruling 3) is executed as objectstack-ai#20307
executed it. No driver reads `having` and every refusal precedes the
driver, so the engine is pinned on both path shapes with counting
drivers (objectql), and the engine and REST doors over a real SqlDriver
on SQLite (rest), with the `where` twin as the control everywhere.
InMemoryDriver and PostgreSQL were measured (432 cells per tree, above),
not pinned. No package in the claim's surface holds the engine together
with those drivers: objectql has neither, and rest has `driver-sql` but
no `driver-memory` and no CI job hands it a PostgreSQL URL. Adding
either is outside the surface.

## Acceptance notes (observations, not filed)

- The first round's note that `HAVING_REMEDY` named only literal forms
is resolved by the patch round (open question 1 = B).
- Over REST, the 500-character bound now cuts a `datetime` column's
`having` refusal inside the remedy, before the placeholder it names
(`…epoch milliseconds, or a relative-date pl…`). The `where` refusal for
a `datetime` field was already cut at the same place on `main` (578
characters, in the patch-round table above). The engine message is
whole, and the order kept the bound as it is. So the placeholder half of
the remedy reaches an in-process caller, and a REST caller only on a
`date` column. Noted, not filed.
- The per-aggregation `filter` still resolves tokens through a direct
call to core's `resolveFilterTokens` with `filterTokenContextFrom`,
which is the same resolver in a second spelling of the stage function
`resolveWhereFilterTokens`. It is behaviour-identical and not changed
here.
- The per-aggregation temporal refusal keeps `where`'s consequence
sentence ("reach the driver as written … return 200 with an empty
result"). For a per-aggregation filter the consequence is a wrong count
for that one aggregation. Ruling 2 moved only the path, so the wording
is left as it was.
- `.changeset/20148-aggregation-filter-where-doors.md` says the
per-aggregation filter is "refused by the temporal-comparand door
`where` takes, run unchanged on this position, in its words". I judge it
TRUE as scoped to objectstack-ai#20148: the same door and sentence, now with this
position's location clause. It is not corrected, and the seat may
re-judge.

## Open questions (answered by the seat)

- **Q1, the `having` remedy's words: B, in this PR** (seat answer
5863946181). `having`'s temporal refusal reads `REMEDY` and
`HAVING_REMEDY` retires, done in this patch round (item 3 above).
- **Q2, the claim's file surface: A** (seat answer 5863946181). The
takeover claim 5863879760 amends its `File surface:` to list
`text-operator-declared-type-door.ts`, its path parameter and one doc
line.

## Out-of-scope findings

None filed or proposed. The only divergence seen, PostgreSQL's native
`sum` returned as a string, is objectstack-ai#20307's finding 3, which objectstack-ai#20335 holds.

Authored in two rounds. The first was an os-dev run under
`session_01Bvd69VPa6puiNzzPUroDBx` (report 5862763145). The patch round
was an os-dev run under the seat session
`session_01N8TPEsoJxPsdSdNKGnNGEN` (takeover claim 5863879760; this
round's report is on objectstack-ai#20334).

---------

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants