Skip to content

fix(objectql)!: a select refuses a plain object or an array as its value, and as a multi-value element (#29244) - #29540

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-5nmkm4
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-5nmkm4

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29244

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** or content/docs/** teaches writing an object or an array into a select (a git grep of skills/** for select values shows only option-code examples); the data skill already authors a select's value as an option value.

Clause-② measured on origin/main @ 04e0c2c0 (base) and on this branch (head): writes the base admitted are now refused (table under Why). Metadata-protocol stays none: no key, value or metadata type an author writes moves; the narrowed face is a data write's answer.

BREAKING for a data write that sends an object or an array where a select expects an option value. FROM: { stage: { a: 1 } } or { stage: ["x"] } on a single-value select / radio with no options answered 201 / 200 and stored the object or the array (SQLite as its JSON text); so did { stage: ["a"] } on a select whose options include a, and { labels: [{ a: 1 }] } or { labels: [["a"]] } on a multiple: true select, multiselect, checkboxes or tags field. TO: each is refused 400 VALIDATION_FAILED with the field's invalid_type, and nothing is stored. The fix for a caller: send the option code itself ({ stage: "x" }), or an array of codes on a multi-value field; structured data belongs in a json field.

Landing hold. Seat 3's PR #29464 (same file, the blank path) lands first, per ruling #29346 B execution step 3. It is still a draft as this PR opens, so this PR is a draft too; it merges origin/main after #29464 lands and checks merge-tree is clean then. The two do not overlap: this diff does not touch normalizeBlankTypedValues, the blank/'' arm or the BOUNDED_STRING_FIELD_TYPES string arm.

维护者速读

  • 改了什么:select / radio 单值字段收到对象或数组时,服务端写入口改为拒收(400 VALIDATION_FAILED,字段码 invalid_type),有无 options 都一样;多值选项字段(multiple: true 的 select、multiselect、checkboxes、tags)数组里夹着对象或嵌套数组的元素也拒收。
  • 为什么改:没声明 options 的自由选择字段原样把 {a:1}、["x"] 交给驱动存下,有选项的字段也因为 String(["a"]) 等于 "a" 而把数组存进去;列表、筛选(筛选入口本来就拒收这种比较值)和报表都读不了这种值。
  • 风险与回滚:以前写得进去的对象/数组写入现在被拒;普查(objectql、rest、runtime、自动化流程、dogfood 全套,含示例应用种子数据)没有一个产品写入方这样写。已存的旧值不改写、不迁移。回滚 = revert 本 PR。
  • 先看:packages/objectql/src/validation/record-validator.ts 的 select/radio 单值分支与多值选项分支新增的形状检查;packages/objectql/src/validation/record-validator.select-shape.test.ts;.changeset/29244-select-object-array-value.md。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-data-select-shape.test.ts,预期 7 个测试全绿(主干上 6 个红:POST 201、PATCH 200 并把对象/数组存进库)。
  • 需要你做的:无,席位落地(须等 fix(objectql): a cleared optional single-value select or lookup stores null, so a second unique clear is no longer refused 409 (#29277) #29464 先合并)。

What changes

  • Single-value arm (select / radio when not multi-valued, record-validator.ts): a plain object or an array is refused invalid_type with the ADR-0104 invalid_value_shape sentence ("expected one option value, received an object" / "an array"). It is a shape test, so it runs ahead of shapeOnly (a readonly value is reached, as the string arm's 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 rule is) and ahead of option membership. The 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 operator-object check still runs first and keeps its own sentence.
  • Multi-value option branch (multiple: true select/radio, multiselect, checkboxes, tags): an element that is a plain object or an array is refused invalid_type ("expected an array of option values, received an element that is an object" / "an array"), again ahead of shapeOnly and membership. An array of strings, numbers or booleans is judged as before; a value that is not an array keeps its existing invalid_type_array sentence; a lone scalar is still wrapped by the engine.
  • The spec's contract. valueSchemaFor (packages/spec/src/data/field-value.zod.ts) already says an option field's stored element is a declared code or, with no options, a string. This makes the write door agree for the two shapes the card names; it does not touch packages/spec.
  • No new code or sentence. invalid_type and the invalid_value_shape catalog entry are the ones the string arm and the operator rule already use.
  • Scope note (declared, bounded in-place). The card names the free-form single select and asks for a decision on multiple: true. The same arm admitted ["a"] on a select with declared options (by its string reading), and the same multi-value branch admitted object elements on multiselect / checkboxes / tags. Both are the same defect class in the same branch, closed by the same mechanical rule with the same gates, so they are fixed here rather than as a type-by-type split (one rule per branch, not per type). Raised in the report for the seat.

Why

Measured with a recording driver behind the real engine (insert, update by id, update by predicate: all three doors answer the same in every row) and through the real REST routes on SQLite and driver-memory (POST create, PATCH update):

Field Value base head
select, no options (and radio) { a: 1 } ADMIT, driver got {"a":1}; REST 201/200, read back the object (SQLite: its JSON text) 400 invalid_type
select, no options (and radio) ["x"], [] ADMIT, driver got the array 400 invalid_type
select, options a/b ["a"] ADMIT, driver got ["a"]; REST 201, read back ["a"] 400 invalid_type
select, options a/b { a: 1 } 400 invalid_option 400 invalid_type (code moves)
select multiple: true, no options [{ a: 1 }], [["x"]] ADMIT, stored 400 invalid_type
select multiple: true, options a/b [["a"]] ADMIT, stored 400 invalid_type
multiselect / tags / checkboxes, free [{ a: 1 }], [["x"]] ADMIT 400 invalid_type
controls "x", 3, "a", ["x","y"], lone "x" on multi ADMIT (as before) ADMIT (as before)

Writer census (who writes an object or an array into a select today). A log-only probe in the select arms of origin/main @ 04e0c2c0, built into objectql's dist/, recorded every object/array value while these suites ran: @objectstack/objectql, @objectstack/rest, @objectstack/runtime, @objectstack/service-automation (flow nodes, including create_record / update_record and the screen paths) and the dogfood battery (which boots the example apps, packages/apps, the platform objects and their seeds). Positive control: the probe recorded the 36 writes of a one-off REST probe that reaches objectql through dist/, and the objectql probe's own writes.

Writer Hits
Seeds and data in examples/, packages/apps, platform objects (dogfood, runtime) 0
Flow / screen paths (service-automation, dogfood flow fixtures) 0
Dogfood fixtures 0
Tests 2: the two #5922 pins re-homed below

Not exhaustive: plugin suites and other service packages were not run under the probe.

unique and filters. A unique free-form select (REST, SQLite and driver-memory): base admitted {a:1} then {a:1} as 201, 409 and {a:1} then {a:2} as 201, 201; head refuses each write 400 before the unique check. "x" then "x" is 201, 409 on both. Filters do not move: the filter door already refused an object or array comparand on a select (engine.find INVALID_FILTER, GET 400) on base and head, and { free: "x" } / { free: { $in: ["x"] } } answer the same rows on both.

Tests

New files:

  • packages/objectql/src/validation/record-validator.select-shape.test.ts: single-value select (free and declared) and radio refuse object, {}, array and [] on insert and update with the shape detail; the message names field, type and shape; a readonly select is reached; an operator object keeps its own sentence; every multi-value option type refuses an object element and an array element; controls (scalars, arrays of scalars, membership, non-array sentence, null); end to end through the engine's insert, update by id and update by predicate with a recording driver that sees no write, and a scalar / array / wrapped lone scalar that still land.
  • packages/rest/src/rest-data-select-shape.test.ts (7 tests): real ObjectQL + SQLite SqlDriver + RestServer: POST and PATCH with {a:1}, ["x"] on a free select and [{a:1}] on a multiple: true select answer 400 VALIDATION_FAILED / invalid_type, no row is written and the PATCHed row is unchanged (physical row read); control stores a string and an array of strings.

Forced edits to existing tests:

  • packages/objectql/src/validation/operator-object-write-value.test.ts: admits an UNDECLARED $-spelling and admits a plain non-operator object (on stage_free, moved there by fix(objectql)!: a string-typed field refuses a plain object or an array instead of storing its JSON text #29223) rested on exactly this admission. Restated as refusals that assert the SHAPE sentence and that the message does not name a filter operator: that is what the pins prove (the operator rule did not claim $inn or {a:1}). Moving them to json would pin nothing, because a json column skips the operator rule entirely, and no scalar-valued type is left that admits a plain object.
Ablations

Made with scripts/ablation-replace.mjs (anchor hit once, blob moved), objectql rebuilt with --force, marker proven in dist by scripts/ablation-dist-preflight.mjs ("marker present in 4 built files"), each restored by the tool (blob == HEAD, git diff HEAD empty), then a final rebuild with the marker absent from all 14 built files and both files green (69/69, 7/7).

Ablation Red
A: the single-value shape test off unit Tests 29 failed | 40 passed (69) (every single-value refusal, both restated #5922 pins); REST Tests 4 failed | 3 passed (7) (the four stage cases)
B: the element test off unit Tests 11 failed | 58 passed (69) (every element case, the end-to-end refusal); REST Tests 2 failed | 5 passed (7) (the two labels cases)

Acceptance notes

Verification

Head 3ffa21c9, branched from origin/main @ 04e0c2c0. origin/main has moved since (to 390bd2ae, no commit touching the five files here, git merge-tree clean); per the card delta this branch merges origin/main once #29464 lands, and the suites are re-run on that head then. CI runs the rest: see the PR's check-runs.

Suites, typecheck, lint, gates
Run (head 3ffa21c9, full-declaration build of the rest + dogfood closure) Result
pnpm --filter @objectstack/objectql typecheck exit 0
objectql full suite (vitest run --maxWorkers=2) Test Files 536 passed (536) · Tests 10432 passed (10432)
pnpm --filter @objectstack/rest typecheck exit 0
rest full suite Test Files 379 passed (379) · Tests 7352 passed, 352 skipped (7704)
dogfood battery (pnpm --filter @objectstack/dogfood test, a narrowing) Test Files 397 passed, 3 skipped (400) · Tests 3277 passed, 28 skipped (3305)
eslint --no-inline-config packages/objectql packages/rest exit 0
dispatch-gates --commands (61 derived), reconciled with --ran 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN; all exit 0

check:dual-build-cjs-loads and check:type-check-debt first answered exit 3 (PREREQUISITE NOT MET: seven packages outside the closure had no dist/, and the dogfood closure build was in flight); after building those seven, both exit 0. The plugin-teardown self-test needed git fetch --depth 1 origin 621a4876….

The census runs (base + probe) built with OS_SKIP_DTS=1, so their dogfood leg also showed 6 reds in rest-route-generation-tombstones.test.ts (TS7016, no .d.ts for @objectstack/spec/api): a build-mode artifact of that run only; the head battery above, on a full-declaration build, is green.


Generated by Claude Code

…lue, and as a multi-value element (#29244)

A single-value select or radio with no options returned before looking at
the value, so an object or an array reached the driver as sent; a declared
select admitted ['a'] through String(['a']). The arm now refuses both shapes
with invalid_type ahead of option membership, and the multi-value option
branch refuses an element that is an object or an array.

The two #5922 pins that rested on the admission are restated as refusals
that assert the shape sentence, not the operator sentence.

Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e
Co-authored-by: Claude <noreply@anthropic.com>
…or and the lone-scalar wrap on the write path (#29244)

Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 3 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/automation/flows.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/frontend/cli.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in validateOne))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in validateOne))
  • content/docs/releases/v17/17-6.mdx (via invalid_type (literal, a string literal in validateOne))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 32907ae9ce0f020a966c5363eaca2a688240e47a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 32907ae9ce0f020a966c5363eaca2a688240e47a

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 32907ae9ce0f020a966c5363eaca2a688240e47a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 3ffa21c · 2026-11-02T18:23Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ffa21c9de78aee8ddd86b0bce888d7af84ead6d
Local-runs: none
Scope: packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/operator-object-write-value.test.ts, packages/objectql/src/validation/record-validator.select-shape.test.ts, packages/rest/src/rest-data-select-shape.test.ts + packages/spec/src/data/field-value.zod.ts (SINGLE_OPTION_TYPES, MULTI_OPTION_TYPES, MULTI_CAPABLE_TYPES, isMultiValueField, valueSchemaFor), packages/spec/src/data/field.zod.ts (SelectOptionSchema.value, multiple), packages/spec/src/system/validation-message.ts (invalid_value_shape, invalid_type_array), packages/core/src/utils/multi-value-storage-form.ts, packages/objectql/src/engine.ts (write-door ordering, 16400–16428), packages/drivers/driver-memory/src/memory-driver.ts (unique enforced at the driver). Not reviewed: .changeset/29244-select-object-array-value.md (prose face, read for ②).

Inputs: card #29244 (body, triage 6575520401, claims 6582080861 / 6582152970, dev report 6584899923), ruling #29346 B (6579813296), PR #29540 body, file list and net diff at the head, and the head's check-runs. The published spec and the cited code were read on origin/main, never reviewed.

① Derived judgments

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

  1. Single-value select / radio, no options: a plain object or an array is refused invalid_type (sentence invalid_value_shape, detail "expected one option value, received an object / an array"). RIGHT. On main the arm (record-validator.ts 1689–1710) returns null when allowed.length === 0, before reading the value, so both shapes reached driver.create / driver.update as sent; the card measured that at 45e9b2f1, the dev re-measured at 04e0c2c0. The spec's stored element for an option field authored without options is z.string() (field-value.zod.ts 716–719), never an object or an array.
  2. Single-value select / radio WITH options: ['a'] is refused invalid_type. RIGHT. On main membership is judged by String(value), and String(['a']) === 'a', so the array passed and the driver stored ['a']. The spec's element is z.enum(codes); an array is not a member.
  3. Single-value select WITH options: a plain object moves from invalid_option to invalid_type. RIGHT. On main the refusal was an accident of String({a:1}) === '[object Object]' failing membership (the 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 header says exactly this about the four accidental cells). A shape refusal ahead of a membership refusal is the string arm's 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 order and the operator rule's own order; the wire code invalid_type is ADR-0114 vocabulary already in use. Named in the changeset.
  4. Shape test runs ahead of shapeOnly, so a readonly select's value is judged under 'include' / 'only'. RIGHT. 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 ReadonlyValueScope is "a readonly field's VALUE is still judged for its SHAPE", and the 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 string arm sits in the same position. On main a readonly free select returned null before any shape question.
  5. Shape test runs ahead of picklistUnresolved(). RIGHT by construction: a picklist-bound select that received an array gets the shape sentence instead of "list did not resolve"; neither admits the write, and the shape is the earlier fact.
  6. The 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 operator rule still runs first and keeps its sentence. RIGHT. The operator check (1193–1240 on main) precedes every per-type arm and is untouched; the new test a filter-operator object keeps its own, earlier sentence and the two restated pins assert it.
  7. Multi-value option branch (multiple: true select/radio, multiselect, checkboxes, tags): an element that is a plain object or an array is refused invalid_type (detail "expected an array of option values, received an element that is …"). RIGHT. On main (1714–1748) the branch admitted any array when allowed.length === 0 and judged each element by String(v) otherwise, so [['a']] passed membership as 'a'. valueSchemaFor derives z.array(element) with element a string or an enum member (752). The rule sits inside if (!isValueShapeJudgedType(t)), so multi-valued lookup / user / file / image stay on the ADR-0104 arm; no second shape rule for them (the 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 ban holds).
  8. A non-array on a multi-value field keeps invalid_type_array; a lone scalar is still wrapped. RIGHT. multiValueStorageForm (core) wraps string / number / boolean and leaves an object untouched; the element rule is reached only after Array.isArray(value).
  9. No new error code, no new catalog sentence, no packages/spec change. RIGHT. fail('invalid_type', { type, detail }, 'invalid_value_shape') is the exact call the string arm (1278) and the ADR-0104 arm (1789) make; the four-locale catalog entry exists on main.
  10. Public surface. No export, type or signature moves in @objectstack/objectql; validateRecord / validateRecordInScope keep their shapes. 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(); REST POST / PATCH; flow record nodes; screen-flow resume). The two 写入载荷里的算子对象:text 型字段不做类型校验,{ title: { $in: [...] } } 原样写进库(number 型会响亮拒绝) #5922 pins the card ordered re-homed are restated as refusals asserting the shape sentence and the absence of "filter operator" in the message, which is what they proved before (the operator rule did not claim { $inn } or { a: 1 }); moving them to json would pin nothing because valueMayBeAnObject skips the operator rule for STRUCTURED_JSON_TYPES.
  11. Unique collision. Unchanged in kind, moved in time: unique is enforced at the driver (memory-driver.ts 438–444; SQL as a translated driver error, engine.ts 16428 / 16504), and validateRecordInScope runs at 16402 before driver.create, so an object or array on a unique free select is now refused 400 before any 201 / 409 could be answered. A string pair still answers 201 then 409. No stored row is rewritten or re-judged.
  12. Filters. Not touched by this diff; the filter door already refuses an object or array comparand on a select (engine-nested-object-door.test.ts on main). The write door now agrees with the read door.

Not implied by the diff, and correctly left alone (see ③ OQ2): a number or boolean on a single-value select, and a number or boolean element on a multi-option field, are still admitted and stored as sent.

② Semver level

.changeset/29244-select-object-array-value.md: '@objectstack/objectql': patch, title fix(objectql)!: …, line-initial Clause-②: no (narrowing) and Metadata-protocol: none, one adr-0087: not-required (no-migration-prescription) marker, a BREAKING banner with FROM → TO and the one-line caller fix, the one code move named. The PR body carries the same two declaration 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 exact shape of the landed precedent .changeset/29133-text-field-refuses-object-array.md. Metadata-protocol: none is right: no key, value or metadata type an author writes moves; SelectOptionSchema.value and FieldSchema.multiple are untouched. The Check Changeset and Governed Surface Queue Guard check-runs on the head are success. The level matches what the diff publishes.

Clause-②: no (narrowing) — matches the card's Level line, the claim's expectation, and the diff.

③ Boundary flags

Every dev flag and open_questions entry, answered:

OQ1 — scope wider than the card (declared-options select refusing ['a']; object on a declared select moving invalid_option → invalid_type; the element rule on multiselect / checkboxes / tags as well as multiple: true select). Dev recommends A, keep. ANSWERED: A, keep, with the three parts tested one by one against the spec on main:

OQ2 — a number on a single-value select is still stored as a number though the spec's element is z.string(). Dev recommends a follow-up card. ANSWERED: A, file the card; out of this card's scope (the card names objects and arrays). Three things belong on that card, all read in the tree, none a defect this PR introduces: (i) a number or boolean on a single-value select is admitted by its string reading and stored raw (the #29241 text-family normalisation is the precedent, and it moved a unique answer, so it is its own narrowing); (ii) a number or boolean ELEMENT on a multi-option field is admitted the same way; (iii) isPlainObjectValue is deliberately prototype-strict (a Date, a Map, a class instance are "not a plain object"), so such a value on a free select still falls to membership and is stored as sent — the string arm has the same posture by the same helper, so this is the family's question, not this arm's. The new control expect(errorsOf({ free: 3 })).toEqual([]) pins (i) as present behaviour; the follow-up card will move that pin.

Dev flag — landing hold behind #29464 (ruling #29346 B, execution step 3). ANSWERED, holds: #29464 is open, not merged, draft: false at my read, head 22a77935; its file list touches record-validator.ts (normalizeBlankTypedValues) and this diff does not touch that function, the blank/'' arm or the BOUNDED_STRING_FIELD_TYPES arm. This PR's mergeable_state is blocked as expected. The seat sequences; the record does not lift the hold.

Dev flag — AGENTS.md section 10 pre-PR origin/main merge deferred. ANSWERED: ordered by the card delta; origin/main moved to 390bd2ae with no commit on the five files and a clean merge-tree per the report. The re-run on the merged head is owed before landing, as the PR body says. Not a review finding.

Dev flag — census built with OS_SKIP_DTS=1, 6 TS7016 reds in the census leg only. ANSWERED: a build-mode artifact of a probe worktree, restored and removed; the head battery on a full-declaration build is green and CI type-checks the head. Not a finding.

Dev flag — PR assignee / reviewer not set; PR activity not subscribed. ANSWERED: both are the standard order's own posture. Not findings.

Out-of-scope finding (carrier: none). The number-on-select item is OQ2's card. Nothing else was carried.

No flag is escalated.

④ Design judgment

The narrowing itself, judged against the published spec on main and the cited code, with every attempt to break it named:

What the spec declares. ADR-0104 D1's value contract (field-value.zod.ts) is the one authority for an option field's stored shape: select ⇒ option code in the module header; SINGLE_OPTION_TYPES "value is one declared option code"; MULTI_OPTION_TYPES "value is an array of option codes"; valueSchemaFor derives z.enum(codes) or z.string() per element and z.array(element) only when isMultiValueField. SelectOptionSchema.value is a SystemIdentifierSchema string. Nowhere does the spec declare an object or an array as a single select's stored value, or an object or a nested array as a multi-option element. The narrowing makes the spec's own refusal real at the write door; it takes no declared capability away at the consumer. It is the select arm's instance of the #29133 string-arm rule, with the same helper (isPlainObjectValue), the same posture (never silently alter a value), the same code and the same catalog sentence.

Attempts to break it:

  1. Is select-without-options a declared "free-form" capability that admits anything? No: valueSchemaFor makes it z.string(), not z.unknown(); the module says openness is an explicit decision and reserves z.unknown() for json and the computed types. A free select is a free STRING, as the spec reads it.
  2. Does the single-value test misfire on a multiple: true select? No: the gate is !isMultiValueField(def), the spec's own predicate (engine: the one-definition-of-multi-valued invariant reaches packages/objectql and driver-turso — the domain:engine half of #18199's ruling #18408), so a multi select falls to the array branch, where the element rule applies.
  3. Does the element rule reach multi-valued lookup / user / file / image and create a second shape rule (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 ban)? No: it sits inside if (!isValueShapeJudgedType(t)), so those types still parse under shapeSchemaFor on the ADR-0104 arm with its posture flags.
  4. Does it run under shapeOnly before the readonly strip and change a value's identity? No: it mutates nothing; it only refuses. normalizeMultiValueFields still does the only payload write, before validation, and only wraps scalars.
  5. Does it collide with fix(objectql): a cleared optional single-value select or lookup stores null, so a second unique clear is no longer refused 409 (#29277) #29464's blank arm? No overlap in the tree: normalizeBlankTypedValues and the '' path are not touched; isMissing(value) returns before this arm for '', null, undefined.
  6. Does the operator rule lose its sentence? No: it runs first (!valueMayBeAnObject(def) is true for a single select) and returns before this arm; { $in: [...] } on a free select still says "is a filter operator, not a value".
  7. Does the enforcement posture differ from the ADR-0104 arm's warn-first? Yes, deliberately and by precedent: the 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 string arm and 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 number arm enforce unconditionally; warn-first is the reference / structured-JSON class's evidence-gated posture, not the scalar classes'. The census found no product writer, and the ablations prove each half of the rule is load-bearing.
  8. Can a declared-options select be hurt by ['a'] being refused where a UI might send a one-element array? The spec gives no such write form; Metadata-protocol and the authoring form (object.form.ts) author a single select's value as one code; the dev's census found 0 such writers in the UI-driving dogfood battery. A remaining caller is told exactly what to send by the sentence and the changeset.
  9. Does anything about a stored object/array row change on read? No: rows are not rewritten or re-judged, and os migrate value-shapes does not scan option fields; the changeset says so.

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 precedent (②); every flag and open question is answered, none escalated (③); the narrowing takes no declared capability away and survives each probe above (④). The head's check-runs: see the line below.

Check-runs on 3ffa21c9: all 48 check-runs completed (35 success, 13 skipped), read at 2026-11-02T18:41Z; none concluded failure, cancelled or timed_out. The gate families this diff derives are success: Build Core, Test Core (8 shards), Type Check (workspace, source gates, consumer gates, debt ledger), Lint · ESLint and the scoped lint gates, 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-5nmkm4
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS

Stamped 2026-11-02T18:41Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Regen-provenance: contract review 6585290571 (PASS, head 3ffa21c9de78aee8ddd86b0bce888d7af84ead6d) carries to head 40b7e036b8dc409d7312448bf3a8d39bbc4382f5. The hop is a merge of origin/main 32907ae9 (which brings #29464 at bd682481; merge-tree clean, no rebase) plus commit 9e1353cf, three comment lines in record-validator.select-shape.test.ts applying that record's should-fix S1, with the assertion unchanged. The PR's net hunks in record-validator.ts against its merge base are byte-identical across the two heads (seat diff), and the net diff is the same 5 files (+403/−17, was +400/−17: the three comment lines). No code, rule or level moves.

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

Head 40b7e036b8, report 6584899923 (on #29244), claim 6582152970. Clause-②: no (narrowing) → contract review owed at CONTRACT_REVIEW_TIER: record 6585290571, PASS, no must-fix, carried by the provenance line above.

item reading
Shape Draft, base main, Fixes #29244. 5 files: the select/multi-option shape rule in packages/objectql/src/validation/record-validator.ts, its unit test, the restated #5922 pins in operator-object-write-value.test.ts, a REST test, and a @objectstack/objectql patch changeset (fix(objectql)!:, BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: none, ADR-0087 not-required). No packages/spec/**, no new error code. Not governed (check-governed-merges governed: false).
Rule A single-value select or radio refuses a plain object or an array with invalid_type and the value-shape sentence, ahead of option membership; the #5922 operator rule still runs first. A multi-option field refuses an object or array element. Scalars are judged as before.
Open questions OQ1: A, keep the wider scope (single-select array refused; invalid_option → invalid_type for an object on a declared select; the element rule on every multi-option type). The review tested each against valueSchemaFor on main: no published spec admits any of them. OQ2: A, filed as #29562 (number/boolean on a single select, number/boolean elements, non-plain objects).
Order Ruling #29346 B step 3 held this landing behind #29277; PR #29464 merged bd682481 and is in this head.
Evidence Base 04e0c2c0 vs head on the engine (insert, update by id, by predicate) and REST POST/PATCH, SQLite and driver-memory; writer census 0 product writers. On 40b7e036: objectql 539/539 files, rest 381/381, dogfood VERDICT 0, 61/61 derived gates reconciled. CI: 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.

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/m tests tooling

Projects

None yet

1 participant