Skip to content

fix(objectql): [] no longer satisfies required on a multi-value field — the #9447 ruling's enforcement half - #9780

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-9476-required-non-empty-multi-value
Aug 19, 2026
Merged

fix(objectql): [] no longer satisfies required on a multi-value field — the #9447 ruling's enforcement half#9780
os-elon merged 2 commits into
mainfrom
claude/issue-9476-required-non-empty-multi-value

Conversation

@os-elon

@os-elonos-elon commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9476

What

required on a multi-value field now means non-empty array, per the #9447 maintainer ruling (2026-08-18). validateRecord judged required through isMissing (undefined / null / blank string), so an explicit [] satisfied a required multiple: true lookup on both INSERT and UPDATE while null was 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 two required sites:

  • INSERT required check (validateOne): [] gets the required envelope, same as a missing value.
  • UPDATE ADR-0113 non-regression check: a SUPPLIED [] is an explicit clear — the distinct required_cleared sentence (wire code required), same as an explicit null. 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 flagged multiple: true. Structured-JSON types stay out — [] there is a legitimate document (pinned). Every other path keeps def-free isMissing semantics 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 via git restore --source=origin/main -- …/record-validator.ts, and the new suite run against it (merge-base 2065e31d5):

  • the three [] rejection pins FAIL — expected undefined to be an instance of ValidationError, i.e. today validateRecord(schema, { members: [] }, 'insert') and …'update' both pass, and multiselect [] passes — the card's measurement reproduced;
  • all 79 other tests pass, including the null controls (insert required, update required_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

  1. Rejection envelopeerr instanceof ValidationError, err.code === 'VALIDATION_FAILED', fields[0] matching { field, code: 'required' }, plus the message sentence. On the status half: this validator-level error deliberately carries no .status property — REST maps the VALIDATION_FAILED class to HTTP 400 in packages/rest/src/error-response.ts (pinned in packages/rest tests); the new pin's comment names that mapping. Same envelope depth as the house's closest prior art (engine-lookup-referential-integrity.test.ts).
  2. Populated-array control['u1'] on insert and ['u1','u2'] on update still land.
  3. null controls — still rejected with the two DISTINCT reasons exactly as on main: insert renders the required sentence, update the required_cleared sentence (both keep wire code required; 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/.yml under packages / apps / examples / scripts for field defs holding BOTH required: true AND (multiple: true OR an inherently-multi type: multiselect / checkboxes / tags), then a repo-wide grep for name: [] writes against every declared name.

Fences

Follow-up finding (filed separately, not in this diff)

Two comments justify #9625's blanket cascade refusal partly by "today [] still satisfies required in the record validator (#9476)" — engine.ts (cascade region) and engine-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 of origin/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.
  • Dispatch lead, all 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.
  • Re-derived via 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 via turbo 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/objectql patch with adr-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

…#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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see

Coarse fallback — 14 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 985a9cd2dbbad0bec9edce107f35d20791c9ac5cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2f3b48807e7856b55ab3fb78ac7a37f41a554cc6 — the merge of head cd7aa7a42c31a112c24c5545660683ffdbd448ce into base 985a9cd2dbbad0bec9edce107f35d20791c9ac5c, 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 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

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

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-elon
os-elon marked this pull request as ready for review August 18, 2026 23:42
@os-elon
os-elon added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 6a5e6adAug 19, 2026
29 checks passed
@os-elon
os-elon deleted the claude/issue-9476-required-non-empty-multi-value branch August 19, 2026 00:03
os-elon pushed a commit that referenced this pull request Aug 19, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

record-validator: [] satisfies required on a multiple: true lookup — diverges from the #9447 ruling (required means non-empty array)

2 participants

@os-elon@claude