Skip to content

fix(objectql)!: a number field reads a string by the spec's numeric grammar and stores its number (#20309) - #20496

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20309-numeric-string-grammar
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20309-numeric-string-grammar

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #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 #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 fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #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 fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #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 fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #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 fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 met).

Generated by Claude Code

…rammar and stores its number (#20309)

The record validator's number arm judges a string with parseNumericString
(@objectstack/spec/data) instead of Number()-finite, and a new write-side
rewrite, normalizeNumericStringValues, stores an admitted string as the number
it denotes at the three points normalizeBlankTypedValues runs (insert, update,
validate).

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…mmar's case table (#20309)

Validator, engine door (stub driver payload, hooks, dry run) and REST on
SQLite (physical cell and storage class), each driven by
NUMERIC_STRING_GRAMMAR_CASES: admitted strings are judged and stored as their
number, refused ones answer invalid_number and write nothing.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…s the sibling value narrowings do

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 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 fc0db22bcfdbdb778945317fc4de6dc46aab966d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json fc0db22bcfdbdb778945317fc4de6dc46aab966d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6bf61e75aaf6786b56a73ae7184423aa9dce1860
Local-runs: none

Inputs read: card #20309 (body and all 11 comments, 5860329599 through 5876625514), PR #20496 (body, 6-file list, the one bot comment), the net diff against the merge base fc0db22bc (git diff, 607/36), the spec module filter-number-comparand-declared-type.ts at origin/main, the two pending notes 20309-number-arm-non-string-refused.md and 20386-progress-min-max-enforced.md at origin/main, PR #20414 and #20370 bodies, #20336's thread, import-coerce.ts and import-runner.ts at the head, metadata-protocol/src/protocol.ts's data doors at the head, objectui plugin-grid/src/ImportWizard.tsx and importParsers.ts at the local checkout b8e09415c9, check-adr-0087-registration.mjs and check-empty-changeset.mjs at origin/main, the squash 3062e5001 (#20264) for the deliberate-correction precedent, and the check-runs on the head (first read 19:08Z, final read below). Read-only git and REST GETs only; nothing built, run or posted. origin/main has moved one commit past the merge base (45f428d8f, #20477's runtime dispatcher, not this surface).

① Derived judgments

  1. The number arm judges a string by parseNumericString alone — RIGHT. At the head the arm is if (isJudgedNumberType(t)) → non-number/non-string refused → const n = typeof value === 'number' ? value : parseNumericString(value) → n === undefined || !Number.isFinite(n) refused. No regex, no Number() on a string and no trim anywhere in the diff; between validateOne's entry and the arm the only string handling is String(value) in the bounded-string branch, a different type. parseNumericString answers a number only when readNumericString is numeric: true (whole-string JSON-number pattern, finite), so the arm's string verdict is the spec's verdict for every input, the 41 table rows included; the isFinite re-check is inert for a string and is the number path's own check. Pinned: every table row straight to the arm and through the rewrite, on all six judged types, plus 12 off-table probes asserting acceptance iff parseNumericString reads (record-validator.number-value.test.ts). isJudgedNumberType is the arm's previous inline predicate (NUMERIC_VALUE_TYPES minus COMPUTED_VALUE_TYPES), now shared with the rewrite; summary stays unjudged and unrewritten.

  2. normalizeNumericStringValues placement and reach — RIGHT, with one declared residual. The function rewrites a value only when it is a string, the key is not a SKIP_FIELDS name, an own-property def exists, the def is not system/readonly, isJudgedNumberType(def.type), and parseNumericString reads it — a subset of what validateRecord walks on both insert and update. Pure, copy-on-write (pinned, including the valueOf/constructor own-property case). It is called at exactly the three normalizeBlankTypedValues sites in engine.ts (validate() 11747, insert() 11925, update() 12973), each immediately after the blank rule; the two rules commute on these inputs (a blank reads no number; a numeric string is not blank) and the pinned test asserts the rewrite leaves a blank for the blank rule. Doors, traced at the head: REST POST → createData → engine.insert; createMany → createManyData → engine.insert(records); batch create/update/upsert → batchData → engine.insert / engine.update; PATCH → updateData → engine.update; updateMany → updateManyData → engine.update per row; engine.insertMany → this.insert(rows, __partialRowErrors); update by predicate is update() itself (the site sits above the by-id/predicate split). The site is the first statement on the payload in insert()/update(), before middleware, the suppliedValues snapshot, the hooks, the readonly strips and validateRecord, so hooks see the number (pinned: beforeInsert/beforeUpdate see 12.5/-3). So for a caller-sent value the n the validator judges is the number the driver receives. /import: coerceFieldValue reads every typed numeric cell through parseNumberCell (NUMBER_TYPES is NUMERIC_VALUE_TYPES by alias, all six judged types), so what reaches the arm from /import on a judged field is a JS number or the row's own invalid_number refusal — never a string; only a cell with no field metadata passes raw, and such a key has no def for the arm either. Residual, declared by the dev: a string a before* hook writes after the door is judged by the grammar but not rewritten (hook-owned value; the same shape the blank rule has).

  3. Refused and admitted sets against the census — RIGHT. I re-derived the narrowing from the table: the non-empty refused rows whose Number() is finite are exactly ' 12 ', '12\n', '\t-3', '0x10', '0X1A', '0o17', '0b101', '+5', '.5', '5.', '007' (every other refused row is NaN or ±Infinity under Number()); the NAMED NARROWING test pins that literal against the table. Admitted: the 10 admitted rows, stored as their number (-0 stays -0 on the driver payload; SQLite stores 0, pinned). Census, re-read at objectui b8e09415c9: the legacy fallback's validateValue applies !isNaN(Number(value)) to number/currency/percent only and answers true for every other type; parseDelimited, the xlsx reader and the HTML-paste reader each .trim() every cell; validateRow posts the raw cell to dataSource.create. So this shipped producer can send, and is now refused on, the radix literals and '+5'/'.5'/'5.'/'007' (admitted spellings like '12'/'1e3' still land, now as numbers). It is named in the changeset with the prescription (the wizard's default server /import route, whose reader coerces first). Every interactive widget sends a number or null (census 5863923799, consistent with the same checkout). One precision note under ③: the wizard's fix-up grid hands a hand-typed correction through onCorrect(rIdx, csvIdx, e.target.value) untrimmed, so a padded form can reach the legacy fallback through that box; the same per-row invalid_number and the same prescription cover it.

  4. scale / precision / min / max and rating for a number-typed value — unchanged, RIGHT. Below the finite check the arm is byte-identical to the merge base (the diff touches the predicate line and the two n lines only); for a JS number n = value as before. There is no integer check on rating at base or head — the only Number.isInteger reads in the arm are on the declared scale and precision; a rating's decimals are bounded by scale alone, which the PR body states. A string now gets exactly the answer its number gets, pinned over 14 cases with byte-equal error objects (toEqual on the whole field error).

  5. The removed characterization test — RIGHT. UNCHANGED here: a string is still judged by Number()… pinned '12', '12.5', '0x10', ' 12 ', '1e3' as accepted and said of itself that it turns red when the string half lands. It pinned the old boundary; all five inputs are rows of the grammar table and are re-pinned with their new verdicts (three admitted with their number on every door, two refused and also in the NAMED NARROWING literal).

  6. Public surface — none changes. @objectstack/objectql's root and ./core entries publish only ValidationError, validateRecord, VALIDATION_FAILED_CODE and types from the validator; normalizeNumericStringValues is module-internal exactly as normalizeBlankTypedValues is. No spec, driver or REST source change; the packages/rest file is one test.

