Skip to content

Commit 2ac0616

Browse files
fix(rest, core)!: the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core (#24161)
Fixes #24131 Clause-②: yes (narrowing) ## What this changes An anonymous `POST /forms/:slug/submit` now enforces the form's own `required`. A section entry `{ field: 'email', required: true }` on a field the object does not require used to answer `201` and store the row with the field empty, while the rendered form refused the same submission. It now answers `400 VALIDATION_FAILED` naming the field, and nothing is written. The PR has **two faces**, and both are declared: - **Widening: two new functions on `@objectstack/core`'s `.` entry, from one new module (`minor`).** `isMissing(value)` and `isEmptyForRequired(def, value)`, the emptiness rule a `required` declaration is judged by, move out of `@objectstack/objectql`'s record validator into `packages/core/src/utils/required-emptiness.ts`, unchanged, typed on `@objectstack/spec/data`'s `ValueShapeFieldDef`. This follows the inherited ruling the seat applied (#23982, `6227928430`, letter A): the one rule lives in `@objectstack/core` and is exported from its `.` entry. `@objectstack/rest` does not depend on the engine at runtime (the `check:undeclared-dep-imports` LEDGER records that), and both rest and objectql already depend on core. - **Narrowing: the public submit door's accept set (`@objectstack/rest`, `patch`).** A submission that omits a form-required field answered `201` and now answers `400`. Nothing refused before is accepted now. No error code is minted. The refusal uses the object-level `VALIDATION_FAILED` envelope. ## How 1. **`packages/core/src/utils/required-emptiness.ts`** (new, exported from `src/index.ts` beside `multi-value-storage-form.ts`, the module the same two packages already share). `isMissing`: `undefined`, `null`, a string that is empty or only whitespace. `isEmptyForRequired`: that, plus `[]` where the declared value is a set (`isMultiValueField`). A structured-JSON field's `[]` is a document, not empty. 2. **`packages/objectql/src/validation/record-validator.ts`**: the private `isMissing` and `isEmptyForRequired` are deleted. All five read sites (`required` on insert, `required_cleared` on PATCH, the blank-to-`null` typed-column normalisation, `validateOne`'s early return, the value-shape check) import core's. No behaviour change, and objectql's suites prove it before and after (below). 3. **`packages/rest/src/form-routes.ts`**, submit route only: - The loop that builds the allow-list from `sections[].fields` now also collects, off the same entries, the ones declaring `required: true`. The two cannot drift. - After the payload is filtered and before `createData`, each such field is judged on what the door writes (`filteredData`) with core's `isEmptyForRequired`, typed by the object's field definition. So an object `defaultValue` or a `beforeInsert` stamp does not satisfy a field the visitor omitted, as triage's pin asks. - The refusal is thrown as `validationFailure(...)` (`@objectstack/types`). The route's catch then answers through the same `mapDataError` arm as the object-level refusal: `400`, top-level `code: 'VALIDATION_FAILED'`, `error` (the messages joined by `; `), and `fields[]` of `{ field, code: 'required', message, label }`. The message comes from the same catalog (`renderValidationMessage`, key `required`), and the label is the object field's declared label, else its name. Every empty form-required field is named in one refusal, in the form's order. - **Judged only where the form asks.** Some entries are not judged: - an entry with `hidden: true`; - an entry with a `visibleWhen`, on itself or on its section. Nothing server-side evaluates a form-view `visibleWhen` (the spec's own describe text says so). Judging such an entry unconditionally would refuse a submission whose visitor was never shown the field. - a server-managed anchor, because this door never writes one. 4. **Docs**: one sentence in `content/docs/ui/public-data-collection.mdx` §2. The card's done-condition asks the docs to say which route was taken, and the page said nothing about it. 5. **Changesets**: `@objectstack/core` `minor` (the new export), `@objectstack/rest` `patch` (the narrowing, with who it affects and the one-line fix), and `@objectstack/objectql` `patch`. objectql's published bytes change. Measured on its built `dist/` (`files: ['dist', …]`): before the move, `function isEmptyForRequired` appeared once each in `dist/index.js` and `dist/core.js` (the positive control). After the rebuild it appears 0 times, and the read sites call `(0, import_core.isEmptyForRequired)(def, value)`. ## Evidence (head `be605e655`; merge base `6dede7a51`) - **Premise, before the fix** (`origin/main` `747c8e9c4`, the real route): on a form-required text field, absent, `null`, `''` and `' '` each answered `201`, and the row was stored with `null` / `''` / `' '`. On a form-required `tags` field, absent and `[]` each answered `201`. - **Core pin** `packages/core/src/utils/required-emptiness.test.ts`, read through core's `.` entry: 26 passed. It covers absent, `null`, `''`, whitespace, `[]` on `tags` / `multiselect` / a `multiple: true` lookup (empty), and `[]` on `json` and on a single-valued lookup (not empty), each beside a present twin. - **objectql validation suites, before and after the move**: `vitest run src/validation` gave 22 files / 998 passed before and 22 files / 998 passed after, with core rebuilt in between. Full `pnpm --filter @objectstack/objectql test`: 437 files / 8236 passed. - **Rest pins** `packages/rest/src/public-form-required.test.ts` run through the real route (`RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` → `SqlDriver` sqlite in memory; only the metadata read is stubbed). 11 tests: - each of absent / `null` / `''` / whitespace on a form-required text field, and `[]` / absent on a form-required `tags` field, answers `400` naming the field, and the row count is unchanged; - two missing fields give one refusal naming both; - the CONTROL with every asked field filled answers `201` and stores the row as sent; - a field only the object requires is still refused by the engine (`Company is required`), and the form-level refusal has the same keys at both levels; - a field both levels require answers the exact body the engine answered before; - the boundary: hidden and conditional entries are not judged. - **Ablation, once, on the new refusal.** The tested route resolves from `src`, so no rebuild is needed. It ran through `scripts/ablation-replace.mjs` with an EXIT/INT/TERM trap. - Mutation leg: the `throw validationFailure(...)` line becomes `void missing;`. The anchor went 1 → 0 and the blob `e5f50db0a754` → `8107a5a036c7`. Result: **8 failed | 3 passed (11)**. As predicted, every form-only cell and the envelope test went red. The control, the hidden/conditional boundary and the both-levels case stayed green: the engine refuses that last one with the identical body, which is that pin's point. - Restore leg: the blob equals HEAD (`e5f50db0a754`), `git diff HEAD` is empty, and the marker count is 0. Full `pnpm --filter @objectstack/rest test` afterwards (at `d4e51629a`): 291 files, 6007 passed, 326 skipped. The one later commit (`be605e655`) only types the pins' engine reads, which the query-options-erasure ratchet asked for. The pin file reran at `be605e655`: 11 passed. - **Typecheck**: `pnpm --filter … typecheck` exits 0 for core, objectql and rest. `--listFilesOnly` confirms both new test files are in their package's test program. - **Lint**: `pnpm exec eslint --no-inline-config` on the six changed `.ts` files exits 0. - **Gates** at `be605e655`: the derived list (`dispatch-gates --commands`, no paths, 98 commands) ran with each exit captured before any pipe. All 98 exit 0. `dispatch-gates --ran` with the exit codes recorded reads `98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN`. - Three needed their prerequisite first, and were rerun after it rather than counted. `check-engine-split-ratio` needed history deepened (`fetch --shallow-since`). `check:skill-examples` needed `@objectstack/client-react` built. `check:dual-build-cjs-loads` needed every package's `dist/`. - One real finding, fixed: `check:query-options-erasure` reported 236 → 241 test sites, from this PR's own pins, typed in `be605e655`. - The repo-wide lint and the CI-only lanes are CI's; its check-runs are authoritative. ## Acceptance notes - **Liveness ledger (spec seat, not edited).** In `packages/spec/liveness/view.json`, row `props.form.children.sections` carries a blanket `live` verdict for the whole FormField subtree, `required` included, with no per-key row. Its server evidence names the submit route only for `allowedFields`. With this change the submit route also reads `required`, so the row's evidence can now name that read point. - **A third private copy, untouched by this card.** `packages/objectql/src/validation/rule-validator.ts:2864` keeps its own private `isMissing`, byte-identical, used by the conditional `requiredWhen` arm. The dispatch holds off objectql's other validation arms. `packages/services/service-automation/src/screen-input-contract.ts` `isPresent` is another copy, without the `[]` arm, also out of scope. Neither has a carrier. - **Boundary, new with this change.** The resolve route strips lookup, master_detail and user fields from the anonymous rendering unconditionally. So a form that declares one of them `required: true` can now never be submitted from its rendered page: the server refuses, naming a field the page never showed. No example declares one, and the declaration was already unsatisfiable from the page. Refusing it at authoring belongs to the lint/spec lane. - **The `Clause-②` spelling.** The PR body reads `Clause-②: yes (narrowing)`: the `@objectstack/core` export widens and the submit door narrows. Per package, the core changeset carries `yes (widening)`, objectql's `no`, and rest's `no (narrowing)` with the ADR-0087 disposition `not-required (no-migration-prescription)`; `check-adr-0087-registration` and `check-changeset-no-major` read them green (patch round 1, `cf5965828`). - **Label language.** The refusal names the field by the object's declared label and renders in the catalog's default locale. That is what the engine does on this route too, because the anonymous context carries no locale. --- _Generated by [Claude Code](https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 52c04b3 commit 2ac0616

10 files changed

Lines changed: 509 additions & 24 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/core": minor
3+
---
4+
5+
feat(core): export the emptiness rule a `required` declaration is judged by — `isEmptyForRequired(def, value)` and `isMissing(value)`
6+
7+
Clause-②: yes (widening)
8+
9+
- **New exports from `@objectstack/core`'s `.` entry.** `isMissing(value)` is `true` for `undefined`, `null`, and a string that is empty or only whitespace. `isEmptyForRequired(def, value)` is `isMissing`, plus an empty list `[]` on a field whose DECLARED value is a set (`@objectstack/spec/data`'s `isMultiValueField`: `tags`, `multiselect`, `checkboxes`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` field flagged `multiple: true`). `def` is a `ValueShapeFieldDef` (`{ type, multiple? }`). A structured-JSON field's `[]` is a document, not an empty value.
10+
- **Why it moved.** It was private to `@objectstack/objectql`'s record validator, where the object-level `required` check runs. `@objectstack/rest`'s anonymous public-form submit route now judges a FORM's `required` with it, and rest does not depend on the engine at runtime. So the one rule lives in the package both depend on, and the two doors call the same field missing.
11+
- **Unchanged:** the rule itself, byte for byte — the engine's object-level `required` refusal answers exactly what it did.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
refactor(objectql): the record validator reads `isMissing` / `isEmptyForRequired` from `@objectstack/core` instead of private copies
6+
7+
Clause-②: no
8+
9+
- **No behaviour change.** The object-level `required` check, the `required_cleared` PATCH check, the blank-to-`null` normalisation of typed columns and the value-shape scan judge "missing" exactly as before; the private copies of the two predicates are deleted and `@objectstack/core`'s exports, moved there unchanged, are imported at every read site.
10+
- **Why:** `@objectstack/rest`'s public-form submit route now judges a form's `required` by the same rule, and it cannot import the engine.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest)!: an anonymous public-form submit that leaves a form-required field empty answers `400 VALIDATION_FAILED` naming the field, and stores nothing
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the narrowing is a runtime REST door's accept set, POST /forms/:slug/submit: no stored metadata changes shape and nothing must be rewritten, so `objectstack migrate meta` has nothing to do; a caller that omitted a form-required field sends it -->
10+
11+
- **What changed.** `POST /forms/:slug/submit` now enforces the form's own `required`: a section entry `{ field: 'email', required: true }` is judged before the write, with the rule the engine's object-level check uses (`isEmptyForRequired` from `@objectstack/core`). A submission whose value for such a field is absent, `null`, `''`, only whitespace, or `[]` on a multi-value field is refused, and no row is created. Before, the engine judged only the fields the OBJECT requires, so a field only the form required answered `201` and was stored empty — while the rendered form refused the same submission.
12+
- **The refusal** is the object-level one's envelope on this route, key for key: `400` with top-level `code: 'VALIDATION_FAILED'`, `error` (the messages), and `fields[]` of `{ field, code: 'required', message, label }`, worded by the same validation catalog (for example `Email is required`). Every empty form-required field is named in one refusal, in the form's order.
13+
- **BREAKING — a narrowing, and who it affects.** A script or integration that posts to a public form and omits a field the form marks `required: true` now gets `400` instead of `201`. The fix is to send that field. Nothing that was refused before is accepted now.
14+
- **Judged only where the form asks.** An entry the form hides (`hidden: true`), or shows only conditionally (`visibleWhen` on the entry or on its section), is not judged — nothing server-side evaluates a form-view `visibleWhen`. A server-managed anchor (`owner_id`, `organization_id`, …) is never written by this route, so it is never judged. An object `defaultValue` or a `beforeInsert` hook does not satisfy a field the visitor omitted: the route judges what the visitor sent.

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry
4444

4545
### 2. The field whitelist *is* the security boundary
4646

47-
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.
47+
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.
4848

4949
System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
5050

‎packages/core/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ export * from './utils/temporal-storage-form.js';
157157
// because those two do not depend on each other at runtime.
158158
export * from './utils/multi-value-storage-form.js';
159159

160+
// [#24131] …and the emptiness rule a `required` declaration is judged by.
161+
// `@objectstack/objectql`'s record validator judges an object field's
162+
// `required` with it, and `@objectstack/rest`'s public-form submit door the
163+
// form's own `required` — one rule, here because rest does not depend on the
164+
// engine at runtime.
165+
export * from './utils/required-emptiness.js';
166+
160167
// [#21007] …and the refusal a scalar comparison gets on a field stored as a
161168
// JSON column: the operator set and the words. `driver-sql` refuses it on
162169
// `where`, and `@objectstack/objectql` on the per-aggregation `filter` it
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24131] The emptiness rule `required` is judged by, pinned per arm, and
5+
* read through core's `.` entry — the specifier both of its doors import
6+
* (`@objectstack/objectql`'s record validator, `@objectstack/rest`'s public-form
7+
* submit route).
8+
*
9+
* The arms are the ones the rule moved out of `record-validator.ts` with:
10+
* absent, `null`, `''`, a whitespace-only string, `[]` on a field whose
11+
* declared value is a set (empty), and `[]` on a structured-JSON field (a
12+
* document, NOT empty). Each "empty" row sits beside a present twin, so a
13+
* predicate that answered `true` for everything fails as loudly as one that
14+
* answered `false`.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { isEmptyForRequired, isMissing } from '../index.js';
19+
20+
const TEXT = { type: 'text' };
21+
const TAGS = { type: 'tags' };
22+
const MULTISELECT = { type: 'multiselect' };
23+
const MULTI_LOOKUP = { type: 'lookup', multiple: true };
24+
const SINGLE_LOOKUP = { type: 'lookup' };
25+
const JSON_FIELD = { type: 'json' };
26+
27+
describe('[#24131] isMissing — the def-free half', () => {
28+
it.each([
29+
['absent (undefined)', undefined],
30+
['null', null],
31+
["the empty string ''", ''],
32+
['a whitespace-only string', ' '],
33+
['a tab-and-newline string', '\t\n'],
34+
])('%s is missing', (_label, value) => {
35+
expect(isMissing(value)).toBe(true);
36+
});
37+
38+
it.each([
39+
['a non-blank string', 'Ada'],
40+
['a padded non-blank string', ' Ada '],
41+
['zero', 0],
42+
['false', false],
43+
['an empty list (def-free: never missing here)', []],
44+
['an empty object', {}],
45+
])('%s is present', (_label, value) => {
46+
expect(isMissing(value)).toBe(false);
47+
});
48+
});
49+
50+
describe('[#24131] isEmptyForRequired — isMissing, plus [] where the declared value is a set', () => {
51+
it.each([
52+
['absent', TEXT, undefined],
53+
['null', TEXT, null],
54+
["''", TEXT, ''],
55+
['whitespace-only', TEXT, ' '],
56+
['[] on tags (an inherently-multi type)', TAGS, []],
57+
['[] on multiselect', MULTISELECT, []],
58+
['[] on a lookup flagged multiple: true', MULTI_LOOKUP, []],
59+
['null on tags', TAGS, null],
60+
])('%s is empty', (_label, def, value) => {
61+
expect(isEmptyForRequired(def, value)).toBe(true);
62+
});
63+
64+
it.each([
65+
['a non-blank string', TEXT, 'ada@example.test'],
66+
['zero', { type: 'number' }, 0],
67+
['false', { type: 'boolean' }, false],
68+
['a one-member list on tags', TAGS, ['x']],
69+
['a one-member list on a multi lookup', MULTI_LOOKUP, ['rec_1']],
70+
// A structured-JSON field's `[]` is a legitimate document, not an emptied
71+
// set — the half of the rule a value-shape guess would get wrong.
72+
['[] on a json field (a document, not an emptied set)', JSON_FIELD, []],
73+
// A single-valued lookup's declared value is NOT a set, so its `[]` is
74+
// not judged empty here; the value-shape arm downstream refuses it.
75+
['[] on a single-valued lookup', SINGLE_LOOKUP, []],
76+
])('%s is not empty', (_label, def, value) => {
77+
expect(isEmptyForRequired(def, value)).toBe(false);
78+
});
79+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24131] THE emptiness rule a `required` declaration is judged by — one rule,
5+
* read by every door that refuses a missing required value.
6+
*
7+
* ## Two faces, one rule
8+
*
9+
* - `@objectstack/objectql`'s record validator judges an object field's
10+
* `required` with it on every insert, and a PATCH that clears a required
11+
* field (`required_cleared`).
12+
* - `@objectstack/rest`'s anonymous public-form submit door judges the FORM's
13+
* own `required` (a form-field entry's `required: true`) with it before the
14+
* write, so a field the form requires and the object does not is refused in
15+
* the same words the object-level refusal uses.
16+
*
17+
* `@objectstack/rest` does not depend on the engine at runtime (the
18+
* `check:undeclared-dep-imports` ledger records why), and a copy each is how
19+
* one value comes to be "missing" at one door and "present" at the other — the
20+
* whitespace-only string and the empty list are the two cells a hand-written
21+
* check drops. So the rule lives here, where both already depend, beside the
22+
* other value-level rules those two packages share
23+
* (`multi-value-storage-form.ts`). It moved out of `record-validator.ts`
24+
* unchanged.
25+
*
26+
* ## The rule
27+
*
28+
* - {@link isMissing}: `undefined`, `null`, and a string that is empty or only
29+
* whitespace. Def-free.
30+
* - {@link isEmptyForRequired}: everything {@link isMissing} calls missing, and
31+
* — only where the DECLARED value is a set (`@objectstack/spec/data`'s
32+
* `isMultiValueField`, ADR-0104 D1) — an empty list. The #9447 maintainer
33+
* ruling sets that half: `required` on a multi-value field means a NON-EMPTY
34+
* list, because the empty set is representable (it reads back as `[]`, never
35+
* `null`), so an explicit `[]` is an empty value exactly as `null` / `''`
36+
* are. A structured-JSON field's `[]` is a legitimate document, not an
37+
* emptied set, and is NOT empty here.
38+
*
39+
* ⛔ What a door does with "missing" is the door's: the engine skips a
40+
* runtime-owned `autonumber`, the form door judges only the entries the visitor
41+
* is asked to fill. Neither belongs in this rule, and neither is a reason for a
42+
* second copy of it.
43+
*/
44+
45+
import { isMultiValueField, type ValueShapeFieldDef } from '@objectstack/spec/data';
46+
47+
/**
48+
* Whether a value is missing: `undefined`, `null`, or a string that is empty
49+
* or only whitespace. The def-free half of {@link isEmptyForRequired}.
50+
*/
51+
export function isMissing(v: unknown): boolean {
52+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
53+
}
54+
55+
/**
56+
* Whether a value leaves a `required` field empty — {@link isMissing}, plus an
57+
* empty list on a field whose declared value is a set. Judged against the
58+
* field's DECLARATION (`type`, `multiple`), never against the value's shape.
59+
*/
60+
export function isEmptyForRequired(def: ValueShapeFieldDef, value: unknown): boolean {
61+
if (isMissing(value)) return true;
62+
return isMultiValueField(def) && Array.isArray(value) && value.length === 0;
63+
}

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ import {
105105
classifyFilterToken,
106106
} from '@objectstack/spec/data';
107107
import type { FieldErrorCode } from '@objectstack/spec/api';
108-
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand, multiValueStorageForm } from '@objectstack/core';
108+
// [#24131] `isMissing` / `isEmptyForRequired` — the emptiness rule `required`
109+
// is judged by — live in core, so the public-form submit door in
110+
// `@objectstack/rest` judges a form's `required` by the same rule.
111+
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand, multiValueStorageForm, isMissing, isEmptyForRequired } from '@objectstack/core';
109112
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
110113
import {
111114
renderValidationMessage,
@@ -301,27 +304,13 @@ interface FieldDef {
301304
picklist?: string;
302305
}
303306

304-
function isMissing(v: unknown): boolean {
305-
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
306-
}
307-
308-
/**
309-
* #9476 — the emptiness judgment the `required` check runs, def-aware where
310-
* `isMissing` is not. The #9447 maintainer ruling (2026-08-18) sets the
311-
* contract: `required` on a multi-value field means NON-EMPTY array — the
312-
* empty set is representable (it reads back as `[]`, never `null`), so an
313-
* explicit `[]` is an empty value exactly as `null` / `''` are.
314-
*
315-
* Judged only where the DECLARED value is a set (`isMultiValueField`,
316-
* ADR-0104 D1): a structured-JSON field's `[]` is a legitimate document, not
317-
* an emptied set, and every other type keeps the def-free `isMissing`
318-
* semantics. Only the two `required` read sites call this — a bare `[]` on a
319-
* non-required multi-value field still flows to the array-shape branch.
320-
*/
321-
function isEmptyForRequired(def: FieldDef, value: unknown): boolean {
322-
if (isMissing(value)) return true;
323-
return isMultiValueField(def) && Array.isArray(value) && value.length === 0;
324-
}
307+
// [#24131] `isMissing` (the def-free "missing": `undefined`, `null`, a blank
308+
// string) and `isEmptyForRequired` (#9476 — that, plus `[]` where the DECLARED
309+
// value is a set, per the #9447 ruling) moved to `@objectstack/core`
310+
// (`utils/required-emptiness.ts`, which carries their contract) unchanged, and
311+
// are imported above. Only the two `required` read sites call
312+
// `isEmptyForRequired` — a bare `[]` on a non-required multi-value field still
313+
// flows to the array-shape branch.
325314

326315
/**
327316
* How many decimal places a finite number carries (#7501).

0 commit comments

Comments
 (0)