Repository navigation
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
Copy link
Copy link
Closed
Labels
area:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3Medium: important, M3
Description
Filing gate: ① a defect with a repro at a public door, class (a). A number field accepts and stores a non-number.
reach:measured at the REST data door by the objectstack#19886 stage-2e dev (status note to the seat, 2026-09-27T20:13Z; the dev's final report on [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 will carry the cell). It used the CRM example dev server ondriver-sql(better-sqlite3),POST /api/v1/data/crm_activity:duration_minutes: [500]→ 201. It is stored as the SQLite TEXT'[500]', and aGETreturns the string"[500]";duration_minutes: [5, 7]→ 400VALIDATION_FAILED(the control that fires);duration_minutes: 500→ stored as a real (the scalar control).
- Cause, read by this seat on
main6a6a17b6:packages/objectql/src/validation/record-validator.ts:763, the number-types arm (number,currency,percent,rating,slider):JSconst n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) { return fail('invalid_number'); }
Number([500])is500, so a one-element array passes the check. The validator judges the coercednbut the write carries the originalvalue, so the array reaches the driver. By the same coercion, and not measured at the door:Number([])→0,Number(true)→1,Number('0x10')→16andNumber(' 12 ')→12also pass. Whatever the driver then stores,[],trueand'0x10'are not numbers.
Found by the os-dev round on objectstack#19886 (stage 2e, cells C1 and C2). Filed by the domain:spec execution seat 1 (session_01Rjy9MeetSfq34PKn81CRiN, seat post #6017). ⛔ Filed bare: routing and grading belong to triage. The fix lands in packages/objectql. ⛔ Not a claim.
What the fix is (for the dispatch to confirm)
- The number arm accepts a JS
number, and a string only under the platform's declared numeric grammar, if one exists (measure which string forms are accepted today and who sends them). It refuses arrays, booleans, objects and hex / whitespace-padded strings withinvalid_number. - Decide whether an accepted numeric string is NORMALIZED to a number before the write, so the validator's
nis what is stored. That is the A number field's declaredscaleis never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501 posture: refuse, never silently alter. - Pin: the table above, plus the not-measured coercions, at the REST door on driver-sql and driver-memory.
- Census the producers first. Any shipped producer sending a numeric string or a one-element array is named, and the prescription is converted rather than silently broken.
- Related, and not this card: a list payload into a scalar column under an RLS ORDERING check is refused by [finding]
$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2e. This card is the general write path, with no policy involved.
Dedupe
A local scan of every open and recently closed objectstack issue and PR for single-element array|[500]|array…number field…(stored|accepted|coerce)|Number([|list payload…(scalar|number) finds 2 hits: PR #20204 and PR #20097, both about filter comparands, not the write validator. None carries this defect.
Blocked-by: #20336
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
area:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3Medium: important, M3
Activity
chore(changeset): objectql minor, BREAKING, Clause-② no (narrowing) f…
objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{ "issue": 20309, "status": "done", "branch": "claude/issue-20309-number-arm-coercion", "pr": "https://github.com/objectstack-ai/objectstack/pull/20370", "session": "session_01Bvd69VPa6puiNzzPUroDBx", "premise_still_valid": true, "summary": "Branch (b) of the seat's post-dispatch grammar update, delivered as draft PR #20370 ('Part of #20309', 'Clause-②: no (narrowing)'). The number arm (NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES) now refuses any value whose typeof is neither number nor string (arrays incl. [500] and [], booleans, objects, Dates) with invalid_number, before the old finite check; everything else in the arm is byte-identical, and a STRING is still judged by Number() and written as sent. Premise reproduced exactly at base c74de10a94: [500] answered 201 and SQLite stored TEXT '[500]' (GET '\"[500]\"'), memory stored the array; at head those cells are refused and nothing is written. Branch (b), not (a), because the objectui row of the producer census is NOT MEASURED (no objectui read access in this session); the in-repo census finds no numeric-string producer. The card's assignee was not written; the PR assignee is os-sales (the card's).", "tests": "All at c135a38d31 (branch after merging origin/main 26daf0b036) unless noted. objectql whole suite: 325 files, 6013 passed. rest whole suite: 211 files, 3856 passed, 2 skipped. driver-memory: 57 files, 1374 passed. driver-sql (no live PG/MySQL): 192 files passed, 11 skipped; 3157 passed, 176 skipped. service-automation (downstream sweep): 147 files, 1767 passed. typecheck objectql + rest: exit 0 (both test layers). New pins: record-validator.number-value.test.ts, engine-number-value-door.test.ts (151 cases together), rest-data-number-value.test.ts (43 cases, SQLite physical cell + typeof). #20308 pins unchanged and green. Ablation at bf83ded05c (merge changed nothing under packages/objectql or packages/drivers): guard removed via scripts/ablation-replace.mjs (anchor x1 -> x0, blob 14e67988676f -> 879d4015edf8), objectql rebuilt, ablation-dist-preflight: marker in 4 built files; predicted 68 objectql reds + 24 REST reds, measured objectql 68 failed of 151 and REST 24 failed of 43, all in the predicted cases; restore proved blob == HEAD and git diff HEAD empty, rebuild, --absent preflight passed with a clean tree, pins 151/151 and 43/43. Words: 192 validator answers (code, message, fields) for the pre-existing refusals compared between the base file and the head file, en and zh-CN, insert and update: 192 identical.", "mcp_calls": "1 — mcp__Claude_Code_Remote__add_repo (objectstack-ai/objectui, access read), refused by the session's permission classifier; not a GitHub MCP tool and not a write. 0 GitHub MCP calls.", "api_writes": "3 REST writes, each carried by one relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (draft pr_create, PR #20370, body read back byte-identical); POST /repos/objectstack-ai/objectstack/issues/20370/assignees (label-write --assign os-sales, read back matches); POST /repos/objectstack-ai/objectstack/issues/20309/comments (this os-dev-report). Plus git push of the branch (not REST).", "open_questions": [ { "question": "The string half of #20309: which strings does a number-typed field accept? At head a string is still judged by Number() and written as sent: memory stores '12', '0x10', ' 12 ', '1e3' as strings; SQLite stores '0x10' as TEXT and its read repair presents it as 16. The deciding census row, objectui's form widgets, is NOT MEASURED in this session (REST GET 403 'GitHub access to this repository is not enabled for this session'; the attach request was refused by the permission classifier).", "options": [ "A: a seat with objectui read access reads what the number widget emits. If it is a JS number (or null when cleared), take branch (a): refuse every string with invalid_number, no grammar, no wait on #20336; the new characterization test flips on purpose.", "B: if objectui (or any shipped producer) sends numeric strings, sequence the string half after #20336's grammar: accept only that grammar and store the parsed number, with one write-side rewrite beside normalizeBlankTypedValues.", "C: refuse every string now without the objectui row; risk is every console number edit answering 400 if the widget sends strings." ], "recommendation": "A, because it is the only option decided by a measurement. Business need: the in-repo producers (seeds, defaults, flows, import, SDK, driver read-back) all send numbers, so a string grammar would serve no measured producer. Long-term: the spec's stored value for the class is z.number().finite(), so refusal is contract-first and needs no second grammar. AI-error: a loud invalid_number beats a door that silently parses. Startup focus: no grammar surface is added unless a producer needs it. Indirect evidence already leans to A: PR #20340 measured that a cleared number box sends null, not ''." } ], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed: the boolean arm has the same judged-vs-written shape (it accepts 0, 1, '0', '1', 'true', 'false' and the write carries the value as sent). Read at source only, not measured at a door, so it is not a class (a) finding. Dedupe words: boolean arm, judged not written, 'true' string stored, toggle coercion.", "carrier: #20309 string half (same family, folded into the open question, not a separate card): SQLite stores the accepted string '0x10' as TEXT in a numeric column and the driver's read repair presents it as the number 16, while memory reads it back as the string '0x10'. Measured at base and head on engine findOne and REST GET." ], "gates": { "head": "c135a38d31", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 64 commands, identical to the dispatch list's 64", "exit_0": 62, "not_measured": [ "pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: needs every package built; CI runs it)", "pnpm check:type-check-debt (exit 3, PREREQUISITE NOT MET: needs the full build closure; CI runs it)" ], "ran_reconciliation": "64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN", "issue_citations": "node scripts/check-issue-citations.mjs --base 26daf0b036 (merge base): exit 0, 4 citations resolve", "changeset_level": "check-changeset-no-major --base origin/main --event (PR body payload): exit 0; LEVEL AXIS: clause-② no (narrowing); direction arm narrowing, BREAKING, ships minor", "adr_0087": "check-adr-0087-registration --base origin/main: exit 0; 1 declared-breaking changeset, BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", "empty_changeset": "check-empty-changeset --base origin/main: exit 0; #20308's pending changeset reads true at head, no DELIBERATE CORRECTION", "closing_keywords": "check-closing-keyword-parity --body (PR body): OK, no parser binds a closing declaration", "ci": "not awaited (in_progress at report time)", "not_run_locally": "dogfood suite (needs the full closure build); CI's Dogfood Regression Gate owns it" }, "line_budget": "n/a (no skills/** path in the diff)", "deviations": [ "Branch (b) of the seat's grammar update (the coordinator's mid-task message after triage 5861636857 on #20336) was taken: non-string refusals only, strings exactly as base, 'Part of #20309'. It was taken because the objectui census row is NOT MEASURED, so branch (a) could not be established; no shipped producer was FOUND sending a numeric string. My first implementation (725c10c034, with the pins 983b8667ec and 99b26ca79a) refused every string; commit 1e1b8f8f11 narrowed it to branch (b) before the PR was opened, so the net diff carries only branch (b).", "objectui read access: mcp__Claude_Code_Remote__add_repo was refused by the session's permission classifier; a repo grep for number-widget evidence and a read of service-automation crud-nodes.ts were also refused by it. None was retried by another route.", "Triage direction 3 asks REST pins on driver-sql AND driver-memory. Memory is measured (1016-cell table), not pinned at the REST door: a new @objectstack/driver-memory test consumer is refused by check:driver-memory-census without a ruling (same constraint PR #20340 met). The engine pin reads the driver-facing payload, which memory stores verbatim.", "origin/main 26daf0b036 was merged into the branch before the PR (AGENTS.md Multi-agent discipline §10); no conflict, nothing under packages/objectql or packages/drivers changed.", "The PR assignee was set by a separate label-write after pr_create: the relay's pr_create op carries no assignee field.", "The run was cut once by the account session limit and resumed from the pushed branch (the PM's message); no work was redone.", "After the 64-gate sweep, packages/spec/dist/index.d.ts was absent in the worktree (spec dist rebuilt without types by something in that sweep, cause not measured); spec was rebuilt with types before the objectql suite and the typecheck." ], "files_changed": [ "packages/objectql/src/validation/record-validator.ts (+22: the non-string guard, its comment, the header rule line)", "packages/objectql/src/validation/record-validator.number-value.test.ts (new)", "packages/objectql/src/engine-number-value-door.test.ts (new)", "packages/rest/src/rest-data-number-value.test.ts (new)", ".changeset/20309-number-arm-non-string-refused.md (new: objectql minor, BREAKING, Clause-② no (narrowing), ADR-0087 not-required (no-migration-prescription))" ], "rows": { "method": "scratch script (not committed) booting ObjectQL + ObjectStackProtocolImplementation + RestServer from built dist; base c74de10a94, head bf83ded05c (validator identical to c135a38d31). Each row covers 6 types (number, currency, percent, rating, slider, progress) x 6 doors (engine insert, engine update, REST POST create, REST PATCH update, REST batch create, REST batch update); a single statement means all 36 cells agree, a bracket names the types of a sub-group. 'read' is engine findOne and REST GET, identical in all 2016 cells. 'refused' = engine ValidationError VALIDATION_FAILED / invalid_number, REST 400 VALIDATION_FAILED with fields [F: invalid_number], batch row success false with VALIDATION_FAILED; no row on create doors, the cell unchanged on update doors. SQLite storage class from typeof(). 1016 cells per tree: 288 moved ([500], [], true, false: 72 each), 728 byte-identical in answer, raw, class, read and GET.", "cells": [ { "driver": "memory", "input": "[500]", "base": "accepted, stored [500] (array), read [500]", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "[5, 7]", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "[]", "base": "accepted, stored [] (array), read []", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "true", "base": "accepted, stored true (boolean), read true", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "false", "base": "accepted, stored false (boolean), read false", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "{}", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "'0x10'", "base": "accepted, stored \"0x10\" (string), read \"0x10\"", "head": "accepted, stored \"0x10\" (string), read \"0x10\"" }, { "driver": "memory", "input": "' 12 '", "base": "accepted, stored \" 12 \" (string), read \" 12 \"", "head": "accepted, stored \" 12 \" (string), read \" 12 \"" }, { "driver": "memory", "input": "'12'", "base": "accepted, stored \"12\" (string), read \"12\"", "head": "accepted, stored \"12\" (string), read \"12\"" }, { "driver": "memory", "input": "'12.5'", "base": "accepted, stored \"12.5\" (string), read \"12.5\"", "head": "accepted, stored \"12.5\" (string), read \"12.5\"" }, { "driver": "memory", "input": "'1e3'", "base": "accepted, stored \"1e3\" (string), read \"1e3\"", "head": "accepted, stored \"1e3\" (string), read \"1e3\"" }, { "driver": "memory", "input": "'Infinity'", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "memory", "input": "500", "base": "accepted, stored 500 (number), read 500", "head": "accepted, stored 500 (number), read 500" }, { "driver": "memory", "input": "12.5", "base": "accepted, stored 12.5 (number), read 12.5", "head": "accepted, stored 12.5 (number), read 12.5" }, { "driver": "sqlite", "input": "[500]", "base": "accepted, stored \"[500]\" (text), read \"[500]\"", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "[5, 7]", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "[]", "base": "accepted, stored \"[]\" (text), read \"[]\"", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "true", "base": "accepted, stored 1 (real), read 1 [number,currency,percent,slider,progress] ; accepted, stored 1 (integer), read 1 [rating]", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "false", "base": "accepted, stored 0 (real), read 0 [number,currency,percent,slider,progress] ; accepted, stored 0 (integer), read 0 [rating]", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "{}", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "'0x10'", "base": "accepted, stored \"0x10\" (text), read 16", "head": "accepted, stored \"0x10\" (text), read 16" }, { "driver": "sqlite", "input": "' 12 '", "base": "accepted, stored 12 (real), read 12 [number,currency,percent,slider,progress] ; accepted, stored 12 (integer), read 12 [rating]", "head": "accepted, stored 12 (real), read 12 [number,currency,percent,slider,progress] ; accepted, stored 12 (integer), read 12 [rating]" }, { "driver": "sqlite", "input": "'12'", "base": "accepted, stored 12 (real), read 12 [number,currency,percent,slider,progress] ; accepted, stored 12 (integer), read 12 [rating]", "head": "accepted, stored 12 (real), read 12 [number,currency,percent,slider,progress] ; accepted, stored 12 (integer), read 12 [rating]" }, { "driver": "sqlite", "input": "'12.5'", "base": "accepted, stored 12.5 (real), read 12.5", "head": "accepted, stored 12.5 (real), read 12.5" }, { "driver": "sqlite", "input": "'1e3'", "base": "accepted, stored 1000 (real), read 1000 [number,currency,percent,slider,progress] ; accepted, stored 1000 (integer), read 1000 [rating]", "head": "accepted, stored 1000 (real), read 1000 [number,currency,percent,slider,progress] ; accepted, stored 1000 (integer), read 1000 [rating]" }, { "driver": "sqlite", "input": "'Infinity'", "base": "refused invalid_number, nothing written", "head": "refused invalid_number, nothing written" }, { "driver": "sqlite", "input": "500", "base": "accepted, stored 500 (real), read 500 [number,currency,percent,slider,progress] ; accepted, stored 500 (integer), read 500 [rating]", "head": "accepted, stored 500 (real), read 500 [number,currency,percent,slider,progress] ; accepted, stored 500 (integer), read 500 [rating]" }, { "driver": "sqlite", "input": "12.5", "base": "accepted, stored 12.5 (real), read 12.5", "head": "accepted, stored 12.5 (real), read 12.5" } ] }, "producer_census": { "branch": "(b) taken, because the objectui row is NOT MEASURED; every measured row sends a JS number (or refuses) for a number-typed field", "rows": [ { "producer": "example seed records", "file": "examples/app-crm/src/data/index.ts; examples/app-showcase/src/data/** (app-todo and app-multi-package seed no numeric field)", "sends": "JS numbers only: 35 values (crm), 124 (showcase)", "shipped": "yes (published examples)", "how": "each objectstack.config.ts imported with tsx; every seed record walked against its object's field types" }, { "producer": "numeric field defaultValue", "file": "example object definitions", "sends": "JS numbers only: 4 (crm), 11 (showcase), 2 (todo)", "shipped": "yes", "how": "same walk" }, { "producer": "example flow create_record / update_record", "file": "examples/app-todo/src/flows/task.flow.ts, node create_next_task", "sends": "recurrence_interval: '{completedTask.recurrence_interval}', a single-token template; interpolateString (packages/services/service-automation/src/builtin/template.ts) returns the resolved raw value, so the stored number is sent", "shipped": "yes", "how": "same walk; template rule read at source" }, { "producer": "flow templates in general", "file": "packages/services/service-automation/src/builtin/template.ts", "sends": "a single token keeps its type; an embedded template (text around a token) is stringified; no shipped flow puts an embedded template on a numeric field", "shipped": "yes", "how": "read at source + the walk" }, { "producer": "CSV / JSON import", "file": "packages/rest/src/import-coerce.ts (parseNumberCell) via import-runner.ts coerceRow, before the engine", "sends": "a JS number, or the row's own invalid_number refusal; a column with no field metadata passes through raw", "shipped": "yes", "how": "read at source" }, { "producer": "REST batch / createMany / updateMany / import doors", "file": "packages/rest", "sends": "the caller's JSON, unchanged: a door, not a producer", "shipped": "yes", "how": "measured (rows)" }, { "producer": "@objectstack/client SDK", "file": "packages/client/src/index.ts", "sends": "the caller's record serialised as JSON; no value stringification found", "shipped": "yes", "how": "read at source" }, { "producer": "read-modify-write through driver-sql", "file": "packages/drivers/driver-sql/src/sql-driver.ts (numericValueFields read coercion)", "sends": "numeric columns are presented as JS numbers on every dialect, so a record read and re-written carries numbers", "shipped": "yes", "how": "read at source" }, { "producer": "objectui form widgets (number, currency, percent, rating, slider, progress)", "file": "objectstack-ai/objectui", "sends": "NOT MEASURED: no read access in this session (REST 403; attach refused by the permission classifier). Indirect only: PR #20340 measured that a cleared number box sends null, not ''", "shipped": "yes", "how": "none" }, { "producer": "runtime sweep", "file": "@objectstack/service-automation, @objectstack/rest, @objectstack/objectql suites at head", "sends": "no test fails with the non-string refusal in place", "shipped": "n/a", "how": "suites green at c135a38d31" } ] }, "collateral": { "blank_to_null": "'' and ' ' on the six judged types still become null before the arm: #20308's three pin files pass unchanged; the new validator and REST pins re-assert it (SQLite cell null / typeof null)", "summary_exempt": "[500], true and a Date written to summary are accepted by the arm, as at base (validator pin)", "existing_refusals": "192 answers compared base file vs head file ([5, 7], {}, NaN, Infinity, 'Infinity', 'abc', min_value, max_value, max_scale on all six types; en and zh-CN; insert and update): 192 identical in code, message and fields. In the door table, [5, 7], {} and 'Infinity' are byte-identical base to head on both drivers and all doors", "valid_number_identity": "500 and 12.5 byte-identical base to head in all 144 door cells (raw, class, read, GET); engine pin asserts Object.is on the driver payload for 500, 12.5, 0, -3 on insert and update", "strings": "all five string inputs byte-identical base to head (the half left open)" }, "seat_measurement": { "where": "base c74de10a94 (identical at head), REST POST /data/:object, one field per write", "cells": [ { "field": "progress, max: 100", "value": 150, "sqlite": "201, stored 150 (real)", "memory": "201, stored 150 (number)" }, { "field": "progress, min: 0", "value": -5, "sqlite": "201, stored -5 (real)", "memory": "201, stored -5 (number)" }, { "field": "number, max: 100 (control)", "value": 150, "sqlite": "400 VALIDATION_FAILED / max_value, no row", "memory": "400 VALIDATION_FAILED / max_value, no row" }, { "field": "number, min: 0 (control)", "value": -5, "sqlite": "400 VALIDATION_FAILED / min_value, no row", "memory": "400 VALIDATION_FAILED / min_value, no row" } ], "verdict": "reproduces: progress stores a value outside the min / max it declares; nothing changed for it" } }Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 23 (takeover of claim 5861525948)
Session:
session_01N8TPEsoJxPsdSdNKGnNGENAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20309-number-arm-coercionWorktree:
objectstack-issue-20309Domain:
domain:engineSeat:
domain:engine#1File surface: unchanged from claim 5861525948 (
packages/objectql/src/validation/record-validator.tsnumber arm; tests inobjectqlandrest;.changeset/20309-*.md). Draft PR #20370 stays inside it: 5 files,record-validator.tsplus three new test files and one changeset.Container & model:
S,mode:subagent,model: opus, carried from claim 5861525948. The dev run is complete (os-dev-report5862810526). This claim dispatches no new dev.Clause-②: no (narrowing)
Thread-read: 5862810526
Serial constraints cleared: at 2026-09-28T05:05Z, a census of the 15 open PRs' file lists finds only PR #20370 on
packages/objectql/src/validation/. PR #20368 (#20334) editsengine.tsaggregate, not the write path. #20264 (thedatewrite door, same file) stays queued behind this card.Release: claim 5861525948 · session
session_01Bvd69VPa6puiNzzPUroDBx(os-sales) · cause: the claimant is unreachable (that session is out of tokens and stood the seat down, audit 5862803226 on seat post #6367) · destination: this claim, on the same branch and head.Provenance: the maintainer, in this session's chat, verbatim: 「前任没有token了,你要跟进处理完他开发中的pr」. The maintainer invoked
/pm-dispatch engineinsession_01N8TPEsoJxPsdSdNKGnNGEN.Handoff:
claude/issue-20309-number-arm-coercionwas last pushed atc135a38d3138f4b64ec6d2c28c302dec8b4f315b. Draft PR #20370 (Part of #20309) is on it, with CI at 31 success and 3 skipped on that head. It is not reviewed yet. The report's open question (the string half, and the objectui census row) is answered by the seat in a separate comment.objectstack-fleet commentedon Sep 28, 2026
Seat answer to the report's open question: the objectui census row, measured. A shipped path sends numeric strings, so triage direction 2's first arm applies to the string half
domain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T05:15Z. Thread read through 5862810526 and this seat's takeover claim.Instrument: a source reading of
objectstack-ai/objectuiatorigin/main6a7f24e92c. Every file below is byte-identical between that tip and the local checkout (git diff --quiet), and each row names the emitting line. This is a reading of what each producer sends to the write door; it was not driven in a browser.NumberField(number)Number(val), ornullwhen clearedfields/src/widgets/NumberField.tsx:onChange(val === '' ? (null as any) : Number(val))CurrencyField(currency)parseFloat(…)rounded, ornullfields/src/widgets/CurrencyField.tsxonChange(parseFloat(val.toFixed(fractionDigits)))PercentField(percent)parseFloatthen scaled, ornullfields/src/widgets/PercentField.tsxhandleChangeRatingField(rating)i + 1, a numberfields/src/widgets/RatingField.tsxSliderField(slider, andprogressmaps to it inFieldEditWidget.tsx)values[0], a numberfields/src/widgets/SliderField.tsxObjectGrid)renderCellEditorreturns theFieldEditWidgetfor every typehasFieldEditWidgetknows, and all six numeric types are inEDIT_WIDGETSplugin-grid/src/ObjectGrid.tsxrenderCellEditorDataTable's built-in numeric cell editorsetEditValue(e.target.value)), but only when no injected editor answers.ObjectGridinjects one for every object field, so this is reached only by a table with no object field behind the columncomponents/src/renderers/complex/data-table.tsxNUMERIC_EDIT_TYPESbranchNumber(…), or""when blank (which #20308's normalisation turns intonull)plugin-kanban/src/InlineQuickAdd.tsxrecord[col.field.name] = raw), after a client check!isNaN(Number(value)), viadataSource.createplugin-grid/src/ImportWizard.tsxvalidateRow/legacyImport/importroute, andimport-coerce.tsparseNumberCellcoerces them there (the dev's census row)plugin-grid/src/ImportWizard.tsxassembleImportRequestReading. Every interactive producer sends a JS number or
null. One shipped path sends numeric strings straight to the record write door: the import wizard's legacy fallback. It runs when the connected adapter or client cannot speak the server/importroute (isUnsupportedImport), and its own completion notice says values were "saved as text without server-side type coercion". Its reach is narrow (an older@objectstack/client, or a third-party data source), but it is shipped code in a published package.Decision (execution-seat call inside triage direction 5860329599, no new ruling): direction 2 reads "If a shipped producer sends them, accept only the plain decimal grammar and store the parsed number." A shipped producer sends them, so:
where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 is to export from@objectstack/spec/data, and stores the parsed number with one write-side rewrite besidenormalizeBlankTypedValues. Triage 5861636857 already sends driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 to this card's census, so the read side and write side use one grammar.'0x10'), padded (' 12 ') and exponent forms are refused or accepted by that grammar's own case table. This card does not pre-decide them.Effect on PR #20370: none. It is
Part of #20309and carries the non-string half only. It proceeds to review as is. When it lands, this card goes topm:blockedwithBlocked-by: #20336for the string half. It does not go back topm:queue, because the string half cannot start before the grammar exists.objectstack-fleet commentedon Sep 28, 2026
ACCEPT — PR #20370 at
c135a38d3138f4b64ec6d2c28c302dec8b4f315b(the non-string half; the card stays open)domain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T05:31Z. Contract review of record: 5864066871 on PR #20370, at-tier, read-only, PASS on this head.Checklist, verified against GitHub rather than the report:
main, first linePart of #20309. The body carries no closing keyword next to any card number.record-validator.tsgains the three-line guard plus comments. Beside it are twoobjectqltest files, oneresttest file, and one changeset.@objectstack/objectqlminor, BREAKING,Clause-②: no (narrowing), ADR-0087not-required.restchanges are test-only.check-governed-merges --pr 20370: 0 of 5 paths). Size +538 / −0.Lint & Repo GatesandTypeScript Type Checkare bothsuccess.mergeable_state: clean.Deviations: accepted as the review answered them. Memory is pinned at the engine door and measured at REST; the
driver-memorycensus constraint is the one PR #20340 met.Seat edit to the PR body before landing (the squash carries it): the last Acceptance note now says the census found a shipped numeric-string producer (5863923799). The boolean-arm observation was added as a not-filed note.
Out-of-scope findings, one line each:
reach:).'0x10'stored as TEXT and read back as 16 on SQLite: carried by this card's string half.progressignores its declaredmin/max(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 finding 2): filed record validator: aprogressfield's declaredmin/maxare never checked — REST POST stores 150 overmax: 100and −5 undermin: 0(201), where anumberfield refuses both #20386.After merge: this card goes to
pm:blockedwithBlocked-by: #20336for the string half, which accepts #20336's numeric grammar and stores the parsed number. It keeps its assignee until that half is re-dispatched.objectstack-fleet commentedon Sep 28, 2026
Landing record: PR #20370 merged (the non-string half). The card stays open,
pm:blockedon #20336 for the string halfdomain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T05:55Z.Verified on
main:db74b169dcf2d06f1b8ca352b55ea11fb13b6f68, a queue merge with one parent. It is an ancestor oforigin/main, and theorigin/maintip is the squash itself.Delivered: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with
invalid_numberat every engine, REST, batch and import write door. ACCEPT is 5864081530, and the contract review of record is 5864066871 (PASS).Remaining: the string half. A number-typed field accepts only #20336's numeric grammar and stores the parsed number, per the census answer 5863923799. A shipped producer (objectui's CSV import legacy fallback) sends numeric strings.
Release: session
session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · cause: partial landing (PR #20370,Part of); the remainder cannot start before #20336 exports its grammar · destination:pm:blocked,Blocked-by: #20336(added to this card's body in the same act). The next taker re-claims when #20336 closes.fix(objectql)!: a number field refuses an array, a boolean or an obje…
fix(objectql)!: having resolves placeholders through the where resolv…
objectstack-fleet commentedon Sep 28, 2026
Unlock:
pm:blocked→pm:queue. #20336 closed with PR #20414, and that PR leaves the write side's string half to this card by nameTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T16:06Z. ⛔ Not a claim, ⛔ not a dispatch.priority:p2·domain:engine·area:recordsunchanged.Blocked-by: #20336; no later comment on this thread sets another. driver-sql on PostgreSQL answers 500 for a non-numeric string against a number field —where { amount: { $gt: "abc" } }isDATABASE_ERROR/ 500 over REST, while InMemoryDriver and SQLite answer 200 with no rows #20336 closedcompletedwith PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414, merged 2026-09-28T14:35Z.parseNumericString/readNumericString/NUMERIC_STRING_GRAMMAR_CASESinpackages/spec/src/data/filter-number-comparand-declared-type.ts(origin/maine956924e).Number()-finite.objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 23
Session:
session_01N8TPEsoJxPsdSdNKGnNGENAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20309-numeric-string-grammarWorktree:
objectstack-issue-20309Domain:
domain:engineSeat:
domain:engine#1File surface (the string half; the non-string half landed as PR #20370,
db74b169dc):packages/objectql/src/validation/record-validator.ts, the number-types arm as PR fix(objectql)!: enforce a progress field's declared min / max at the write seam (#20386) #20482 (record validator: aprogressfield's declaredmin/maxare never checked — REST POST stores 150 overmax: 100and −5 undermin: 0(201), where anumberfield refuses both #20386,9801da123) left it: a string is judged by the platform's one numeric grammar from@objectstack/spec/data(parseNumericString/readNumericString,filter-number-comparand-declared-type.ts, PR feat(spec): the number-comparand declared-type door's contract and the platform's numeric grammar #20414) instead ofNumber()-finite, and refused withinvalid_numberwhen the grammar does not read it (triage 5860329599 direction 2; census answer 5863923799, first arm);normalizeBlankTypedValues, and its call inpackages/objectql/src/engine.tsat the sites wherenormalizeBlankTypedValuesis called. Nothing else inengine.ts;objectql, REST and the drivers (test side only);.changeset/20309-*.md.Stop on breach and explain in the report. ⛔ No private grammar (triage 5873875735). ⛔ Not
packages/spec: the grammar's case table decides hex, padded and exponent forms, and this card does not pre-decide them. ⛔ Not thedate/datetimearm (PR #20469, in the merge queue). ⛔ NotnormalizeBlankTypedValues' blank rule or itsCOMPUTED_VALUE_TYPESexemption (ruling 5860986842 on #20308). ⛔ No driver copy.Container & model:
S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)Clause-②: no (narrowing)
Thread-read: 5873875735
Serial constraints cleared: at 2026-09-28T17:55Z, a census of all 11 open PRs' file lists finds two on this surface. PR #20469 (#20264, this seat, in the merge queue) edits
record-validator.ts' header date line, one import and thedate/datetimearm, another region. PR #20487 (#20358,domain:spec) editsengine.ts' grouped and aggregated read path, not the write-path call sites. The later lander mergesmain. The number arm's last holder, PR #20482 (#20386), landed as9801da123, and #20336 closed with PR #20414.fix(objectql)!: a number field reads a string by the spec's numeric g…
test(objectql,rest): pin the number arm's string half on the spec gra…
chore(changeset): objectql minor, BREAKING, for the number arm's stri…
objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{ "issue": 20309, "status": "done", "branch": "claude/issue-20309-numeric-string-grammar", "pr": "https://github.com/objectstack-ai/objectstack/pull/20496", "session": "session_01N8TPEsoJxPsdSdNKGnNGEN (mode:subagent under the PM loop session named in claim 5875596948)", "premise_still_valid": true, "summary": "The string half, delivered as draft PR #20496 ('Fixes #20309', 'Clause-②: no (narrowing)'). The number arm (NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES, now one predicate isJudgedNumberType) judges a string by the spec's parseNumericString instead of Number()-finite, and a new write-side rewrite normalizeNumericStringValues, beside normalizeBlankTypedValues and called at its three engine.ts sites (insert, update, validate), stores an admitted string as its number, so the validator judges and the driver stores one value; no private grammar, no spec, blank rule, date arm or driver change. H1 reproduced at base 851af0c27 (memory stored '12' as the string '12'; SQLite stored '0x10' as TEXT and read it back as 16); at head admitted strings are stored as numbers on both drivers and the 11 strings Number() read as finite but the grammar refuses (' 12 ', '12\\n', '\\t-3', '0x10', '0X1A', '0o17', '0b101', '+5', '.5', '5.', '007') answer 400 invalid_number on every door but /import, whose own cell reader coerces first. The card's assignee was not written; the PR assignee is os-warren (the card's).", "tests": "At 6bf61e75a (branch after a true merge of origin/main fc0db22bc) unless noted. objectql whole suite: 329 files, 6584 passed. rest whole suite: 218 files, 4160 passed, 34 skipped. typecheck objectql + rest: exit 0, both test layers OK (tsc --listFiles over each tsconfig.test.json includes the edited test files: objectql 2, rest 1). Downstream sweep at b78c66612 (before the merge, not re-run after it): service-automation 149 files / 1837 passed; metadata-protocol 189 files passed, 3 skipped, 2750 tests passed. Pin files: record-validator.number-value.test.ts 369, engine-number-value-door.test.ts 275, rest-data-number-value.test.ts 277, every string case driven by NUMERIC_STRING_GRAMMAR_CASES (first REST run had 1 red on my own expectation: rating 1e+21 is stored real, not integer; expectation corrected to Number.isSafeInteger, not the code). Ablation A (arm reads strings by Number() again) via scripts/ablation-replace.mjs: anchor 1 to 0, blob 4a253dd40830 to 70d16a081356, objectql rebuilt, ablation-dist-preflight marker in 4 built files; predicted 68 validator + 67 engine + 66 REST reds, measured objectql 135 of 644 and REST 66 of 277; restored blob == HEAD, git diff HEAD empty, rebuild, --absent preflight: absent from 14 built files, tree clean. Ablation B (rewrite made a no-op): blob 4a253dd40830 to d5976af6ff5e, marker in 4 built files; predicted 79 objectql and 0 REST reds (SQLite affinity stores plain numeric strings as numbers anyway), measured objectql 79 of 644 and REST 0 of 277; restored blob == HEAD, rebuild, --absent preflight clean, pins 644/644 and 277/277. So on SQLite the physical-cell pin cannot see the rewrite; the engine driver-payload pin covers memory and MongoDB. ESLint narrowed: 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 no untouched file's verdict can move; repo-wide pnpm lint is CI's.", "mcp_calls": "0 — no GitHub MCP call of any kind", "api_writes": "3 REST writes, each carried by one relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (draft pr_create, PR #20496, body read back byte-identical); POST /repos/objectstack-ai/objectstack/issues/20496/assignees (label-write --assign os-warren, read back matches); POST /repos/objectstack-ai/objectstack/issues/20309/comments (this os-dev-report). Plus git push of the branch (not REST). issue_patch not spent.", "open_questions": [], "out_of_scope_findings": [ "class: a · reach: REST POST /api/v1/data/:object/import (JSON rows, writeMode insert) measured on InMemoryDriver and SqlDriver/better-sqlite3 with the real RestServer: a decimal-comma cell on a number field is stored as a different number with ok 1, errors 0 — '3,14' stored 314, '1,5' stored 15, '1.000,5' stored 1.0005, '1,2,3' stored 123 (the plain write doors refuse each with invalid_number). Cause, read at source: packages/rest/src/import-coerce.ts parseNumberCell strips every comma (replace of /,/g) with no check that it is a thousands grouping. Not this card's surface (packages/rest/src is test-side only here). Dedupe words: import decimal comma, parseNumberCell thousands separator, '1,5' stored 15, import-coerce comma stripped, CSV import locale number", "carrier: 承接者:无 · noted, not filed: the /import cell reader and the spec grammar disagree on 8 of the 41 grammar rows, each the reader accepting what the grammar refuses (' 12 ', '12\\n', '\\t-3', '1,000', '1.000,5', '+5', '.5', '007'); the other 33 agree, including every admitted row's number. That is the reader's documented import-only tolerance (the spec module header calls it deliberately not this grammar); only the '1.000,5' misread is carried in the class a finding above." ], "gates": { "head": "6bf61e75a", "derived": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 6bf61e75a: 66 commands (change set 6 paths vs merge base fc0db22bc)", "exit_0": 64, "not_measured": [ "pnpm check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET: needs every package built; CI runs it)", "pnpm check:type-check-debt (exit 3, PREREQUISITE NOT MET: needs the full build closure; CI runs it)" ], "ran_reconciliation": "dispatch-gates --ran: 66 derived famil(ies) accounted for — 64 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN", "adr_0087": "check-adr-0087-registration --base origin/main: exit 0; 1 declared-breaking changeset, BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)", "changeset_level": "check-changeset-no-major --base origin/main --event (a pull_request payload carrying the PR body): exit 0; LEVEL AXIS clause-② no (narrowing), direction arm narrowing, BREAKING, ships minor", "empty_changeset": "check-empty-changeset --base origin/main: exit 0 (the earlier pending #20309 changeset is untouched)", "closing_keywords": "check-closing-keyword-parity --body (PR body): the only bound declaration is Fixes #20309", "ci": "not awaited: 31 check-runs on 6bf61e75a at report time, 10 success, 3 skipped, 18 in_progress" }, "line_budget": "n/a (no skills/** path in the diff)", "deviations": [ "Conflict, stated rather than resolved silently: the dispatch asks the changeset for 'a FROM → TO line'. With that label check-adr-0087-registration reads a migration prescription and refuses not-required (no-migration-prescription), the disposition PR #20370 used for this arm (measured: exit 1 at 03580e7cb, evidence from-to-label). The changeset carries the same mapping as a 'before → after' line plus the fix, the spelling the sibling value narrowing 20386-progress-min-max-enforced.md uses (exit 0 at b78c66612). Nothing authored moves, so there is no ledger row to register.", "The earlier pending .changeset/20309-number-arm-non-string-refused.md says a string is still judged by Number() and that which strings are accepted is a separate change; it is left untouched (check:empty-changeset's foreign-changeset rule refuses an edit without a confirmed deliberate correction). The new changeset names itself as that separate change. If the seat wants the old sentence amended, that is a DELIBERATE CORRECTION to confirm on the PR.", "The scratch instrument ran from packages/rest/tmp (gitignored, so bare @objectstack specifiers resolve from the rest package) and was deleted after use; copies of both scripts and all raw cell JSON are in the session scratchpad issue-20309b.", "The 2640-cell measurement ran at c67623f22 (implementation only); the head 6bf61e75a carries the same number-arm and rewrite code plus the date arm merged from main (PR #20469). The downstream sweep (service-automation, metadata-protocol) ran at b78c66612, before the merge, and was not re-run after it; objectql, rest and both typechecks were re-run at 6bf61e75a.", "Memory is measured at every REST door 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 #20370 met).", "objectui census row re-read at the local sibling checkout b8e09415c9 (not fetched; not verified equal to objectui main). It confirms the seat's census (5863923799) and adds two facts used in the changeset: the legacy fallback's client check covers number/currency/percent only, and its parsers trim every cell, so padded forms cannot come from it." ], "files_changed": [ "packages/objectql/src/validation/record-validator.ts (+108 -10: isJudgedNumberType, normalizeNumericStringValues, the arm reads parseNumericString, header line)", "packages/objectql/src/engine.ts (+15 -5: import, and the rewrite call after normalizeBlankTypedValues at insert, update and validate)", "packages/objectql/src/validation/record-validator.number-value.test.ts (+224 -16: the flipped Number() characterization removed; the string half on the grammar table, the rewrite, bounds/scale/precision parity)", "packages/objectql/src/engine-number-value-door.test.ts (+86 -2: admitted strings reach the driver as the number on 5 doors, refused never reach it, hook and dry run)", "packages/rest/src/rest-data-number-value.test.ts (+82 -3: SQLite physical cell and storage class for admitted strings; refusal on POST, batch, PATCH, updateMany)", ".changeset/20309-number-arm-numeric-string-grammar.md (new: objectql minor, BREAKING, Clause-② no (narrowing), ADR-0087 not-required (no-migration-prescription))" ], "rows": { "method": "scratch script booting ObjectQL + ObjectStackProtocolImplementation + RestServer from built dist, InMemoryDriver and SqlDriver (better-sqlite3, in memory); 20 inputs x 6 judged types x 11 doors (engine insert, insertMany, update by id, update by predicate; REST POST, createMany, batch create, PATCH, batch update, updateMany, /import) x 2 drivers = 2640 cells per tree; base 851af0c27, head c67623f22", "moved": "1200 of 2640, 60 per moved input (6 types x the 10 non-/import doors); /import moved 0 of 240", "admitted_strings": "'12', '12.5', '-3', '-0', '0.10', '1e3': memory base stored the string, head stores the number; SQLite stored a number by affinity at base and head, byte-identical cells", "newly_refused": "'0x10' (SQLite base TEXT '0x10' read back 16), ' 12 ', '12\\n', '+5', '.5', '5.', '007': accepted at base on both drivers, invalid_number at head, nothing written, existing cells unchanged", "unchanged": "'1,000', 'Infinity', 'NaN', '1e400', 'abc' refused at base and head; '' null at base and head (#20308); the number 12 stored as 12" } }Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
ACCEPT — PR #20496 at
6bf61e75aaf6786b56a73ae7184423aa9dce1860(the string half; this completes the card)domain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T19:20Z. Contract review of record: 5876843857 on PR #20496, at-tier, read-only, PASS on this head.Checklist, verified against GitHub rather than the reports:
main, first lineFixes #20309. That is the only closing keyword in the body. This card's claim 5875596948 names the branch and readsClause-②: no (narrowing), the same line as the changeset and the PR body.record-validator.ts: the number arm (now one predicate,isJudgedNumberType) reads a string by@objectstack/spec/data'sparseNumericStringalone, with no private grammar. A new write-side rewrite,normalizeNumericStringValues, sits besidenormalizeBlankTypedValues;engine.ts: the import, and the rewrite call right after the blank rule at its three sites (validate,insert,update). Nothing else;objectql(the arm on every grammar row, the rewrite, bounds / scale / precision parity) and inrest(SQLite's physical cell and storage class; refusal on POST, batch, PATCH, updateMany);Every file is inside the claim.
@objectstack/objectqlminor, BREAKING,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription). It carries a "before → after" line, as PR fix(objectql)!: enforce a progress field's declared min / max at the write seam (#20386) #20482 (record validator: aprogressfield's declaredmin/maxare never checked — REST POST stores 150 overmax: 100and −5 undermin: 0(201), where anumberfield refuses both #20386) did (the dispatch's FROM → TO label trips the gate's migration-prescription branch, and nothing authorable moves).20309-number-arm-non-string-refused.md, PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370, same release): the review judged it coherent, so no DELIBERATE CORRECTION is owed. Its "Which strings a number field accepts is a separate change" is the pointer this note answers by name.check-governed-merges --pr 20496: not governed). 643 changed lines, under the human-merge threshold.success(Check Changeset,Lint & Repo Gatesand Test Core 1–6/6 among them), 3 rostered skips, none red.mergeable_state: clean.'12','12.5','-3','1e3') is stored as its number on every driver. Memory used to keep the string. The eleven stringsNumber()read as finite but the grammar refuses (radix literals, padded forms,'+5','.5','5.','007') answerVALIDATION_FAILED/invalid_numberon every write door, with nothing written. A JS number, a blank andsummaryare unchanged.Carried out of this card:
3,14→ 314,1,5→ 15,1.000,5→ 1.0005), becauseparseNumberCellstrips every comma as if it grouped thousands #20497 (filed bare for triage):/import'sparseNumberCellstrips every comma, so a decimal-comma cell is stored as a different number with ok 1./importreader keeps its documented import-only tolerance on 8 of the grammar's 41 rows;before*hook writes after the door is judged but not rewritten (hook-owned, as with the blank rule);invalid_number, with the same prescription;Landing:
readyplus auto-merge through the queue. The merge closes this card (Fixes), and the seat verifies it onmainand removespm:dispatchedin the same act.objectstack-fleet commentedon Sep 28, 2026
Landing record: PR #20496 merged (the string half). This card is closed
completedby itsFixeslinedomain:engine#1·session_01N8TPEsoJxPsdSdNKGnNGEN(os-warren) · written 2026-09-28T19:45Z.Verified on
main:2b24b8b82304e925110800efb0e092845a931d16, a queue merge with one parent. It is an ancestor oforigin/main, and theorigin/maintip is the squash itself.6bf61e75a's changes against its merge basefc0db22bc(the same md5 over every added and removed line).normalizeNumericStringValuesis present inrecord-validator.tsat the squash (3 hits) and absent at its parent. The20309-number-arm-numeric-string-grammarchangeset is present at the squash and absent at its parent.Fixes #20309, so no other card was closed.Delivered, with the non-string half (PR #20370,
db74b169dc): anumber,currency,percent,rating,sliderorprogressfield accepts a JS number, or a string only under the platform's numeric grammar (@objectstack/spec/data'sparseNumericString, PR #20414).+5,.5,5.,007) answersVALIDATION_FAILED/invalid_numberon every write door, with nothing written.@objectstack/objectqlshipsminorBREAKING,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription).Carried out of this card:
3,14→ 314,1,5→ 15,1.000,5→ 1.0005), becauseparseNumberCellstrips every comma as if it grouped thousands #20497 (bare, for triage):/import's decimal-comma misread./importreader's documented tolerance on 8 grammar rows; hook-written strings judged but not rewritten; the import wizard's untrimmed hand-correction box.pm:dispatchedis removed in the same act as this record. The domain, area and type labels stay.feat(spec): the number-comparand declared-type door's contract and th…
fix(objectql)!: a number field reads a string by the spec's numeric g…
test(objectql): two fixtures re-judged under the readonly shape check