Sentence walk (changeset and PR body). Every code-fact sentence is TRUE against the head: the arm reads parseNumericString; the rewrite's contract, purity and three sites; hooks see the number; the caller's object is not mutated; bounds/scale/precision read the parsed number; every REST, batch, updateMany door and validate answer it; /import is unchanged and its reader coerces first; a blank is still null; summary is still unjudged; non-strings answered as before; the 11-string narrowing; the 8-row reader/grammar disagreement (re-derived from parseNumberCell: trim, comma strip, [+-]?\d*\.?\d+ read ' 12 ', '12\n', '\t-3', '1,000', '1.000,5', '+5', '.5', '007'; '5.', '1_000', '1 000', radix, non-finite and placeholders refused by both; every admitted row reads to the same number); the decimal-comma misread (s.replace(/,/g, ''), so '3,14' reads 314); the legacy-fallback census facts; String(n) always qualifies (the spec pins the round-trip); "Rows already stored"; the ADR-0087 marker's facts. The base-state sentences (memory stored '12' as a string; SQLite stored '0x10' as TEXT read back 16, the others as numbers by affinity) are TRUE by PR #20370's independently measured table on the same strings. Measurement and suite sentences (2640 cells, 1200 moved, suite counts, ablations A and B, the 66-command sweep) are the dev's, not re-run here; their arithmetic is self-consistent (20 × 6 × 11 × 2; 1200 = 20 input-by-driver pairs × 60, admitted strings moving on memory only and refused ones on both drivers), nothing read contradicts them, and the check-runs are the gate verdicts. Two wording notes, neither false: the changeset's "the one numeric grammar the filter door also reads" names the spec's read-side contract, whose engine door (#20351) is not yet landed; and "Its parser trims cells" is true of the parsers while the hand-correction box is untrimmed (③).

