Repository navigation
fix(objectql)!: an option field stores the option code string a number or boolean was judged by, and refuses any object (#29562) - #29638
Conversation
…ed a number or boolean by, and refuses any object (#29562) [wip] Claude-Session: https://claude.ai/code/session_01ADHkmSLV8FibVzx9B1ZHNn Co-authored-by: Claude <noreply@anthropic.com>
…, unique and filter face (#29562) Claude-Session: https://claude.ai/code/session_01ADHkmSLV8FibVzx9B1ZHNn Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmSLV8FibVzx9B1ZHNn Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 6f16472 · 2026-11-02T23:15Z |
📓 Docs Drift Check6 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 243c01d2eb83963aa74ad56a7b637874831bde0e && git checkout 243c01d2eb83963aa74ad56a7b637874831bde0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 322cf9068f143f39cbfbc7527294db24f01cd649 6f164722dffdba3aa22c417e1349f2294625ee52 && git checkout -B drift-repro 322cf9068f143f39cbfbc7527294db24f01cd649 && git merge --no-ff 6f164722dffdba3aa22c417e1349f2294625ee52
node scripts/docs-audit/affected-docs.mjs --json 322cf9068f143f39cbfbc7527294db24f01cd649 |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Inputs: card #29562 (body, triage 6585729182, seat ruling 6587559938, claims 6587575551 / 6587628285, dev report 6590908827), PR #29638 (body, file list, net diff at the head), the precedents read on ① Derived judgmentsEvery accept-set and public-surface change the diff implies, read against the cited code on
Correctly left alone: a value a ② Semver level
③ Boundary flagsOQ1 — filter face: OQ2 — stored rows: old raw Dev deviation — no driver-memory test consumer (closed census, Dev deviation — a non-plain object cannot reach REST; its pins are engine-level. ANSWERED: right, a JSON body carries no Dev deviations — PR assignee / reviewer not set; subscribed to PR activity. ANSWERED: the standard order's posture; not findings. Out-of-scope finding (carrier none) — the text arm still admits a non-plain object ( No flag is escalated beyond a card. ④ Design judgmentWhat the spec declares. ADR-0104 D1's value contract ( Attempts to break it:
Findings. Must-fix: none. Should-fix (none blocks):
Verdict grounds. Every accept-set change the diff implies is the spec's stored shape made real at the write door (①); the changeset level and declarations match the diff and the #29241 precedent (②); every flag and open question is answered, two escalated to cards (③); the narrowing takes no declared capability away and survives each probe above (④). The head's check-runs: the line below. Check-runs on Implemented-by: VERDICT: PASS Stamped 2026-11-02T23:35Z. |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY 席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #29562. 5 files (+626/−33): record-validator.ts (the option-family scalar arm beside the #29133 text arm, reusing its helper, plus the header's rule order realigned), two objectql test files (the { free: 3 } pin moved), one REST suite, one patch changeset fix(objectql)!: with the BREAKING banner. No packages/spec/**, no new error code, no filter-door change. Not governed (check-governed-merges governed: false). |
| Rule | Ruling A as written: a number or boolean on a single option field, or as an element on a multi-option field, is stored as the judged string (String(value), the text arm's spelling) and membership is judged on that string. A non-plain object on an option field is refused invalid_type with the value-shape sentence and never stringified. The unique answer moves both ways (3 and "3" now collide; SQLite true/1 now land), so yes (narrowing) is the right arm. |
| Rulings | 6587559938 holds. The reviewer's OQ1 (filter face) is A: decided in #29486 with the text family; the REST pin records today's answer. OQ2 (stored rows) is A: its own card, not this PR; the ADR-0087 "no stored-row migration prescribed" stands for this change. |
| Not in this PR | S1 (the changeset and REST table could also name the false/0 half of the SQLite widening) is wording only and does not block. S2 (add the option family to #29486's decision box), S3 (stored-row card for option fields) and S4 (the text arm admits a non-plain object such as a Date) are carried by the seat as follow-ups on #29486 and as queue cards. |
| Evidence | Dev: per-sibling, per-type pins on both drivers, the unique-collision pin, the filter-face pin naming #29486, ablations red then restored, and the consumer suites green. CI on this head: 48 runs, 35 green, 13 skipped, none red. check-expected-skips OK (13, all in main's roster). |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Hand-off · seat 1 off duty · 2026-11-02T23:59ZSeat 1 has gone off duty on Steve's order. This PR passes to the next |
Requested by Steve · project thread
Fixes #29562
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none. No file under
skills/**orcontent/docs/**teaches writing a number, a boolean or a non-plain object into an option field; agit grepof both trees for option fields shows option codes written as strings only.Measured on
origin/main@d3175d59(base) and on this branch (head), the engine and REST doors, SQLite and driver-memory (table under Acceptance notes): auniqueoption field now refuses3after"3"(andtrueafter"true"), and aDate,Map,Setor class instance on an option field is now refused. Those are the narrowing. On SQLite,trueafter1on a unique option field used to be refused (both stored"1.0") and now lands: the widening half. The metadata protocol is untouched (none): no key, value or metadata type an author writes moves.维护者速读
multiple: true的 select / radio)收到数字或布尔值时,改为存储校验器判定时用的字符串(3存为"3",true存为"true",多选逐个元素同理);Date、Map、类实例等非普通对象一律以invalid_type拒绝。3,SQLite 存成"3.0",与存"3"的行分裂成两个值,筛选、分组、唯一约束因驱动而异。这是 records: a number or a boolean written to a text-family field is admitted on its string reading but stored as the raw number/boolean, so atextcolumn hands back42, not"42"#29241 文本类字段修复的选项类孪生。3与"3"现在冲突(409),非普通对象被拒,以及用数字3作筛选值不再命中新写入的行(筛选侧由 filter: a number or boolean comparand on a text-family field never matches, now that a text field stores the number 42 as "42" (#29241), and no error is raised #29486 决定,本 PR 不动)。旧行不改写。回滚即 revert 本 PR。packages/objectql/src/validation/record-validator.ts的normalizeNumericStringRow(选项分支)与isObjectValue;② 单选分支与多选元素分支的形状判定;③packages/rest/src/rest-data-option-scalar-value.test.ts(真实 SQLite)。pnpm --filter @objectstack/rest exec vitest run src/rest-data-option-scalar-value.test.ts,预期 10/10 通过:POST { stage: 3 }的物理列为"3",唯一字段先3后"3"第二次为 409。What changes
normalizeNumericStringRowinpackages/objectql/src/validation/record-validator.ts, the function the records: a number or a boolean written to a text-family field is admitted on its string reading but stored as the raw number/boolean, so atextcolumn hands back42, not"42"#29241 text-family half lives in, reusing itsisStringReadScalarandString(value)). On an option-family field (SINGLE_OPTION_TYPES∪MULTI_OPTION_TYPES, the spec's two sets, read and not re-listed), a number or a boolean is written asString(value). On a multi-value option field, each number or boolean element is written the same way, and a lone scalar is written as its string before the engine wraps it. It runs where the text half runs: insert, insertMany, update by id, update by predicate and thevalidate()dry run,readonlyvalues included (their shape is judged on every write). Not touched: strings,null, objects (refused), an array on a single-value field (refused), and non-scalar elements.isObjectValue). The single-value select / radio arm and the multi-value element test usedisPlainObjectValue || Array.isArray, which is prototype-strict, so aDate,Map,Setor class instance fell through to membership. With nooptionsit was stored as sent; withoptionsit was refusedinvalid_optionby the accident of its string reading. Now each is refusedinvalid_typewith the 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 value-shape sentence ("expected one option value, received an object", or "… an element that is an object"). It is never stringified, per the seat ruling 6587559938. The text arm keeps its own posture; this rule is on option-family fields only.validateOne's actual order (required, then the operator rule, then the string arm with its 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 / records: a number or a boolean written to a text-family field is admitted on its string reading but stored as the raw number/boolean, so atextcolumn hands back42, not"42"#29241 shape rules, numbers, boolean, temporal, select, multi-value, value shape) and names the 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 and records: an option-family field stores a number or boolean as sent (a single select, or an element of multiselect/checkboxes/tags), though its spec value is a string option code #29562 select rules..changeset/29562-option-field-scalar-stored-string.md:@objectstack/objectqlpatch,fix(objectql)!:, the two declaration lines, ADR-0087not-required, a BREAKING banner with FROM → TO and the caller fix, the#29241shape.packages/spec/**, no new error code, no filter-door change, noprotocol.ts.Tests
packages/objectql/src/validation/record-validator.option-scalar-value.test.ts(new): the door on all five option types and onmultiple: trueselect / radio, per scalar (3,0,-1.5,true,false) and per element; membership on the written string (a declared code"3"admits3, codes a/b still refuse it); readonly fields written too; the untouched cases; the object refusal (Date, Map, Set, class instance) on single-value and element, insert and update, readonly; the engine doors end to end with a recording driver (insert, insertMany, update by id, update by predicate), the non-plain refusal with no driver write, and the unique key seeing one value.packages/rest/src/rest-data-option-scalar-value.test.ts(new): REST POST / PATCH on a real SQLiteSqlDriver, physical column read past every engine coercion: single, radio, declared code, multi-value elements and a lone scalar;invalid_optionstill answered with nothing written; the unique narrowing (3/"3","3"/3,true/"true"→ 409) and SQLite'strue/1widening; and a filter-face pin that records today's answer only ({ stage: 3 }matches no row once both rows hold"3"), naming filter: a number or boolean comparand on a text-family field never matches, now that a text field stores the number 42 as "42" (#29241), and no error is raised #29486.record-validator.select-shape.test.ts, the{ free: 3 }control inwhat the rule leaves alone. It pinned the number admission as present behaviour and named this card. It is restated as the new rule: still admitted by the validator, and written by the door as{ free: "3" }.check:driver-memory-census). The memory column was measured (table below), and the recording-driver pin shows what every driver receives.Acceptance notes
d3175d59vs head, each row on engine insert / update by id / update by predicate and REST POST / PATCH, both drivers (1100 cells per side; the two drivers agree on every answer):3/true3/true"3.0"/"1.0""3"/"true""3","true"·3/true3/true"3.0"/"1.0""3"/"true"3invalid_optioninvalid_optioninvalid_option[3]or lone3[3][3](JSON)["3"]"{}"/ JSON textinvalid_typeinvalid_optioninvalid_optioninvalid_type[Date]/[Map]/[instance]{}/ JSONinvalid_type3then"3"(and reverse;truethen"true")truethen1A non-plain object cannot reach REST (a JSON body carries none); it reaches the engine doors (flows, hooks, server code).
2. Writer census: 0 product writers. A log-only probe at the door (every option-family field receiving a number, a boolean, a non-plain object, or an array holding one) in a second worktree at base
d3175d59, objectql rebuilt there, then each suite with the probe's env set: objectql (all), rest (all), service-automation (all), every package underpackages/plugins/**, runtime (local tier) and the full dogfood battery (397 files, 3277 tests). Hits: 0 in every leg. Positive controls hit: the Step 0 probe script against that tree'sdist(658 lines) and a throwaway vitest file against itssrc(1 line). So no product writer and no existing test sends a number or boolean to an option field through the engine, and the seat's ruling A needs no re-rule.3. Filter face, measured and not changed (#29486's decision box). A row written
9and a row written"9": base{ free: 9 }answered the9row only, and{ free: "9" }the"9"row only; head{ free: 9 }answers no row and{ free: "9" }both, on both drivers. Named in open_questions.4. Stored rows are not rewritten: an old raw
3(driver-memory with persistence) or SQLite"3.0"keeps reading as it is and does not collide with a new"3". Whetheros migrate value-shapesshould carry option fields is an open question, not built.5. Out of scope, noted: the text arm still admits a non-plain object (a
Dateon atextfield passes as its string reading and is stored as sent); this card's ruling is option-family only. carrier: none.Verification
Head
6f164722: the fix commits271c2665(code) andca133d57(tests, changeset) plus a merge oforigin/main@0ab4a09e(no conflict; main's objectql moves are inengine.ts/engine-reference-name-visible.ts, none inrecord-validator.ts). Every run below is on6f164722after a fullturbo run build --filter='./packages/**' --filter='./examples/**'underos-verify-lock(VERDICT command-exit 0), unless marked.Suites on the head (each under the lock, VERDICT command-exit 0)
Ablations (each restored: blob == HEAD, `git diff HEAD` empty)
Gates and lint (after the last commit, on 6f16472)
Generated by Claude Code