Repository navigation
Commit 2ac0616
fix(rest, core)!: the anonymous public-form submit enforces the form's own
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>required; the emptiness rule moves to @objectstack/core (#24161)1 parent 52c04b3 commit 2ac0616
10 files changed
Lines changed: 509 additions & 24 deletions
File tree
- .changeset
- content/docs/ui
- packages
- core/src
- utils
- objectql/src/validation
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
160 | 167 | | |
161 | 168 | | |
162 | 169 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
108 | | - | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
109 | 112 | | |
110 | 113 | | |
111 | 114 | | |
| |||
301 | 304 | | |
302 | 305 | | |
303 | 306 | | |
304 | | - | |
305 | | - | |
306 | | - | |
307 | | - | |
308 | | - | |
309 | | - | |
310 | | - | |
311 | | - | |
312 | | - | |
313 | | - | |
314 | | - | |
315 | | - | |
316 | | - | |
317 | | - | |
318 | | - | |
319 | | - | |
320 | | - | |
321 | | - | |
322 | | - | |
323 | | - | |
324 | | - | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
325 | 314 | | |
326 | 315 | | |
327 | 316 | | |
| |||
0 commit comments