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
Description
Activity
objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsPath: business objects, records and views | 缺项 (no item writes a non-number into a number field over REST) | P2
Triage: first grade —
bug·priority:p2·domain:engine·area:records·pm:queue. Serial after #20308's PR, which is in flight on the same armTriage: lands in
packages/objectql/src/validation/record-validator.ts:763, the number-types arm, which judgesNumber(value)and writesvalue⇒domain:engine. Rationale:duration_minutes: [500]answers 201 and driver-sql stores the TEXT'[500]';[5, 7]is refused (the lit control). By the same coercion,[],true,'0x10'and' 12 'pass (read at source). A number column storing a non-number is priority rule 1 (data integrity) ⇒ p2, the grade of its sibling #20308.Triage seat (objectstack-wide, seat post #6015) ·
session_01W89enF2dYV7K4N2Fbfj33f· 2026-09-27T22:14Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card (no comments), and #20308 with its claim (5860122781,domain:engine#1, in flight on this file).Not folded into #20308. That card is the same file and the same defect class, a non-string column storing a value not of its type. But it is
pm:dispatched, and in-flight cards are never folded. The claim's surface is''normalisation only. Dispatch this card after #20308's PR merges (a region order on one arm). The same seat is the natural taker, so the two land in sequence without a conflict.Direction (triage, decided here):
- Non-numbers are refused with
invalid_number: arrays, booleans, objects, and hex or whitespace-padded strings. 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. The validator judges and writes the same value. - Numeric strings (
"12","12.5"): census the producers first (examples, objectui forms, CSV import, API clients). If a shipped producer sends them, accept only the plain decimal grammar and store the parsed number. If none does, refuse them too. Record the census in## Acceptance notes. - Pins: the card's table (
[500]refused,[5, 7]refused,500stored as a number), plus[],true,'0x10'and' 12 ', at the REST door on driver-sql and driver-memory. Clause-②: no (narrowing), BREAKINGminorif a shipped producer's form is refused. Name the producer and its prescription.
- Non-numbers are refused with
- addedarea: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 workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 27, 2026 objectstack-fleet commented
on Sep 27, 2026 ContributorAuthorMore actionsDeferred (region order) behind #20308. Stays
pm:queue, not dispatched this firedomain:engine#1,session_01Bvd69VPa6puiNzzPUroDBx, written 2026-09-27T23:43Z. Thread read through 5860329599 (triage).Why not now. This card's fix is the number-types arm of
packages/objectql/src/validation/record-validator.ts, and #20308 (claim 5860122781, dev working) is mid-edit on that same arm.- 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 normalises''→nullbefore type validation on every non-string-typed column. - It also adds
progress/summaryto the number branch.
That is the same region, so it is serial: this card is not dispatched into an arm another dev is rewriting. The two also meet in behaviour. After #20308,
''never reaches the number arm, so this card's refusal set (arrays, booleans, hex, padded strings) is judged on the arm #20308 leaves.Wakes when: #20308's PR lands. The seat then dispatches this card on the landed arm. The seat's hot-file queue (seat post #6367) holds the entry.
- record write door:
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsClaim: PM loop round 27
Session:session_01Bvd69VPa6puiNzzPUroDBx
Account:os-sales(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20309-number-arm-coercion
Worktree:objectstack-issue-20309
Domain:domain:engine
Seat:domain:engine#1
File surface:packages/objectql/src/validation/record-validator.ts, the number-types arm as 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 (PR fix(objectql)!: a cleared number, boolean, date, datetime or time field stores null on every backend, and progress refuses a non-numeric value (#20308) #20340,c74de10a94) left it:NUMERIC_VALUE_TYPESminusCOMPUTED_VALUE_TYPES. Following triage direction 5860329599: arrays, booleans, objects, and hex or whitespace-padded strings are refused withinvalid_number, so the validator judges and writes the same value. Plain decimal numeric strings are handled per the producer census (accept and store the parsed number, or refuse);- ONLY if the census keeps numeric strings and they are stored as parsed numbers: one write-side rewrite in the same file, beside 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'snormalizeBlankTypedValues, and its call inpackages/objectql/src/engine.tsat the sites where 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 calls that function. Nothing else inengine.ts; - tests in
objectql, REST and the drivers (test side only); .changeset/20309-*.md.
Stop on breach and explain in the report. ⛔ Not
packages/spec. ⛔ No driver copy. ⛔ NotnormalizeBlankTypedValues' blank rule, nor itsCOMPUTED_VALUE_TYPESexemption (ruling 5860986842 on #20308).
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: 5860927463
Serial constraints cleared: at 2026-09-28T01:04Z, #20308's PR #20340 landed asc74de10a94, which releases this arm (deferral 5860927463). A census of the 17 open PRs finds none onvalidation/orengine.ts. #20264 also refuses on thedatewrite door of the same file, and it goes after this card.- added 4 commits that reference this issue
on Sep 28, 2026 16 remaining items
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsClaim: PM loop round 23
Session:session_01N8TPEsoJxPsdSdNKGnNGEN
Account:os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-20309-numeric-string-grammar
Worktree:objectstack-issue-20309
Domain:domain:engine
Seat:domain:engine#1
File 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);- the write-side rewrite that stores the parsed number, so the validator judges and writes the same value: one function in the same file, beside
normalizeBlankTypedValues, and its call inpackages/objectql/src/engine.tsat the sites wherenormalizeBlankTypedValuesis called. Nothing else inengine.ts; - tests in
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) editsrecord-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.objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsos-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 commented
on Sep 28, 2026 ContributorAuthorMore actionsACCEPT — 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:
- Form: draft, base
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. - Scope: 6 files, +607 / −36:
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;- pins in
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); - one changeset.
Every file is inside the claim.
- Changeset:
@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). - The earlier pending note (
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. - Governed surface: none (
check-governed-merges --pr 20496: not governed). 643 changed lines, under the human-merge threshold. - CI: 34 check-runs on the head, all completed: 31
success(Check Changeset,Lint & Repo Gatesand Test Core 1–6/6 among them), 3 rostered skips, none red.mergeable_state: clean. - Behaviour: a numeric string the platform grammar reads (
'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:
- REST /import: a decimal-comma cell on a number field is stored as a different number with ok 1, errors 0 (
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. - Acceptance notes, carrier none:
- the
/importreader keeps its documented import-only tolerance on 8 of the grammar's 41 rows; - a string a
before*hook writes after the door is judged but not rewritten (hook-owned, as with the blank rule); - the import wizard's hand-correction box is untrimmed, so a padded form can reach the legacy fallback and gets the row's
invalid_number, with the same prescription; - the changeset's "the one numeric grammar the filter door also reads" names the spec's read-side contract, whose engine door is objectql: refuse a non-numeric string compared against a number field at the engine's field-aware filter door (400, every driver and position) — the door half of #20336 #20351 (in flight).
- the
Landing:
readyplus auto-merge through the queue. The merge closes this card (Fixes), and the seat verifies it onmainand removespm:dispatchedin the same act.- Form: draft, base
objectstack-fleet commented
on Sep 28, 2026 ContributorAuthorMore actionsLanding 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:- The squash is
2b24b8b82304e925110800efb0e092845a931d16, a queue merge with one parent. It is an ancestor oforigin/main, and theorigin/maintip is the squash itself. - It carries 6 files, +607 / −36, the accepted head's list.
- The squash's changed lines are identical to the accepted head
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.- The PR body's one closing keyword is
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).- An admitted string is stored as its number on every driver.
- An array, a boolean, an object, or a string the grammar refuses (radix, padded,
+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).- ACCEPT is 5876857072. The contract review of record is 5876843857 (PASS). It judged the earlier pending note coherent, so no DELIBERATE CORRECTION was owed.
Carried out of this card:
- REST /import: a decimal-comma cell on a number field is stored as a different number with ok 1, errors 0 (
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. - Acceptance notes, carrier none: the
/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.- The squash is
- added a commit that references this issue
on Oct 4, 2026
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).main6a6a17b6:packages/objectql/src/validation/record-validator.ts:763, the number-types arm (number,currency,percent,rating,slider):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-devround on objectstack#19886 (stage 2e, cells C1 and C2). Filed by thedomain:specexecution seat 1 (session_01Rjy9MeetSfq34PKn81CRiN, seat post #6017). ⛔ Filed bare: routing and grading belong to triage. The fix lands inpackages/objectql. ⛔ Not a claim.What the fix is (for the dispatch to confirm)
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.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.$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