Skip to content

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

Closed

Description

@objectstack-fleet

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] $ne with 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 on driver-sql (better-sqlite3), POST /api/v1/data/crm_activity:
    • duration_minutes: [500] → 201. It is stored as the SQLite TEXT '[500]', and a GET returns the string "[500]";
    • duration_minutes: [5, 7] → 400 VALIDATION_FAILED (the control that fires);
    • duration_minutes: 500 → stored as a real (the scalar control).
  • Cause, read by this seat on main 6a6a17b6: packages/objectql/src/validation/record-validator.ts:763, the number-types arm (number, currency, percent, rating, slider):
    const n = typeof value === 'number' ? value : Number(value);
    if (!Number.isFinite(n)) {
      return fail('invalid_number');
    }
    JS Number([500]) is 500, so a one-element array passes the check. The validator judges the coerced n but the write carries the original value, so the array reaches the driver. By the same coercion, and not measured at the door: Number([]) → 0, Number(true) → 1, Number('0x10') → 16 and Number(' 12 ') → 12 also pass. Whatever the driver then stores, [], true and '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)

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

Activity

  1. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  2. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 23 (takeover of claim 5861525948)
    Session: session_01N8TPEsoJxPsdSdNKGnNGEN
    Account: os-warren (the seat's linked user as GET /user answers 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: unchanged from claim 5861525948 (packages/objectql/src/validation/record-validator.ts number arm; tests in objectql and rest; .changeset/20309-*.md). Draft PR #20370 stays inside it: 5 files, record-validator.ts plus 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-report 5862810526). 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) edits engine.ts aggregate, not the write path. #20264 (the date write 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 engine in session_01N8TPEsoJxPsdSdNKGnNGEN.
    Handoff: claude/issue-20309-number-arm-coercion was last pushed at c135a38d3138f4b64ec6d2c28c302dec8b4f315b. 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.

  3. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    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/objectui at origin/main 6a7f24e92c. 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.

    producer (objectui) what it sends for a number-typed field line
    form NumberField (number) Number(val), or null when cleared fields/src/widgets/NumberField.tsx: onChange(val === '' ? (null as any) : Number(val))
    form CurrencyField (currency) parseFloat(…) rounded, or null fields/src/widgets/CurrencyField.tsx onChange(parseFloat(val.toFixed(fractionDigits)))
    form PercentField (percent) parseFloat then scaled, or null fields/src/widgets/PercentField.tsx handleChange
    form RatingField (rating) i + 1, a number fields/src/widgets/RatingField.tsx
    form SliderField (slider, and progress maps to it in FieldEditWidget.tsx) values[0], a number fields/src/widgets/SliderField.tsx
    grid inline edit (ObjectGrid) the same field widgets: renderCellEditor returns the FieldEditWidget for every type hasFieldEditWidget knows, and all six numeric types are in EDIT_WIDGETS plugin-grid/src/ObjectGrid.tsx renderCellEditor
    DataTable's built-in numeric cell editor a string (setEditValue(e.target.value)), but only when no injected editor answers. ObjectGrid injects one for every object field, so this is reached only by a table with no object field behind the column components/src/renderers/complex/data-table.tsx NUMERIC_EDIT_TYPES branch
    kanban quick add Number(…), or "" when blank (which #20308's normalisation turns into null) plugin-kanban/src/InlineQuickAdd.tsx
    CSV import, legacy per-row fallback the raw CSV string (record[col.field.name] = raw), after a client check !isNaN(Number(value)), via dataSource.create plugin-grid/src/ImportWizard.tsx validateRow / legacyImport
    CSV import, server route (the default path) the raw strings go to the server /import route, and import-coerce.ts parseNumberCell coerces them there (the dev's census row) plugin-grid/src/ImportWizard.tsx assembleImportRequest

    Reading. 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 /import route (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:

    Effect on PR #20370: none. It is Part of #20309 and carries the non-string half only. It proceeds to review as is. When it lands, this card goes to pm:blocked with Blocked-by: #20336 for the string half. It does not go back to pm:queue, because the string half cannot start before the grammar exists.

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    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:

    • Form: draft, base main, first line Part of #20309. The body carries no closing keyword next to any card number.
    • Scope: 5 files, all inside the claim's surface. record-validator.ts gains the three-line guard plus comments. Beside it are two objectql test files, one rest test file, and one changeset.
    • Changeset: @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-required. rest changes are test-only.
    • Governed surface: none (check-governed-merges --pr 20370: 0 of 5 paths). Size +538 / −0.
    • CI: 34 check-runs on the head: 31 success, 3 skipped (the rostered expected skips), 0 red. Lint & Repo Gates and TypeScript Type Check are both success. mergeable_state: clean.

    Deviations: accepted as the review answered them. Memory is pinned at the engine door and measured at REST; the driver-memory census 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:

    After merge: this card goes to pm:blocked with Blocked-by: #20336 for the string half, which accepts #20336's numeric grammar and stores the parsed number. It keeps its assignee until that half is re-dispatched.

  5. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #20370 merged (the non-string half). The card stays open, pm:blocked on #20336 for the string half

    domain:engine#1 · session_01N8TPEsoJxPsdSdNKGnNGEN (os-warren) · written 2026-09-28T05:55Z.

    Verified on main:

    • The squash is db74b169dcf2d06f1b8ca352b55ea11fb13b6f68, a queue merge with one parent. It is an ancestor of origin/main, and the origin/main tip is the squash itself.
    • It carries 5 files, +538 / −0.
    • The number arm's non-string guard is present at the squash (1 hit) and absent at its parent (0 hits).
    • The PR body carried no closing keyword, so this card stayed open as intended.

    Delivered: a number, currency, percent, rating, slider or progress field refuses an array, a boolean or an object with invalid_number at 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.

  6. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue. #20336 closed with PR #20414, and that PR leaves the write side's string half to this card by name

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T16:06Z. ⛔ Not a claim, ⛔ not a dispatch. priority:p2 · domain:engine · area:records unchanged.

  7. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 23
    Session: session_01N8TPEsoJxPsdSdNKGnNGEN
    Account: os-warren (the seat's linked user as GET /user answers 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):

    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 the date / datetime arm (PR #20469, in the merge queue). ⛔ Not normalizeBlankTypedValues' blank rule or its COMPUTED_VALUE_TYPES exemption (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 the date / datetime arm, another region. PR #20487 (#20358, domain:spec) edits engine.ts' grouped and aggregated read path, not the write-path call sites. The later lander merges main. The number arm's last holder, PR #20482 (#20386), landed as 9801da123, and #20336 closed with PR #20414.

  8. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    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

  9. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    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:

    • Form: draft, base main, first line Fixes #20309. That is the only closing keyword in the body. This card's claim 5875596948 names the branch and reads Clause-②: 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's parseNumericString alone, with no private grammar. A new write-side rewrite, normalizeNumericStringValues, sits beside normalizeBlankTypedValues;
      • 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 in rest (SQLite's physical cell and storage class; refusal on POST, batch, PATCH, updateMany);
      • one changeset.
        Every file is inside the claim.
    • Changeset: @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-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: a progress field's declared min / max are never checked — REST POST stores 150 over max: 100 and −5 under min: 0 (201), where a number field 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 Gates and 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 strings Number() read as finite but the grammar refuses (radix literals, padded forms, '+5', '.5', '5.', '007') answer VALIDATION_FAILED / invalid_number on every write door, with nothing written. A JS number, a blank and summary are unchanged.

    Carried out of this card:

    Landing: ready plus auto-merge through the queue. The merge closes this card (Fixes), and the seat verifies it on main and removes pm:dispatched in the same act.

  10. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landing record: PR #20496 merged (the string half). This card is closed completed by its Fixes line

    domain: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 of origin/main, and the origin/main tip 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 base fc0db22bc (the same md5 over every added and removed line).
    • normalizeNumericStringValues is present in record-validator.ts at the squash (3 hits) and absent at its parent. The 20309-number-arm-numeric-string-grammar changeset 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): a number, currency, percent, rating, slider or progress field accepts a JS number, or a string only under the platform's numeric grammar (@objectstack/spec/data's parseNumericString, 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) answers VALIDATION_FAILED / invalid_number on every write door, with nothing written.
    • @objectstack/objectql ships minor BREAKING, Clause-②: no (narrowing), ADR-0087 not-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:

    pm:dispatched is removed in the same act as this record. The domain, area and type labels stay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions