Repository navigation
fix(objectql)!: a number or boolean written to a text-family field is stored as the string the validator judged (#29241) - #29384
Conversation
…e string the validator judged
The string arm judges a number or a boolean by String(value) while the
write carried the raw scalar, so driver-memory stored 42 and SQLite stored
the TEXT '42.0' ('1.0' for true). normalizeNumericStringValues, the #20309
door seam, now writes the judged string into the payload for the
BOUNDED_STRING_FIELD_TYPES fields the validator walks.
Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF
Co-authored-by: Claude <noreply@anthropic.com>
The refusal case hands the hook a raw number through the `say` field. A text-family field now stores, and hands its hooks, the string the validator judges a number by, so the vehicle moves to `json`, which keeps the value as sent. Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 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
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 75d1344fa04e5e3c682de5dfb38103831d021080 && git checkout 75d1344fa04e5e3c682de5dfb38103831d021080
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba 288190cde77357eee227b6f17e121511a732fc3e && git checkout -B drift-repro 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba && git merge --no-ff 288190cde77357eee227b6f17e121511a732fc3e
node scripts/docs-audit/affected-docs.mjs --json 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 34c3019 · 2026-11-02T13:46Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Read at 2026-11-02T14:04Z from the net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Must-fix before a re-review:
The code change itself (①1–4) is judged right and needs no edit. |
A unique text-family field compares the stored string now: 42 then "42" is refused 409 UNIQUE_VIOLATION where base stored both, and on SQLite true then 1 lands where base stored both as '1.0'. Pinned on the SQLite REST file (both directions) and at the engine door (one value reaches the driver). The changeset declares Clause-② yes (narrowing) with its BREAKING banner and the ADR-0087 not-required marker. Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 926fa80 · 2026-11-02T15:11Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Round 2, on the narrowing face. Supersedes record 6579147685 (TIER_DEFAULT FAIL on head ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on The filter measurement the dev reported (two rows, 42 and "42"; base
Findings. must-fix: none. should-fix: (1) the two in-tree sentences in ①6; (2) the changeset sentence above; (3) the follow-up cards in ③ (filter-door text-family comparand arm; Implemented-by: VERDICT: PASS Generated by Claude Code |
…ilter answers The docblock and the test header said nothing is refused or newly admitted; the unique-key answer moves (Clause-② yes, narrowing). The changeset now names the filter consequence: a number comparand on a text-family field no longer matches a row written as that number. Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Regen-provenance: contract review 6580906146 (PASS, head 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #29241, title fix(objectql)!. 5 files: the #20309 seam in record-validator.ts, the objectql and rest tests, and a changeset. The changeset is @objectstack/objectql patch, Clause-②: yes (narrowing), with a BREAKING banner (FROM → TO), one ADR-0087 marker not-required (no-migration-prescription) and Skills-docs: none. Metadata-protocol: none. No packages/spec/** change and no new code. Not governed (check-governed-merges reports governed: false). |
| Rule | A number or boolean written to a text-family field is stored as the string the validator judged (42 → "42", true → "true") on every engine write door. A single write is admitted or refused as before. On a unique text field, 42 after "42" is now 409 on both drivers (the narrowing), and on SQLite true after 1 is now admitted (the widening). |
| Evidence | Base 201,201 → head 201,409 on SQLite and memory; base 201,409 → head 201,201 on SQLite for true/1. Ablations went red and were then restored. Dogfood VERDICT 0 at 926fa801 (393 files, 3243 tests). objectql 531/531, rest 371/371, 61 gates. CI on this head: 52 runs, 37 green, 15 skipped, none red. check-expected-skips OK (15, all in main's roster). |
| Follow-up | The filter door has no field-aware arm for a number or boolean comparand on a text-family field. Ruling (b) of the record found this gap already existed; this change exposes it. The seat files it as its own card after landing. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY
Requested by Steve · project thread
Fixes #29241
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**orcontent/docs/**teaches writing a number or boolean into a text-family field, or how auniquetext field compares42with"42"(agit grepofskills/**for unique and text/number shows none); the field-types rule already says a text field stores a string.Clause-② as amended by seat ruling 6579176393 on #29241. Measured: a single write is answered as before (the same payloads are admitted and refused). The unique key now compares the stored string, so on a
uniquetext-family field the accept set of a write sequence moves in both directions (table under Why). Metadata-protocol staysnone: no key, value or metadata type an author writes moves.维护者速读
42存成"42",true存成"true"),再交给驱动。"42"判定,存的却是原值,结果因驱动而异:内存驱动存数字42,SQLite 存成"42.0"、true存成"1.0"(maxLength: 2的字段放行了42,最后存了 4 个字符)。同一次写入,三种存法,等值过滤和排序因库而异。42再写"42",以前两条都存,现在第二条409 UNIQUE_VIOLATION(两个驱动都如此,这是 BREAKING);SQLite 上先写true再写1以前被拒,现在两条都存。已存的行不迁移,SQLite 旧行"42.0"和新写的"42"不再相等。回滚 = revert 本 PR。packages/objectql/src/validation/record-validator.ts的normalizeNumericStringRow新的else if分支;packages/rest/src/rest-data-text-scalar-value.test.ts;.changeset/29241-text-field-scalar-stored-string.md。pnpm --filter @objectstack/rest exec vitest run src/rest-data-text-scalar-value.test.ts,预期 8 个全绿(含唯一字段的 4 个:两个 409、两个双 201)。What changes
normalizeNumericStringValuesinpackages/objectql/src/validation/record-validator.ts, the 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 door that writes a numeric string on a number field as its number.ObjectQL.insert(),update(),validate()(the dry run) andvalidateInsertValuesalready call it on the caller's payload before the middleware, the snapshots, the hooks and the validator read it. Every REST, batch and import door goes through those methods.engine.tsis not touched, and the blank-value path (normalizeBlankTypedValues) is not touched.uniquetext-family field that changes which write sequences are admitted, both ways; see the second table under Why.BOUNDED_STRING_FIELD_TYPES(the set the validator's string arm reads) is written asString(value), the exact string the arm judges. The field scope is the numeric half's: the fields the validator walks,readonlyincluded, never aSKIP_FIELDSname or asystemfield.null, a missing key, an object or an array (refused since records: a text field accepts an object or an array on create, PATCH and screen-flow resume and stores its JSON text (201/200), although the type's stored shape is a string #29133),jsonand the structured types, number and boolean fields, a field the validator never judges, an undeclared key.selectand the reference types are not in the set (records: a free-formselectfield (nooptions) admits a plain object or an array on create and stores it as-is, although a select's stored value is a scalar option value #29244 owns the select arm).Why
Measured on
origin/main@a19759ac(base) and on this branch (head), through the real REST routes (POST /data/:object, then the physical row,engine.findOneandGET /data/:object/:id):text/textareafield42/true42/true(number, boolean)"42.0"/"1.0""42"/"true"false/0false/0"0.0"/"0.0""false"/"0"1.5/-31.5/-3"1.5"/"-3.0""1.5"/"-3""x42"/"true"(control)All three reads (physical row, engine, REST) agree within each cell. Every write answered
201on base and head.On SQLite the stored value is neither the raw scalar nor the judged string: a number is bound as a REAL and the TEXT column keeps its REAL spelling, and a boolean is bound as
1/0. So amaxLength: 2text field admitted42and stored four characters, and{ title: true }stored"1.0". PostgreSQL and MongoDB: NOT MEASURED (no daemon in the container). The engine now hands every driver the string, so no driver decides.Who writes a number or boolean into a text-family field today (option A vs B). A log-only probe at the new arm recorded every rewrite while these suites ran:
@objectstack/objectql,@objectstack/rest,@objectstack/runtime,@objectstack/service-automation(the flow nodes, including thecreate_record/update_recordand screen paths) and the dogfood battery, which boots the example apps and their seeds. Positive control: the probe was proven indist/(4 built files) and recorded the 6 writes of the new REST test, which reach objectql throughdist/.examples/,packages/apps, platform objects (booted by the dogfood battery and the runtime suite)service-automationsuite, dogfood flow fixtures)protocol-save-notice-answer.test.ts(atextfield used to hand a raw42to a hook)No product writer under these suites relies on the raw scalar, so A was built as the card's default. The census did not cover the plugin suites (for example plugin-audit and service-messaging) or the other service packages, so it is not exhaustive; the review spot-read the obvious text-typed value columns there (
sys_audit_log.old_value/new_valueare written throughsafeStringify) and found no number writer.A unique text-family field (measured: the REST create route, the base leg with
origin/main@7f277a86'srecord-validator.tsbuilt into objectql'sdist/, the head leg with this branch's; SQLite inpackages/rest, driver-memory in a one-off probe beside it, deleted):42, then"42"UNIQUE_VIOLATION"42", then42true, then"true"true, then1false, then01, thentrue42, then"42.0"The first three rows are the narrowing, on both drivers: the validator reads both values as one string, and now the unique key does too. The last four are SQLite's widening: base stored
trueand1both as"1.0", and42as"42.0". Each new answer is the one the validator's reading implies.Tests
packages/objectql/src/validation/record-validator.text-scalar-value.test.ts(70 tests): the spec set (12 types) ×42,0,-1.5,true,false; the written string is the one amaxLengthreads; a readonly field; a batch per row, caller objects unmutated; the controls listed above; and the engine doors (insert, insertMany, update by id, update by predicate) with a recording driver, which receives the string; and auniquetext field, where42and"42"reach the driver as one value (what driver-memory's unique key compares). Thevalidate()dry run calls the same function; its answer carries no values, so it is not pinned here.packages/rest/src/rest-data-text-scalar-value.test.ts(8 tests): realObjectQL+ SQLiteSqlDriver+RestServer, POST, PATCH and batch create, checked on the physical column,findOneandGET …/:id; a string control and a number-field control; and the unique field in both directions:42then"42"andtruethen"true"refused409 UNIQUE_VIOLATION(status and code asserted),truethen1andfalsethen0both landing as two strings.packages/objectql/src/protocol-save-notice-answer.test.ts. Itsrefuses a numbercase sentsay: 42through atextfield so the hook would callctx.notice(42). The hook now receives"42", so the vehicle fieldsayisjson, which keeps the value as sent. The assertion is unchanged, and the file's other 21 tests are green on it.Red on base, green on head
a19759acTests 3 failed, 1 passed (4):expected '42.0' to be '42',expected '0.0' to be '0',expected '1.0' to be 'true'Tests 4 passed (4)Tests 8 passed (8)at926fa801Ablations (the fix, the three negative pins, and the unique pins)
Each mutation was made with
scripts/ablation-replace.mjs(anchor 1 → 0, blob changed) and restored to the HEAD blobbdeb597bwithgit diff HEADempty. The objectql file resolvessrc/directly; the rest file reads objectql fromdist/, so leg 1 rebuilt objectql andablation-dist-preflight.mjsfound the marker in 4 built files, then absent from all 14 after the restore rebuild.Tests 64 failed, 5 passed (69); restTests 3 failed, 1 passed (4)('42.0','0.0','1.0')a json, number or boolean field keeps its number or boolean; the engine-door test (itsamount: 7number-field control)a declared field the validator never judges is not rewrittena string, null, a missing key, an object and an array on a text field: the same row back926fa801)92fe1708,git diff HEADempty)Tests 7 failed, 1 passed (8): both 409 pins, both 201,201 pins and the three stored-form tests; objectqla unique text field: 42 and "42" reach the driver as one valueAcceptance notes
"42.0"and no longer equals a new"42", nor collides with it on a unique field. driver-memory keeps rows for the process by default, but itspersistenceoption (file,localor a custom adapter) is opt-in and keeps raw42/trueacross restarts the same way. A migration for such rows would be a separate card.42and one as"42",engine.findandPOST /data/:object/queryagreeing): base:{ title: 42 }matched only the row written as42on both drivers (driver-memory's raw42, SQLite's"42.0"), and{ title: "42" }only the other. Head:{ title: "42" }matches both rows, and{ title: 42 }matches none, on both drivers. So at head a number comparand on a text field does not answer differently by driver; it silently matches nothing, while the write door stores that number as its string. The seat ruled it (b), a gap that predates this PR, to be filed as its own card after landing; the changeset tells a caller to filter with the string.Date(or another class instance) byString(value)while the raw object reaches the driver. Out of this card's scope (numbers and booleans). Reported to the seat, not filed.before*hook that writes a number into a text field after the door is the hook's own value and is not rewritten, as on the numeric half.Verification
Head
288190cd(round 3: prose only — the changeset's filter sentence, and the record-validator docblock and objectql test header restated to the unique answer; no code or test line changed. Round 2 head926fa801: the unique pins and the declaration; round 1 head34c30195had mergedorigin/mainwith #29271,merge-treeclean). See the PR's check-runs for CI.Suites, typecheck, gates, lint, dogfood
pnpm --filter @objectstack/dogfood test(closure built at this head)926fa801record-validator.text-scalar-value.test.ts926fa801rest-data-text-scalar-value.test.ts926fa801dispatch-gates.mjs --commands(61 families), each run926fa801--ran: 61 derived, 61 run, 0 NOT-MEASUREDcheck-adr-0087-registration --base origin/main288190cdnot-required (no-migration-prescription)--no-inline-config) overpackages/objectqlandpackages/rest926fa801lintscript)check:doc-authoring,check:issue-citations,check:nul-bytes,check-empty-changeset,check-closing-keyword-parity, eslint onpackages/objectql/src/validation288190cdpnpm --filter @objectstack/objectql test34c30195pnpm --filter @objectstack/objectql typecheck34c30195pnpm --filter @objectstack/rest test34c30195pnpm --filter @objectstack/rest typecheck34c30195pnpm --filter @objectstack/runtime test27920d0a+ log-only probepnpm --filter @objectstack/service-automation test27920d0a+ log-only probeRound 2 (
34c30195→926fa801) changed only the changeset and the two new test files, so the full objectql and rest suites were not re-run; their round 2 readings are the two edited files above, and Test Core in CI. The runtime and automation rows ran with the census probe in objectql'sdist/: one uncommitted line in the new arm that appended a JSON line to a file whenOS_TEST_PROBE_29241was set, and changed no value; it was removed andablation-dist-preflightproved it absent from all 14 built files. The round 1 dogfood reading (27920d0a+ probe: 391 files, 3223 tests) is superseded by the head reading above.Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses on the touched packages.main, one pull request.packages/spectouched: n/a.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code