Repository navigation
fix(objectql)!: a select refuses a plain object or an array as its value, and as a multi-value element (#29244) - #29540
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
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 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
|
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 3ffa21c · 2026-11-02T18:23Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: 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 ① Derived judgmentsEvery accept-set change the diff implies, read against the cited code on
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
③ Boundary flagsEvery dev flag and OQ1 — scope wider than the card (declared-options select refusing
OQ2 — a number on a single-value select is still stored as a number though the spec's element is Dev flag — landing hold behind #29464 (ruling #29346 B, execution step 3). ANSWERED, holds: #29464 is open, not merged, Dev flag — AGENTS.md section 10 pre-PR Dev flag — census built with 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 judgmentThe narrowing itself, judged against the published spec on What 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 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 Implemented-by: VERDICT: PASS Stamped 2026-11-02T18:41Z. |
…viour pin #29562 moves (#29244) Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADHkmPFEXGDQbc7fNcM24e Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Regen-provenance: contract review 6585290571 (PASS, head 席位核对 · ACCEPT ·
|
| 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. |
Requested by Steve · project thread
Fixes #29244
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**orcontent/docs/**teaches writing an object or an array into aselect(agit grepofskills/**for select values shows only option-code examples); the data skill already authors a select's value as an optionvalue.Clause-② measured on
origin/main@04e0c2c0(base) and on this branch (head): writes the base admitted are now refused (table under Why). Metadata-protocol staysnone: no key, value or metadata type an author writes moves; the narrowed face is a data write's answer.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/mainafter #29464 lands and checksmerge-treeis clean then. The two do not overlap: this diff does not touchnormalizeBlankTypedValues, the blank/''arm or theBOUNDED_STRING_FIELD_TYPESstring arm.维护者速读
select/radio单值字段收到对象或数组时,服务端写入口改为拒收(400VALIDATION_FAILED,字段码invalid_type),有无options都一样;多值选项字段(multiple: true的 select、multiselect、checkboxes、tags)数组里夹着对象或嵌套数组的元素也拒收。options的自由选择字段原样把{a:1}、["x"]交给驱动存下,有选项的字段也因为String(["a"])等于"a"而把数组存进去;列表、筛选(筛选入口本来就拒收这种比较值)和报表都读不了这种值。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 并把对象/数组存进库)。What changes
select/radiowhen not multi-valued,record-validator.ts): a plain object or an array is refusedinvalid_typewith the ADR-0104invalid_value_shapesentence ("expected one option value, received an object" / "an array"). It is a shape test, so it runs ahead ofshapeOnly(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.multiple: trueselect/radio,multiselect,checkboxes,tags): an element that is a plain object or an array is refusedinvalid_type("expected an array of option values, received an element that is an object" / "an array"), again ahead ofshapeOnlyand membership. An array of strings, numbers or booleans is judged as before; a value that is not an array keeps its existinginvalid_type_arraysentence; a lone scalar is still wrapped by the engine.valueSchemaFor(packages/spec/src/data/field-value.zod.ts) already says an option field's stored element is a declared code or, with nooptions, a string. This makes the write door agree for the two shapes the card names; it does not touchpackages/spec.invalid_typeand theinvalid_value_shapecatalog entry are the ones the string arm and the operator rule already use.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 onmultiselect/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):
select, no options (andradio){ a: 1 }{"a":1}; REST 201/200, read back the object (SQLite: its JSON text)invalid_typeselect, no options (andradio)["x"],[]invalid_typeselect, optionsa/b["a"]["a"]; REST 201, read back["a"]invalid_typeselect, optionsa/b{ a: 1 }invalid_optioninvalid_type(code moves)selectmultiple: true, no options[{ a: 1 }],[["x"]]invalid_typeselectmultiple: true, optionsa/b[["a"]]invalid_typemultiselect/tags/checkboxes, free[{ a: 1 }],[["x"]]invalid_type"x",3,"a",["x","y"], lone"x"on multiWriter 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'sdist/, recorded every object/array value while these suites ran:@objectstack/objectql,@objectstack/rest,@objectstack/runtime,@objectstack/service-automation(flow nodes, includingcreate_record/update_recordand 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 throughdist/, and the objectql probe's own writes.examples/,packages/apps, platform objects (dogfood, runtime)service-automation, dogfood flow fixtures)Not exhaustive: plugin suites and other service packages were not run under the probe.
uniqueand filters. Auniquefree-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.findINVALID_FILTER,GET400) 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-valueselect(free and declared) andradiorefuse 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): realObjectQL+ SQLiteSqlDriver+RestServer: POST and PATCH with{a:1},["x"]on a free select and[{a:1}]on amultiple: trueselect answer 400VALIDATION_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 $-spellingandadmits a plain non-operator object(onstage_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$innor{a:1}). Moving them tojsonwould pin nothing, because ajsoncolumn 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 byscripts/ablation-dist-preflight.mjs("marker present in 4 built files"), each restored by the tool (blob == HEAD,git diff HEADempty), then a final rebuild with the marker absent from all 14 built files and both files green (69/69, 7/7).Tests 29 failed | 40 passed (69)(every single-value refusal, both restated #5922 pins); RESTTests 4 failed | 3 passed (7)(the fourstagecases)Tests 11 failed | 58 passed (69)(every element case, the end-to-end refusal); RESTTests 2 failed | 5 passed (7)(the twolabelscases)Acceptance notes
{ stage: 3 }) is still admitted and stored as the number, although the spec's stored element is a string. Out of this card (the card names objects and arrays); the same normalisation 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 did for the text family would be its own card.os migrate value-shapesscan does not cover option fields.Verification
Head
3ffa21c9, branched fromorigin/main@04e0c2c0.origin/mainhas moved since (to390bd2ae, no commit touching the five files here,git merge-treeclean); per the card delta this branch mergesorigin/mainonce #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
3ffa21c9, full-declaration build of the rest + dogfood closure)pnpm --filter @objectstack/objectql typecheckvitest run --maxWorkers=2)pnpm --filter @objectstack/rest typecheckpnpm --filter @objectstack/dogfood test, a narrowing)eslint --no-inline-config packages/objectql packages/restdispatch-gates --commands(61 derived), reconciled with--rancheck:dual-build-cjs-loadsandcheck:type-check-debtfirst answered exit 3 (PREREQUISITE NOT MET: seven packages outside the closure had nodist/, and the dogfood closure build was in flight); after building those seven, both exit 0. The plugin-teardown self-test neededgit fetch --depth 1 origin 621a4876….The census runs (base + probe) built with
OS_SKIP_DTS=1, so their dogfood leg also showed 6 reds inrest-route-generation-tombstones.test.ts(TS7016, no.d.tsfor@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