Skip to content

fix(objectql)!: an option field stores the option code string a number or boolean was judged by, and refuses any object (#29562) - #29638

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-fp1844
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-fp1844

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29562

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none. No file under skills/** or content/docs/** teaches writing a number, a boolean or a non-plain object into an option field; a git grep of 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): a unique option field now refuses 3 after "3" (and true after "true"), and a Date, Map, Set or class instance on an option field is now refused. Those are the narrowing. On SQLite, true after 1 on 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.

维护者速读

  • 改了什么:选项类字段(单选 select / radio,多选 multiselect / checkboxes / tags,及 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 a text column hands back 42, 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

Tests

  • packages/objectql/src/validation/record-validator.option-scalar-value.test.ts (new): the door on all five option types and on multiple: true select / radio, per scalar (3, 0, -1.5, true, false) and per element; membership on the written string (a declared code "3" admits 3, 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 SQLite SqlDriver, physical column read past every engine coercion: single, radio, declared code, multi-value elements and a lone scalar; invalid_option still answered with nothing written; the unique narrowing (3/"3", "3"/3, true/"true" → 409) and SQLite's true/1 widening; 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.
  • Forced test edit (one): record-validator.select-shape.test.ts, the { free: 3 } control in what 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" }.
  • driver-memory: no new test consumer (its test consumers are a closed census, check:driver-memory-census). The memory column was measured (table below), and the recording-driver pin shows what every driver receives.

Acceptance notes

  1. Step 0 table, base d3175d59 vs 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):
field · value base memory base SQLite head (both)
free select / radio · 3 / true 3 / true "3.0" / "1.0" "3" / "true"
select, codes "3","true" · 3 / true admitted, 3 / true admitted, "3.0" / "1.0" admitted, "3" / "true"
select, codes a/b · 3 invalid_option invalid_option invalid_option
multi option (5 types) · [3] or lone 3 [3] [3] (JSON) ["3"]
free select / radio · Date / Map / instance (engine) stored as sent epoch / "{}" / JSON text invalid_type
declared select / multiselect · Date / Map / instance invalid_option invalid_option invalid_type
free multi option · [Date] / [Map] / [instance] ISO text / {} / JSON same invalid_type
unique select · 3 then "3" (and reverse; true then "true") 201, 201 201, 201 201, 409
unique select · true then 1 201, 201 201, 409 201, 201

A 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 under packages/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's dist (658 lines) and a throwaway vitest file against its src (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 9 and a row written "9": base { free: 9 } answered the 9 row 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". Whether os migrate value-shapes should carry option fields is an open question, not built.
5. Out of scope, noted: the text arm still admits a non-plain object (a Date on a text field 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 commits 271c2665 (code) and ca133d57 (tests, changeset) plus a merge of origin/main @ 0ab4a09e (no conflict; main's objectql moves are in engine.ts / engine-reference-name-visible.ts, none in record-validator.ts). Every run below is on 6f164722 after a full turbo run build --filter='./packages/**' --filter='./examples/**' under os-verify-lock (VERDICT command-exit 0), unless marked.

Suites on the head (each under the lock, VERDICT command-exit 0)
objectql      vitest run (all)              Test Files 542 passed (542) · Tests 10577 passed · typecheck + check:test-typecheck OK
rest          vitest run (all)              Test Files 384 passed (384) · Tests 7405 passed | 352 skipped · typecheck OK
driver-memory test                          Test Files 74 passed (74) · Tests 1767 passed
driver-sql    test                          Test Files 236 passed | 12 skipped (248) · Tests 3847 passed | 214 skipped
runtime       vitest --project local        Test Files 527 passed (527) · Tests 8503 passed | 19 skipped · typecheck OK
dogfood       pnpm --filter @objectstack/dogfood test   Test Files 398 passed | 3 skipped (401) · Tests 3280 passed | 28 skipped
Ablations (each restored: blob == HEAD, `git diff HEAD` empty)
A  single-value arm: isObjectValue(value) -> isPlainObjectValue(value) || Array.isArray(value)
   objectql option-scalar-value.test.ts   Tests 35 failed | 49 passed (84)   e.g. "free: refuses a Date on insert as invalid_type": expected [] to deeply equal [['free','invalid_type']]
B  element arm: value.find(isObjectValue) -> value.find((v) => isPlainObjectValue(v) || Array.isArray(v))
   objectql option-scalar-value.test.ts   Tests 21 failed | 63 passed (84)   e.g. "free_multi: refuses an element that is a Date as invalid_type"
C  door: isOptionType -> false (marker __ablation_29562__), objectql rebuilt, dist preflight found the marker
   objectql option-scalar-value + select-shape   Tests 25 failed | 102 passed (127)
   rest rest-data-option-scalar-value.test.ts    Tests 8 failed | 2 passed (10)
     "3, then \"3\"": the second row landed holding "3" beside "3.0", expected 201 to be 409
     "\"3\", then 3": the second row landed holding "3.0", expected 201 to be 409
     "true, then 1": 409 UNIQUE_VIOLATION, expected 201 (SQLite's base "1.0" collision)
   restored, objectql rebuilt, ablation-dist-preflight --absent: marker absent from all 14 built files, tree clean
Gates and lint (after the last commit, on 6f16472)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack   61 derived
each run, exit captured before any pipe; --ran reconciliation:
  61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN
  two first-run exit 3 (prerequisite, not a finding), re-run exit 0:
    check-plugin-teardown-shape --self-test (fixture commit 621a4876 fetched)
    check:type-check-debt (first run overlapped a closure build; re-measure OK, 0 raw tsc errors above ledger)
eslint packages/objectql packages/rest --no-inline-config   exit 0

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 6f16472 · 2026-11-02T23:15Z

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 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
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 20 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 322cf9068f143f39cbfbc7527294db24f01cd649 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 243c01d2eb83963aa74ad56a7b637874831bde0e — the merge of head 6f164722dffdba3aa22c417e1349f2294625ee52 into base 322cf9068f143f39cbfbc7527294db24f01cd649, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6f164722dffdba3aa22c417e1349f2294625ee52
Local-runs: none
Scope: packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/record-validator.option-scalar-value.test.ts, packages/objectql/src/validation/record-validator.select-shape.test.ts, packages/rest/src/rest-data-option-scalar-value.test.ts + packages/spec/src/data/field-value.zod.ts (SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, MULTI_CAPABLE_TYPES, isMultiValueField, optionCodes, valueSchemaFor), packages/spec/src/data/field.zod.ts (SelectOptionSchema.value), packages/objectql/src/engine.ts (write-door ordering: insert 15531–15532, update 16968–16969, normalizeMultiValueFields 16495 / 18007 / 18257), packages/objectql/src/engine-validate.ts (the dry run's door, 158–160), and on main the record validator's normalizeNumericStringRow, isStringReadScalar, isPlainObjectValue, isInReadonlyScope, optionValues, isValueShapeJudgedType, isScannableValueShapeField and the two option arms of validateOne. Not reviewed: .changeset/29562-option-field-scalar-stored-string.md (prose face, read for ②).

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 origin/main (#29241's .changeset/29241-text-field-scalar-stored-string.md and record-validator.text-scalar-value.test.ts; #29244's changeset and review 6585290571; #29133's changeset; card #29486 and its triage 6585221625), and the head's check-runs. The published spec and the cited code were read, never reviewed.

① Derived judgments

Every accept-set and public-surface change the diff implies, read against the cited code on main:

  1. A number or a boolean on a single-value select / radio is written as String(value) at the door. RIGHT. The spec's stored element for the type is z.enum(codes) or z.string() (valueSchemaFor), never a number; on main the arm already judges membership by String(value) (record-validator.ts 1684) while the write carried the raw scalar, so "judged ≠ stored" was the defect. The door's new branch (BOUNDED_STRING_FIELD_TYPES.has(def.type) || isOptionType(def.type)) is the exact String(value) the arm reads, at the same point of the same function 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 a text column hands back 42, not "42" #29241 landed, so what is judged is what is stored. The spec's own optionCodes reads a declared option by String(o.value) and the validator's optionValues does the same, so a declared code "3" admits 3 and stores "3", which is the code the spec names; SelectOptionSchema.value is a SystemIdentifierSchema string, so an authored code is a string already.
  2. A number or boolean ELEMENT of a multi-value option field (multiselect, checkboxes, tags, multiple: true select / radio) is written as its string; a lone scalar is written as its string and then wrapped. RIGHT. valueSchemaFor derives z.array(element) with the same string element; the multi-value branch on main (1740) judges each element by String(v). tags is free-form z.string(), so "7" is a legal tag exactly as 7 was judged to be. The door runs before normalizeMultiValueFields on every door (engine.ts: insert 15532 vs 16495; update 16969 vs 18007 / 18257), so a lone 3 on tags becomes '3' and is then wrapped to ['3']; had the wrap run first, the new array branch would have read [3] to ['3'] — either order lands the same value. optionElementsAsStrings keeps the same-reference contract (toBe(row) is pinned for untouched rows).
  3. The array branch is gated on isOptionType(def.type) && isMultiValueField(def). RIGHT. A multi-valued lookup / user / file / image array is left as sent and still parses under the ADR-0104 arm (isValueShapeJudgedType), so no second shape rule for those types (finding: a multi-valued reference field (lookup/user with multiple: true) admits an element of the wrong shape: REST stores an embedded record [{ id }] with 201, where the single-valued field refuses it invalid_type (ADR-0104 D1) #22908 holds, pinned by an array on a multi-value lookup … left as sent). An array on a single-value select is left as sent, and the arm refuses it as before.
  4. Readonly scope. The door runs under 'include' (isInReadonlyScope), so a readonly option field's scalar is rewritten, while system non-readonly and lifecycle columns are skipped — identical to 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 a text column hands back 42, not "42" #29241 half and to A system-context write skips value-shape validation for readonly fields: a seed's malformed readonly datetime ('yesterday', an unresolved cel envelope) is stored verbatim, while the same value on a non-readonly field is refused #21663's rule that a readonly value's SHAPE is judged on every write. RIGHT.
  5. Any object on a single-value select / radio is refused invalid_type with the records: a free-form select field (no options) 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 sentence (expected one option value, received an object). RIGHT. isObjectValue is typeof value === 'object' && value !== null, so a Date, Map, Set, class instance, typed array or plain object all refuse, arrays keep the an array word via receivedObjectShape. On main isPlainObjectValue is prototype-strict, so a Date on a free select fell to membership and was stored as sent (SQLite its epoch TEXT), and on a declared select was refused invalid_option by the accident of String(date) failing membership — the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 header's own "accident, not a rule". The spec's element has no object member, and no object has a string reading a caller meant as a code. The shape test still runs ahead of shapeOnly and of membership; the invalid_option → invalid_type move for a non-plain object on a declared select is named in the changeset. The plain-object / array rule of records: a free-form select field (no options) 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 is unchanged in verdict, code and sentence.
  6. Any object as an ELEMENT of a multi-value option field is refused the same way. RIGHT. value.find(isObjectValue) sits inside if (!isValueShapeJudgedType(t)), so the reference / media types are not touched. A null element is not an object: it is left to the branch as before (admitted on a free field, invalid_option as "null" on a declared one) — unchanged.
  7. The blank arms are untouched. null and undefined on a single select return at isMissing before any arm, and the door skips them (neither string, array nor scalar); '' is normalizeBlankTypedValues' null (objectql: a cleared optional unique lookup or select still stores '' and the second cleared record is refused 409 UNIQUE_VIOLATION #29277 / [Decision] records: a cleared single select or lookup is stored as '' by API / import / flow writers. Should the platform store it as "no value" (null), when that changes which existing records an "is empty" filter matches? #29346) one line earlier and a string is never rewritten on an option type (isJudgedNumberType is false, so the string path continues). RIGHT: a cleared select still stores null.
  8. The edge scalars. String(-0) is "0", String(NaN) "NaN", String(Infinity) "Infinity", String(1e21) "1e+21", String(1.50) "1.5". Each is exactly the string the arm already judged membership by on main, so on a declared select only a declared code admits it (none of these is a SystemIdentifierSchema code), and on a free select or tags the stored value is now the judged string where before it was the driver's reading of the raw scalar (memory NaN, SQLite's REAL-to-TEXT). None is a NEW admission, and JSON cannot carry NaN or Infinity, so REST never sends them; the posture is 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 a text column hands back 42, not "42" #29241's (its SCALARS pins String(value) with no numeric grammar). RIGHT, with the observation that -0 and 0 were one code before and still are.
  9. unique. Unique is enforced at the driver on the stored value, so 3 then "3" (and true then "true") now answer 201 then 409 on both drivers where both landed before; on SQLite true then 1 (both "1.0" before) and false then 0 (both "0.0") now land as two strings. The narrowing half and the true / 1 widening half are pinned against SQLite and stated in the changeset; the false / 0 half is implied and not stated (should-fix S1).
  10. Public surface. No export, type or signature moves; normalizeNumericStringValues keeps its shape and has no consumer outside packages/objectql/src on main. No packages/spec change, no new error code or catalog sentence (fail('invalid_type', { type, detail }, 'invalid_value_shape') is the existing call), no filter-door change, no protocol.ts. The narrowed face is a data write's answer, served by every door that runs the record validator: engine insert / insertMany / update by id / update by predicate, validate() (engine-validate.ts 158–160 calls the same door), REST POST / PATCH, flow record nodes, screen-flow resume.
  11. Header realignment (S3 of 6585290571). The new "Rules applied" list follows validateOne's arms on main in order: required (1151), operator (1162), string types with their bounds / domain / format nested (1211), number types with min / max / scale / precision nested (1315), boolean (1529), date / datetime (1536), time (1614), select / radio (1645), multi-value (1697), value shape (1756). RIGHT.
  12. The forced test edit. select-shape.test.ts' { free: 3 } control is the pin review 6585290571 S1 asked to be moved by this card; it is restated as the rule (admitted, written as "3"), and the records: a free-form select field (no options) 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 pins around it are untouched. RIGHT.

Correctly left alone: a value a before* hook writes AFTER the door is not rewritten (the arm still judges its string, the driver stores it as sent) — the latitude #20309 and #29241 documented; a bigint or symbol is neither a scalar the door reads nor an object the arm refuses, so it keeps its pre-existing path (unreachable through JSON). Observations, carrier none.

② Semver level

.changeset/29562-option-field-scalar-stored-string.md: '@objectstack/objectql': patch, title fix(objectql)!: …, line-initial Clause-②: yes (narrowing) and Metadata-protocol: none, one adr-0087: not-required (no-migration-prescription) marker, a BREAKING banner with FROM → TO for both narrowed cases and the one-line caller fix, the invalid_option → invalid_type move named, and the filter and stored-row notes. The PR body carries the same two lines. Per AGENTS.md § Post-Task Checklist 3 a non-protocol narrowing ships patch and keeps its BREAKING banner and ADR-0087 disposition; this is the shape of the landed twin .changeset/29241-text-field-scalar-stored-string.md line for line. Metadata-protocol: none is right: no key, value or metadata type an author writes moves. The Check Changeset and Governed Surface Queue Guard check-runs on the head are success.

Clause-②: yes (narrowing) — RIGHT, and the yes arm is the correct one: the stored form moves and the unique answer moves in BOTH directions (the SQLite true / 1 pair now lands), which a bare no (narrowing) could not carry; #29241 declared the same pair the same way, #29133 and #29244 (pure refusals) declared no (narrowing). The changeset states the widening half (true / 1); it omits the false / 0 pair #29241 named — S1, prose.

③ Boundary flags

OQ1 — filter face: { field: 3 } now matches no row written 3; should a number comparand on an option field be read by its string? Dev recommends A, decide in #29486 with the text family. ANSWERED: A. Measured posture, from the dev's table and the cited code: on base, SQLite stored 3 as the TEXT "3.0" (better-sqlite3 binds a JS number as REAL; TEXT affinity renders it "3.0") and a number comparand bound the same way matched that row and NOT a row written "3"; memory matched 3 === 3 only. So the filter face was already split by write spelling on both drivers. On head { f: "3" } matches every new row and { f: 3 } none. The one caller made worse is the one that writes numbers AND filters with numbers into an option field, whose census is 0 product writers (the dev's log-only probe over objectql, rest, runtime, service-automation, plugins and the dogfood battery; not re-run here). The spec's option value is a string, the #7872 comparand door admits a number on any field and has no field-aware arm for option fields, and the seat ruled the filter face out of this card (6587559938). The same silent miss #29486 names for text now exists for the option family; its body names text-family only, so the decision box must name both (S2). Not a must-fix: the REST pin records today's answer under #29486's name, so a change there is seen here.

OQ2 — stored rows: old raw 3 (memory with persistence) or SQLite "3.0" keep reading as they are and do not collide with a new "3"; should os migrate value-shapes carry option fields? Dev recommends A, its own card. ANSWERED: A, its own card, not this PR. Read in the tree: isScannableValueShapeField covers the reference and structured-JSON classes only, so the scan cannot see an option field today; an old SQLite "3.0" is decidable only against declared codes ("3.0" means "3" when "3" is a code, and nothing can be said on a free select), while a memory-persisted raw 3 is decidable by type. That is a migration rule, not a write-door one. Until it lands the inconsistency is the one #29241 accepted for text and the changeset states: a read hands back the old form, { f: "3" } misses old rows, and a new "3" does not collide with an old "3.0" on a unique field. The ADR-0087 marker's "no stored-row migration is prescribed" is right for this PR. S3 names the card.

Dev deviation — no driver-memory test consumer (closed census, check:driver-memory-census). ANSWERED: accepted; the recording-driver pin shows the payload every driver receives, the memory column was measured in the Step 0 table, and the unique collision on memory follows from its keying on the stored value (6585290571 ① 11).

Dev deviation — a non-plain object cannot reach REST; its pins are engine-level. ANSWERED: right, a JSON body carries no Date, Map or class instance; flows, hooks and server code reach the engine doors, where the pins sit.

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 (new Date(0) on a text field passes as its string reading and the driver stores it as sent). ANSWERED: escalated as S4, a card; it is sibling (3)'s text-family twin through the same isPlainObjectValue helper, and this card's ruling is option-family only.

No flag is escalated beyond a card.

④ Design judgment

What the spec declares. ADR-0104 D1's value contract (field-value.zod.ts): the module header says select ⇒ option code; SINGLE_OPTION_TYPES "value is one declared option code"; MULTI_OPTION_TYPES "value is an array of option codes (tags: free-form)"; valueSchemaFor derives z.enum(codes) or z.string() per element and z.array(element) when isMultiValueField; optionCodes reads a declared value by String(o.value); SelectOptionSchema.value is a SystemIdentifierSchema string. No published spec declares a number, a boolean or any object as an option field's stored value. The narrowing makes the spec's own stored shape real at the write door and takes no declared capability away at the consumer: every payload admitted before is admitted now, with the same verdict and code (the one code move is a non-plain object on a declared select, from an accidental invalid_option to the shape sentence), and the value stored is the one the arm already judged. It is the option-family instance of #29241 (same function, same String(value), same door points) and of #29133 / #29244 (same shape refusal), widened from "plain" to "any" object because an option code is a string and no object has a string reading a caller meant.

Attempts to break it:

  1. Is a free select or tags a declared "anything" capability that a number exercises? No: valueSchemaFor gives z.string(), not z.unknown(); the module reserves openness for json and the computed types. The number was admitted by its string reading, and now that reading is stored.
  2. Can String(value) mint a code the arm did not judge? No: the door runs before the validator on every door with the same expression on the same scalar, and validate() (engine-validate.ts) runs it too. The only value the arm judges without the door is one a before* hook writes afterwards — the documented latitude of 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 and 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 a text column hands back 42, not "42" #29241, not this PR's.
  3. Could a declared option be a number such that "3" and the code disagree? No: the spec's optionCodes and the validator's optionValues both String() the declared value, and SelectOptionSchema.value is a string identifier.
  4. The edge scalars (-0, NaN, Infinity, 1e21, 1.50). Each stores the string the arm judged ("0", "NaN", "Infinity", "1e+21", "1.5"); on a declared select none passes membership unless declared, on a free field each was already admitted and the driver stored its own reading of the raw scalar. Nothing new is admitted; JSON cannot carry two of them. 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 a text column hands back 42, not "42" #29241 has the same posture.
  5. Does the array branch of the door reach a non-option array? No: isOptionType && isMultiValueField; a multi-valued lookup / user / file / image and a json array are left as sent (pinned), and a single select's array is left for the arm to refuse (pinned).
  6. Does the lone-scalar order matter? No (① 2): the door precedes normalizeMultiValueFields on insert and both update paths, and the array branch covers the other order.
  7. Does isObjectValue catch null, and does the blank arm survive? null is excluded explicitly; isMissing returns before the arms; '' is null by objectql: a cleared optional unique lookup or select still stores '' and the second cleared record is refused 409 UNIQUE_VIOLATION #29277 one line earlier; strings are never rewritten on option types. A cleared select stores null as before.
  8. Does the object refusal leak into the reference / media classes or create a second element rule? No: both arms sit where records: a free-form select field (no options) 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 put them, inside the !isValueShapeJudgedType(t) branch and the single-value select gate !isMultiValueField(def) (engine: the one-definition-of-multi-valued invariant reaches packages/objectql and driver-turso — the domain:engine half of #18199's ruling #18408).
  9. Readonly and picklist. The shape test runs ahead of shapeOnly (pinned under 'include'); picklistUnresolved and keptOptionValues are untouched and still read String(value).
  10. The operator rule. Still first and still its own sentence (valueMayBeAnObject is false for a single select).
  11. Does the unique move take a capability away? It removes the ability to store 3 and "3" as two rows of one option on a unique field — the split the card is about, and the same move 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 a text column hands back 42, not "42" #29241 made with the same declaration. The SQLite widening half returns a legal pair ("true" and "1" are two codes).
  12. The filter face. Not touched; the read door still refuses an object or array comparand on a select and still admits a number one, which now silently misses — 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's question, named for both families (S2).
  13. Performance. The array branch allocates only when an element changes; a row with no change returns the same reference (pinned).

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 6f164722: all 48 check-runs completed (35 success, 13 skipped), read at 2026-11-02T23:34Z; none concluded failure, cancelled or timed_out. The gate families this diff derives are success: Build Core, Test Core (8 shards), TypeScript Type Check and Type Check (workspace, source gates, consumer gates, debt ledger), Lint & Repo Gates with Lint · ESLint and every scoped lint gate, Check Changeset, PR-body guards, Governed Surface Queue Guard, Dogfood Regression Gate, Test UI and Live E2E UI. The UI build / lint / type-check matrix, Temporal Conformance and the opt-in harnesses are skipped by the path filter (no ui/ or driver path in the diff). Their conclusions are the gate verdicts this record adopts.

Implemented-by: claude/project-thread-fp1844
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS

Stamped 2026-11-02T23:35Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T23:38Z

Head 6f164722df, dev report 6590908827 (on #29562), seat ruling 6587559938 (A). Clause-②: yes (narrowing), so the record is owed at CONTRACT_REVIEW_TIER on the narrowing face. Record 6591249009 on this head at CONTRACT_REVIEW_TIER: PASS, no must-fix.

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.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Hand-off · seat 1 off duty · 2026-11-02T23:59Z

Seat 1 has gone off duty on Steve's order. This PR passes to the next domain:server seat. It is ACCEPTed (6591295533 on review PASS 6591249009) and sits in the merge queue with auto-merge on. The next seat follows it to MERGED and posts the landing note on #29562. The steps are in the hand-off on the card, 6591615821.

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant