Repository navigation
fix(objectql)!: a multi-valued reference element is held to the single value's stored form (#22908) - #22947
Conversation
…e value's stored form The record validator's multi-value branch returned early for lookup / user / file / image, so an element was never parsed against valueSchemaFor's z.array(element). An embedded record, a boolean or a number was admitted as an element on every door, the system write included, where the single-valued field refuses the same three invalid_type (ADR-0104 D1). Those types now fall through to the ADR-0104 arm the single value goes through: same derived schema, same posture, same sink, same refusal. The detail names the offending element's index on a multi-valued field, for the write path and the value-shapes scan alike (one helper). Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…-> TO Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…rd narrows the option arm Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…er's bound Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 faffb3fa01a71ae74c1f41e869f71cfa0e6eae17 && git checkout faffb3fa01a71ae74c1f41e869f71cfa0e6eae17
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51b43a8dc45fef9d86229509ab6b2679ffa9d501 55cf89916e46ef675a9ce842d9740bb38fa076cb && git checkout -B drift-repro 51b43a8dc45fef9d86229509ab6b2679ffa9d501 && git merge --no-ff 55cf89916e46ef675a9ce842d9740bb38fa076cb
node scripts/docs-audit/affected-docs.mjs --json 51b43a8dc45fef9d86229509ab6b2679ffa9d501
|
Contract reviewServed-tier: Inputs: card #22908 (body, triage grade 6115825886, claim 6116778532, os-dev-report 6117876326), PR #22947 (body, its one bot comment, 4-file list, 4 commits), the net diff of the branch against ① Derived judgments
② Semver level
③ Boundary flagsEvery
Implemented-by: VERDICT: PASS Adopted by |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38206341301 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue-red triage: a superseded group. The PR is still in the queue, so nothing is re-armed
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38206501083 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue-red, second build · Run Disposition: parked, as the previous audit said it would be. The PR is not re-armed while #22949 is open. The card is |
Fixes #22908
Clause-②: yes (narrowing)
Two queue builds of this PR went red on the suite-duration ceiling gate (#22949), over packages this diff does not touch. The queue kept the entry, and a third build landed it. A park line written here in error is withdrawn.
What changed
packages/objectql/src/validation/record-validator.ts, the multi-value branch ofvalidateOne. It returnednullearly forlookup/user/file/image("reference integrity is handled elsewhere"), so an element of amultiple: truefield was never parsed against its stored form. Those types now fall through to the ADR-0104 value-shape arm that a single value goes through. That arm parses the whole array withvalueSchemaFor(def, 'stored'), which is alreadyz.array(element)for a multi-valued field (ReferenceIdValueSchemaforlookup/user,FileReferenceIdValueSchemaforfile/image). The result is the same refusal (invalid_type, message keyinvalid_value_shape), under the same posture, with the same warn-first sink. No second shape rule was added, as triage 6115825886 binds.isValueShapeJudgedType, now answers "does the ADR-0104 arm judge this type" in all three places that ask it: the arm, the multi-value branch that hands an element to it, andvalueShapeViolation(the scan's predicate). Before this, the scan and the branch could disagree about which types those are.valueShapeDetailnames the index of the wrong element on a multi-valued field (at index 1: ...), so a refusal names the field and the index. The write path andos migrate value-shapesshare this one reading.repeater/vectordetails are unchanged.isSystemwrite, so the system door now refuses too.assertReferencesResolveis untouched (read-only): itsisSystemlatitude on whether an id EXISTS stays asisSystem写入仍可产生悬空 lookup 引用——需要一条只报告不拦截的巡检(#4441 残留) #4551 left it, and a test pins that.file/imagewithmultiple: true: measured and given their own stored form, the opaquesys_fileid, under the media posture (mediaValueShapeStrict/ the file-references flag). A verified media flag does not open reference elements, and the value-shapes flag does not open media elements (pinned).One changeset,
.changeset/22908-multi-reference-element-shape.md:@objectstack/objectqlminor, BREAKING (accept-set narrowing), ADR-0087not-required (no-migration-prescription), FROM / TO stated.Premise, checked on
origin/main018b077fbfbefore any editvalueShapeStrict: true,{ team: [{ id: 'x' }], watchers: [false] }on a multi-valuedlookup/userwas ADMITTED, while the single-valued control{ owner: { id: 'x' } }was refusedinvalid_type.scanValueShapesover rowsteam: [{ id }],team: [false],watchers: [42]reportedteamcount 2 andwatcherscount 1, plus a single-valued control row, withblocking4. So no gap card is owed. But at BASE the scan counted values the strict write path admitted, which contradicts the scan's own header ("count exactly what strict mode would reject"). This change closes that disagreement, and a test pins the two in agreement. The media half has its own evidence:backfill-file-references.tsconverts arrays element by element.Pins (tests beside the code)
packages/objectql/src/validation/multi-reference-element-shape.test.ts(validator level): forlookup/userwithmultiple: true, the three bad elements (boolean, number, embedded record) are refused on insert and update, naming the field and index 0. The wrong element at index 2 is named as index 2. CONTROLS: the single-valued rows refuse the same three with no index; a well-formed id array and[]are accepted; a non-array is still the array refusal. A readonly multi-valued reference is judged by its shape, as a readonly single value is. Warn-first admits the element and reports it to the sink (value-shapegate for references,mediafor files). The gate split holds, and theOS_ALLOW_LAX_VALUE_SHAPEShatch still works.file/imageelements are held to thesys_fileid form. The scan predicate and the strict write path agree on every probe.packages/objectql/src/engine-multi-reference-element-shape.test.ts(engine write path, strict posture): the three bad elements onlookupanduserwithmultiple: true, through a caller's context (the engine call the REST data routes make) and the system principal, on insert and update. Each is refused as aValidationErrorwithcodeVALIDATION_FAILEDandfields[0]{ field, code: 'invalid_type' }, and nothing is stored or changed. CONTROLS: the single-valued rows are unchanged on both doors; well-formed id arrays are accepted on both doors; and a well-formed id that names no row isreference_not_foundfor the caller but still written by the system principal (theisSystemexistence latitude is kept).The real door: PR #22907's reference-shape family (one-off, not committed)
runReferenceShapeProofsfrom@objectstack/verify(a realbootStack, the REST data routes as the signed-in caller, andhooks.runwith{ system: true }) over a fixture withaccounts(lookup, multiple),watchers(user, multiple), and single-valuedaccount/assigneecontrols. It ran as a scratch test file that was deleted afterwards. It resolves@objectstack/objectqlthrough itsexports, so through the builtdist/.invalid_type(REST answers400), 0 admitted, 0 unattributed.holes, 8 of 12 admitted each. That reproduces the card's measurement: REST embedded record admitted201/200; REST boolean / number refused only asreference_not_found400; system door all admitted. The single-valued controls stay proven. Summary: 2 proven, 2 holes, 32 refused, 16 admitted.Ablation (one-off;
scripts/ablation-replace.mjsin WRAP mode, plus an EXIT/INT/TERM trap)if (isValueShapeJudgedType(t) && String('ABLATION-22908-MARKER')) return null;ahead of the option arm. Anchor 1 to 0, blob671387d31cbdto7cc631fd7095. On disk: marker 1, the early-return line 1.pnpm --filter @objectstack/objectql build, thenscripts/ablation-dist-preflight.mjs: the marker is present in 4 dist files. The real-door reading is above.git checkout HEAD -- ABS_PATHby the tool. Blob after restore equals HEAD (671387d31cbd),git diff HEADis empty,git statusis clean. Rebuilt;--absentpreflight: the marker is absent from all 14 dist files. objectql pins 53/53 pass; the real-door reading is 48/48 refused.Local verification
All read at the final head,
55cf89916e, after the last commit.pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2: 405 files passed, 7911 tests passed (throughos-verify-lock,VERDICT command-exit 0).pnpm --filter @objectstack/objectql typecheck(tsc --noEmitandcheck:test-typecheck): exit 0, test layer held at 40 files / 234 errors / 65 pinned signatures, with no new signature. Both new test files are in thetsconfig.test.jsonprogram (--listFiles);tsconfig.jsonexcludes tests.dispatch-gates --commands, change set from the merge base): 66 families, all inside the 69.dispatch-gates --ranwith recorded exit codes: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). The 3 extra are engine-surface families:check:error-status-conformance,check:stack-collection-maps,check:swallow-census-controls. One gate went red during development,check:objectql-double-limit, which was right: the new engine test's stubfindignored the caller'slimit. It now applies the bound after the filter.check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closure,check:published-files,check:sourcemap-no-sources-content,check-dts-emitted --self-test) ran afterpnpm --filter @objectstack/objectql build.calculateConfigForFile) applies to all 3 changed.tsfiles, and none is ignored.eslint --no-inline-config --format jsonover them gave 3 files, 0 errors, 0 warnings. The config enables no type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict on any untouched file. The repo-widepnpm lintis CI's.plugin-approvalsattachments,service-storagegallery,restandruntimefixtures, the showcase seed) found id elements only. CI runs the full set.Acceptance notes (observations; not filed)
actionableValueIssueMessage,packages/spec/src/data/default-value-shape.ts) is a deliberately separate reading. It does not name the index for a multi-valued literal default. That is polish, not a defect class; carrier: none.assertReferencesResolve's note "already-expanded objects are skipped" now describes a path reachable only under warn-first. On a strict deployment the validator refuses an expanded element first, for both cardinalities, as it already did for a single value.objectui) writes into a multi-valued lookup / user / file / image on save. Noobjectuicheckout was available here. If a console editor wrote expanded records back as elements, a strict deployment would now refuse that save. The single-valued field has refused that shape under the same posture all along.os verify --reference-shapesthe default. The one-off reading above is the input for that separate call.examples/app-showcaseitself was not run through the CLI.Generated by Claude Code