Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): [] no longer satisfies required on a multi-value field — the #9447 ruling's enforcement half - #9780
Conversation
…#9476) Per the #9447 maintainer ruling (2026-08-18): required on a multi-value field means non-empty array. Teach both required read sites (INSERT and the ADR-0113 UPDATE non-regression check) the def-aware emptiness judgment via isEmptyForRequired; pin the rejection envelope, the populated-array control, and the null controls' distinct sentences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…uired-non-empty-multi-value
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not seeCoarse fallback — 14 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 2f3b48807e7856b55ab3fb78ac7a37f41a554cc6 && git checkout 2f3b48807e7856b55ab3fb78ac7a37f41a554cc6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 985a9cd2dbbad0bec9edce107f35d20791c9ac5c cd7aa7a42c31a112c24c5545660683ffdbd448ce && git checkout -B drift-repro 985a9cd2dbbad0bec9edce107f35d20791c9ac5c && git merge --no-ff cd7aa7a42c31a112c24c5545660683ffdbd448ce
node scripts/docs-audit/affected-docs.mjs --json 985a9cd2dbbad0bec9edce107f35d20791c9ac5c |
Uh oh!
There was an error while loading. Please reload this page.
…ding The multi-value `required` callout still said the non-empty half was "declared but not yet enforced" and told authors to "validate emptiness in application code until that lands (tracked in #9476)". #9476 landed (PR #9780); the record validator rejects `[]` on a required multi-value field on insert and on any update that supplies the field, so the callout was instructing authors to write redundant application-level checks against a rule the platform now enforces. Re-derived on origin/main before editing: `isEmptyForRequired` in packages/objectql/src/validation/record-validator.ts (helper plus both `required` read sites — the insert-branch `validateOne` check and the ADR-0113 update-branch clear-out check), with `isMissing` still present as the short-circuit that keeps `[]` on a NON-required multi-value field on the array-shape branch. The representation paragraph above the callout is correct and independent and is left untouched. The callout's trailing contrast sentence ("The representation guarantee above is live") is dropped with the false framing it existed to contrast against; the guarantee itself still stands in that paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
Fixes#9476
What
requiredon a multi-value field now means non-empty array, per the #9447 maintainer ruling (2026-08-18).validateRecordjudgedrequiredthroughisMissing(undefined / null / blank string), so an explicit[]satisfied a requiredmultiple: truelookup on both INSERT and UPDATE whilenullwas correctly rejected — the enforcement gap the card measured.How
One def-aware helper,
isEmptyForRequired(def, value)—isMissing(value) || (isMultiValueField(def) && Array.isArray(value) && value.length === 0)— read by exactly the tworequiredsites:validateOne):[]gets therequiredenvelope, same as a missing value.[]is an explicit clear — the distinctrequired_clearedsentence (wire coderequired), same as an explicitnull. An omitted field still never 400s.Scope is the spec's own multi-value predicate (
isMultiValueField, ADR-0104 D1): inherently-multi option types plus multi-capable types flaggedmultiple: true. Structured-JSON types stay out —[]there is a legitimate document (pinned). Every other path keeps def-freeisMissingsemantics untouched; a[]on a NON-required multi-value field still flows to the array-shape branch.Reverse verification — the before leg was observed, not inherited
Fix committed first, then
origin/main's validator stood up viagit restore --source=origin/main -- …/record-validator.ts, and the new suite run against it (merge-base2065e31d5):[]rejection pins FAIL —expected undefined to be an instance of ValidationError, i.e. todayvalidateRecord(schema, { members: [] }, 'insert')and…'update'both pass, and multiselect[]passes — the card's measurement reproduced;nullcontrols (insertrequired, updaterequired_cleared) — the required machinery works; it never judged array emptiness.Fix restored from the commit (
git checkout HEAD -- …): 82/82 green.The dispatch's three required pins
err instanceof ValidationError,err.code === 'VALIDATION_FAILED',fields[0]matching{ field, code: 'required' }, plus the message sentence. On thestatushalf: this validator-level error deliberately carries no.statusproperty — REST maps theVALIDATION_FAILEDclass to HTTP 400 inpackages/rest/src/error-response.ts(pinned inpackages/resttests); the new pin's comment names that mapping. Same envelope depth as the house's closest prior art (engine-lookup-referential-integrity.test.ts).['u1']on insert and['u1','u2']on update still land.nullcontrols — still rejected with the two DISTINCT reasons exactly as onmain: insert renders therequiredsentence, update therequired_clearedsentence (both keep wire coderequired; the two reasons are distinguished by message-catalog key, unchanged).Rollout blast radius: zero, with a positive control
Probes: an order-free brace-block scan over every tracked
.ts/.json/.ymlunderpackages/apps/examples/scriptsfor field defs holding BOTHrequired: trueAND (multiple: trueOR an inherently-multi type: multiselect / checkboxes / tags), then a repo-wide grep forname: []writes against every declared name.rosterRequiredMultiinpackages/objectql/src/engine-cascade-delete.test.ts(Docs and engine disagree ondeleteBehavior: 'set_null'written EXPLICITLY on a required lookup — the escalation torestrictcannot see the difference #9625's pin). No[]write against it, and the cascade path cannot produce one: the engine escalatesset_nullon a required FK torestrictBEFORE the member-removal branch (engine.ts, pinned by Docs and engine disagree ondeleteBehavior: 'set_null'written EXPLICITLY on a required lookup — the escalation torestrictcannot see the difference #9625), so a required multi-value set is never emptied by cascade.[]writes this PR's own test block adds (members,labels) and Docs and engine disagree ondeleteBehavior: 'set_null'written EXPLICITLY on a required lookup — the escalation torestrictcannot see the difference #9625'saccounts— the probe demonstrably detects the class it reports absent elsewhere.@objectstack/objectqlsuite post-fix — 218 files / 3865 tests green, Docs and engine disagree ondeleteBehavior: 'set_null'written EXPLICITLY on a required lookup — the escalation torestrictcannot see the difference #9625 cascade pins included.validateRecord, which has exactly four call sites, all inengine.ts): UI bulk-action params (project.view.ts), flow approval form (dynamic-approval.flow.ts), designer form defs (hook.form.ts/action.form.ts), SCIM groupmembers, an auth-manager fake engine.Fences
[]ornull? #9447 not weakened or re-opened; this is its enforcement half.[]passesrequired— measured: the entire pre-existing suite stays green under the ruled behavior.Follow-up finding (filed separately, not in this diff)
Two comments justify #9625's blanket cascade refusal partly by "today
[]still satisfiesrequiredin the record validator (#9476)" —engine.ts(cascade region) andengine-cascade-delete.test.ts. That clause goes stale when this lands (the refusal itself stays ruled behavior). Out of this card's declared file surface and inside a concurrently-claimed file, so filed as #9781.Gates
All local readings taken at final HEAD
cd7aa7a42(fix + merge oforigin/main@e9534a4ac), after the last commit — nothing changed after these runs.pnpm --filter @objectstack/objectql test— 218 files / 3865 tests green (also green pre-merge at the branch tip).pnpm --filter @objectstack/objectql typecheck— green.check:cross-package-test-inputs(pnpm + node forms) ·check:durability-log-level·check:error-code-casing·scripts/check-engine-split-ratio.mjs(informational, exit 0) ·scripts/docs-audit/check-affected-docs.mjs.node scripts/pm/dispatch-gates.mjs(no paths — script derives the changed set): additions beyond the lead, all green:check:changeset-gate-self-tests·check:objectui-changeset·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset· convention-triggered for a test-file edit:check:query-options-erasure·check:type-check-coverage·check:type-check-debt -- --re-measure(closure BUILT first viaturbo run build --filter=./packages/* --filter=./packages/*/*; 33 ledger entries re-measured, none above recorded — baselines untouched) ·check:engine-double-contract·check:where-matcher.check:nul-bytes— green.Changeset
@objectstack/objectqlpatch withadr-0087: not-required (no-migration-prescription)— nothing authorable is renamed, retired, or tombstoned; a write-time data-plane tightening to a ruled, already-documented contract.Generated by Claude Code