Check-runs on 6bf61e75a, final read 2026-09-28T19:18Z (the last step): 34 check-runs — 30 success, 3 skipped, 1 in_progress, 0 failures. Success includes Check Changeset, Lint & Repo Gates, TypeScript Type Check, Type Check · source gates / consumer gates / debt ledger / workspace, Build Core, Test Core (1/6) through (6/6), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Part-of PR must not also close its card, The card this PR closes must claim this branch, both single-writer guards, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) (the rostered expected skips). In progress: the Test Core rollup job, whose six shards are all success. (First read at 19:08Z had 15 success / 3 skipped / 13 in progress; nothing turned red between the two reads.)

② Semver level

③ Boundary flags

Dev deviations, each answered:

  1. FROM → TO label vs before → after — answered in ② (accept).
  2. Earlier pending note left untouched, amendment offered — answered in ② (coherent; optional seat-confirmed amendment).
  3. Scratch instrument in gitignored packages/rest/tmp, deleted — the diff carries six files and no tmp path (accept).
  4. Cells measured at c67623f22, head 6bf61e75a merges origin/main — the date arm (PR fix(core,objectql)!: a date or datetime names a year from 0001 to 9999, refused at the comparand door and the write door (#20264) #20469, 3062e5001) is in the merge base, so the net diff against fc0db22bc is this PR's six files only; objectql, rest and both typechecks re-run at the head per the report, and the head's check-runs are the verdict (accept).
  5. Memory measured at every REST door, pinned at the engine payload, no new REST consumer — the check:driver-memory-census constraint PR fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 met; the engine pin asserts Object.is on the driver payload (accept).
  6. objectui re-read at local b8e09415c9, not verified against objectui main — my read is at the same checkout (no fetch, read-only); the two facts used hold there; the seat's own census at objectui 6a7f24e92c reported the files byte-identical to the local checkout (accept, same caveat).

open_questions: none declared; none found.

Out-of-scope findings, dispositions:

  • Decimal-comma cell misread at /import (class a, measured by the dev on both drivers): confirmed at source — parseNumberCell strips every comma before the read, so '3,14' reads 314 and '1.000,5' reads 1.0005 with no grouping check. Not this PR's surface. Carrier: the seat files it (as it said it would).
  • Reader/grammar disagreement on 8 of 41 rows: confirmed at source (above); the spec module header calls the import tolerance deliberately not this grammar. Carrier: none, as noted.

Reviewer notes (not blocking):

  • The import wizard's hand-correction box is untrimmed, so a padded form can reach the legacy fallback through it; the refusal is per-row with the field named, and the prescription (server /import) covers it. The seat may add one clause to the changeset's "Who sends" paragraph or leave it. Carrier: seat's choice.
  • Hook-written strings after the door: judged by the grammar, not rewritten; declared in the PR body and the function's docblock. Carrier: none (hook-owned values; the blank rule has the same shape).

Implemented-by: claude/issue-20309-numeric-string-grammar
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 19:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 2b24b8b Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20309-numeric-string-grammar branch September 28, 2026 19:44
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

2 participants