Skip to content

fix(rest, core)!: the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core - #24161

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24131-public-form-required
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24131-public-form-required

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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 ([Decision] #23217 cannot land as ruled: four of the five request-to-context doors cannot import an internal function of @objectstack/runtime. Home it in @objectstack/core (a new export), keep five copies under a parity pin, or close #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

…ctstack/core

isMissing and isEmptyForRequired move out of objectql's record validator into
packages/core/src/utils/required-emptiness.ts, unchanged, typed on
@objectstack/spec/data's ValueShapeFieldDef and exported from core's `.`
entry. The record validator imports core's at every read site; no behaviour
change. Pinned per arm in core.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
POST /forms/:slug/submit reads `required` off the same section entries its
allow-list reads and, before any write, refuses an empty form-required field
(absent, null, blank, or [] on a multi-value field) with core's
isEmptyForRequired, in the flat 400 VALIDATION_FAILED envelope the
object-level refusal answers on this route. Hidden and conditionally visible
entries are not judged. Pinned through the real route; changesets for core
(minor), objectql and rest (patch); one docs sentence.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
The row-count and read-back calls pass typed query and read options instead of
erasing them with `as any`, so the query-options-erasure ratchet holds.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 45 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 c40506aac02df70aad25588a19a629fa2efa4c62 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2514e030e701846da084c9a64e1198044998475f — the merge of head cf5965828a810054ea69caad37f17e7e6993e42d into base c40506aac02df70aad25588a19a629fa2efa4c62, 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 2514e030e701846da084c9a64e1198044998475f && git checkout 2514e030e701846da084c9a64e1198044998475f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c40506aac02df70aad25588a19a629fa2efa4c62 cf5965828a810054ea69caad37f17e7e6993e42d && git checkout -B drift-repro c40506aac02df70aad25588a19a629fa2efa4c62 && git merge --no-ff cf5965828a810054ea69caad37f17e7e6993e42d

node scripts/docs-audit/affected-docs.mjs --json c40506aac02df70aad25588a19a629fa2efa4c62

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(rest, core): the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core fix(rest, core)!: the anonymous public-form submit enforces the form's own required; the emptiness rule moves to @objectstack/core Oct 17, 2026
… ADR-0087 disposition

core declares the widening (`yes (widening)`), objectql's pure refactor
declares none (`no`), and rest declares its narrowing (`no (narrowing)`,
summary `fix(rest)!:`) with one ADR-0087 disposition,
`not-required (no-migration-prescription)`: the narrowing is a runtime REST
door's accept set and no stored metadata changes shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cf5965828a810054ea69caad37f17e7e6993e42d
Local-runs: none
Scope: packages/core/src/index.ts, packages/core/src/utils/required-emptiness.ts, packages/core/src/utils/required-emptiness.test.ts, packages/objectql/src/validation/record-validator.ts, packages/rest/src/form-routes.ts, packages/rest/src/public-form-required.test.ts + packages/spec/src/data/field-value.zod.ts (isMultiValueField, ValueShapeFieldDef :361-375), packages/types/src/validation-failure.ts (validationFailure :76), packages/types/src/data-error-classification.ts (the flat VALIDATION_FAILED arm :1183-1192, armSentence :310), packages/rest/src/error-response.ts (mapDataError), packages/spec/src/system/validation-message.ts (renderValidationMessage :358, catalog required :99), packages/spec/src/ui/view.zod.ts (form entry required / hidden :3439-3440, entry visibleWhen :3552, section visibleWhen :3837), packages/rest/src/rest-server.ts (readFormObjectDefinitions), packages/objectql/src/validation/rule-validator.ts:2864, packages/spec/liveness/view.json (the sections row), the three package.json dependency edges and . entries of core / objectql / rest, .changeset/config.json + .changeset/pre.json, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, scripts/check-undeclared-dep-imports.mjs (LEDGER), the WHICH LEVEL ruling in .github/workflows/pr-automation.yml :754. The three .changeset/24131-*.md are read for ② only; content/docs/ui/public-data-collection.mdx is a prose face and is the dispatching seat's ACCEPT, not judged here. No non-prose path of the diff is left to the lane seat.

Inputs: card #24131 (body and all six comments, rulings 6229368836 / 6230121735 / 6230321666 and the three os-dev-reports included), PR #24161 (body, 10-file list, net diff of 6dede7a51...cf5965828, 4 commits, +509/−24), and the 47 check-runs on the head. Read-only: no checkout, build, test or gate run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named right or wrong:

  1. @objectstack/core . entry WIDENS by two exports — RIGHT. packages/core/src/index.ts adds export * from './utils/required-emptiness.js'; the new module exports isMissing(value) and isEmptyForRequired(def, value), typed on @objectstack/spec/data's ValueShapeFieldDef ({ type: string; multiple?: boolean; options? }). Both bodies are byte-identical to the private copies deleted from record-validator.ts (v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); then isMultiValueField(def) && Array.isArray(value) && value.length === 0). core already depends on @objectstack/spec at runtime and isMultiValueField is reachable through the addressable ./data export; core's . entry maps to dist/index.*, so the two symbols are published. No api-surface baseline covers core, so nothing owes a regeneration. The core pin reads both through ../index.js, the . entry both consumers import.

  2. @objectstack/objectql: NO public-surface change; published bytes change — RIGHT. The record validator's five read sites at the head (:760 blank-to-null normalisation, :1026 the insert required arm, :1029 the early return, :1745 the value-shape scan, :2007 the PATCH required_cleared arm) now call the imports from @objectstack/core; the private copies are gone and nothing else in the file defines or calls them. objectql's own FieldDef (type: string, multiple?: boolean) satisfies ValueShapeFieldDef structurally, so the call sites are unchanged in meaning. The autonumber skip stays at the engine's arm, where the core module's docblock says it belongs. Nothing exported by objectql is renamed, retired or re-typed.

  3. POST /forms/:slug/submit NARROWS its accept set — RIGHT, and the narrowing is exactly the card's route (a). On the head, the allow-list loop (form-routes.ts :437-455) collects, off the same sections[].fields entries, every object entry with required === true that is not hidden: true, carries no visibleWhen, and sits in a section with no visibleWhen (string entries carry no required, so they are never judged). After the allow-list filter and the anchor / prototype-key strip, and before createData (:556-589), each such field minus the server-managed anchors and the three prototype keys is judged on filteredData — what the door writes — with isEmptyForRequired(objectFieldDef, value) (the object definition read through readFormObjectDefinitions, whose fields is a map keyed by name, the same shape the resolve route reads at :369). An empty value throws validationFailure(messages joined by '; ', entries), and the route's catch maps it through mapDataError(error) to the flat arm at data-error-classification.ts:1183: 400, top-level code: 'VALIDATION_FAILED', error = the thrown message, fields[] of { field, code: 'required', message, label }, and no object key, because the catch passes none — the same arm, the same keys and the same absence the engine's object-level refusal gets on this route. No error code is minted. Nothing refused before is accepted now: the new block only throws, and the allow-list, the [#3022] anchor strip, the [#6920] zero-sections refusal and the createData call are untouched. The refusal happens before any write, so no row is stored; the pins assert the row count.

    • The judged set is narrower than "every form-required entry" — RIGHT as a boundary, declared on every prose face. hidden: true entries, entries with a visibleWhen, and entries in a section with a visibleWhen are not judged. The spec's own describe text on both keys (view.zod.ts :3552 and :3837) says nothing server-side evaluates a form-view visibleWhen, so the server cannot know whether the visitor was shown the field; judging unconditionally would refuse what the visitor never saw. The residual is fail-OPEN, and it should be read as such: a required: true entry gated by a predicate stays unenforced on this door exactly as before, including the current_user predicates that fault open on the public route and show the field to everyone. That is the lenient direction — the door refuses nothing it did not declare — and the PR body, the rest changeset and the docs sentence all state it.
    • Message and label chain — RIGHT, with one stated residual. The route renders through renderValidationMessage({ messageKey: 'required', label, field, params: {} }), the catalog entry '{{label}} is required', and takes the label as def.label.trim() else the field name — the same tail resolveFieldLabel (:432-433) ends in. The one step the route omits is the engine's i18n translate lookup, which runs only when the execution context carries translate, objectName AND locale; the anonymous submit context (publicFormGrant, permissions, anonymous) carries no locale, so on this route the engine renders the default locale too, and the pins assert both levels answer the same words. Residual: a context that one day carries a locale would localise the engine's refusal and not the route's — noted in ③.
    • The object-definition fallback — acceptable. When the object definition or its field type is unreadable the route judges with isMissing alone, dropping the [] arm. That state is degenerate (an insert on an object with no definition fails downstream anyway), and the fallback errs toward accepting, never toward refusing.
    • Cost, not contract: a submit with at least one judged field now performs one getMetaItems({ type: 'object' }) read before the write, the same read the resolve route already performs per request.
    • A field both levels require is refused by the route first, with the exact body the engine answered before (pinned verbatim against the pre-fix measurement); a field only the object requires is still refused by the engine, unchanged. No visible change for either cell.
  4. The pins cover the narrowing's whole accept-set edge. public-form-required.test.ts drives the real route through RestServer → protocol → ObjectQL → SqlDriver sqlite with only getMetaItems stubbed: absent / null / '' / whitespace on a form-required text field, [] / absent on a form-required tags field, two missing fields named together in form order, the 201 control that stores the row as sent, the engine's unchanged object-level refusal with key-for-key envelope equality, the both-levels verbatim body, and the hidden / conditional-entry / conditional-section boundary at 201. The core pin covers each arm of the rule beside a present twin, including [] on json (not empty) and on a single-valued lookup (not empty). The dev reports one ablation on the throw line (8 of 11 red, the control and boundary and both-levels cells green, as predicted); that is the dev's report, not a verdict of this record.

  5. Check-runs on the head (47): 34 success, 7 skipped, 6 in_progress. Every derived gate family that answers a contract question is green: Check Changeset (×2), Lint · ESLint, Lint · PM dispatch-gates, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, Spec property liveness, Governed Surface Queue Guard, Build Core, the card/branch claim guards. Pending at read time: Test Core (1/6) through Test Core (6/6) — the unit-suite shards; not waited on. The landing precondition "every check green" is the owning seat's to confirm before the queue; this record's verdict is on the contract. No governed path is in the file list, so no Tier H word is owed.

② Semver level

Clause-②: yes (narrowing) — the PR body's line-initial declaration, the closed set's spelling for a diff that widens one surface (core's . entry) and narrows another (the submit door), and both facts are true of this diff.

  • @objectstack/core minor, changeset Clause-②: yes (widening) — RIGHT. Two new exports from a published . entry is the additive widening the WHICH LEVEL ruling grades at least minor.
  • @objectstack/objectql patch, changeset Clause-②: no — RIGHT. No public-surface change; the published dist bytes change (the dev measured function isEmptyForRequired 1→0 in dist/index.js and dist/core.js), so patch, never none.
  • @objectstack/rest patch, changeset Clause-②: no (narrowing), summary fix(rest)!:, a visible **BREAKING — bullet, and exactly one adr-0087: not-required (no-migration-prescription) … marker comment — RIGHT. The marker is in the gate's grammar and the category is one of its CATEGORIES; the body carries no FROM → TO rewrite, arrow or table (the one sentence "the fix is to send that field" is a caller's payload, not a consumer-code or metadata rewrite), so the no-migration-prescription precondition holds statement-against-statement. On the level: rest's export surface is unchanged, and the WHICH LEVEL ruling (pr-automation.yml :754) says a fix( that changes no public surface stays patch and that during the launch window breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level — the rest changeset carries both carriers. check-changeset-no-major's level axis on the body's yes (narrowing) reaches discharged (core's minor accounts for the declared axis; rest and objectql at patch are a reading set aside, printed, exit 0), and Check Changeset is green on the head. All three packages sit in the one fixed group in pre mode tag next, so the release version is lockstep whatever rest's own line says. no (narrowing) is the truthful per-package answer (rest widens nothing) and carries the arm the ADR-0087 gate reads; no (widening) appears nowhere.
  • skip-changeset does not apply: three released packages publish.

③ Boundary flags

Every dev flag (reports 6230265113, 6231149249, 6231292769 and the PR's Acceptance notes) and every open_questions entry, answered or escalated:

  1. Judged set narrower than every form-required entry (hidden / visibleWhen / anchors) — ANSWERED in ①.3: right, fail-open, declared on all three prose faces.
  2. Placement of the one emptiness predicate (report 1's open_questions, options A–D) — ANSWERED by the seat's inherited ruling 6230321666 (core, as [Decision] #23217 cannot land as ruled: four of the five request-to-context doors cannot import an internal function of @objectstack/runtime. Home it in @objectstack/core (a new export), keep five copies under a parity pin, or close #23982's 6227928430), and the diff executes it: no packages/spec path moves, rest imports core (a declared runtime dependency, so no check-undeclared-dep-imports LEDGER row is owed; the LEDGER's only rest row stays the rest-server.ts optional engine probe), and objectql deletes its copies.
  3. Clause-② spelling (report 2's open_questions, A keep yes / B re-declare yes (narrowing)) — ANSWERED: B was taken in patch round 1; the PR body reads yes (narrowing) line-initial, the per-package arms are as judged in ②, and the Acceptance-notes bullet the dev called stale now reads the post-patch state (checked against the body as served).
  4. Rest changeset at no (narrowing) rather than yes (narrowing); the added ! and BREAKING bullet — ANSWERED in ②: right, and the banner is the ruling's named carrier.
  5. Label language / locale — ANSWERED in ①.3 with the residual: the route's refusal never takes the engine's i18n translate step; equal on this route because the anonymous context carries no locale. If a later change gives the anonymous context a locale, the two doors' wording diverges — the seat that does so owes a pin.
  6. Liveness ledger packages/spec/liveness/view.json, row sections — ESCALATED to the spec seat (dispatch hold-off): the row's blanket live verdict over the FormField subtree names the submit route only for allowedFields ("submit builds allowedFields from them"); with this head the route also reads each entry's required, so the evidence owes that read point. Spec property liveness is green on the head, so nothing reds; a follow-up on the card's spec half, carrier to be set by the dispatching seat.
  7. rule-validator.ts:2864 keeps a byte-identical private isMissing (confirmed at the head) and screen-input-contract.ts isPresent — ESCALATED as drift candidates, no carrier; both outside this card's hold-offs; neither changes any accept set here.
  8. New boundary: a form entry declaring a lookup / master_detail / user field required: true — ESCALATED to the lint/spec lane. The resolve route strips those types from the anonymous rendering unconditionally (form-routes.ts :369-375, ruling E on security(plugin-security): a non-system caller who resolves no permission set is admitted to every object and read with no row scope; an empty set list grants by absence instead of answering the deny baseline #21079), so such an entry can never be filled from the page, and the submit now refuses it by name rather than storing the row without it. Refusing loudly is the right direction for a declared constraint (AGENTS.md's warn-vs-error rule; silently dropping a declared required is the trap this card is about); the authoring-time refusal of such an entry on an anonymous form is a lint rule, not this route's. The dev read no producer in the tree declaring one; no carrier yet — the dispatching seat files it.
  9. Docs sentence added though the page was silent — a prose face, the dispatching seat's ACCEPT; no objection from this face: the sentence states the judged set and the exemptions exactly as the code does.
  10. origin/main moved 6dede7a51 → c40506aac during the run; branch not re-merged — the 47 check-runs are on the PR's merge ref against current main, which is the verdict that counts; none of the four intervening commits is in core / objectql / rest per the dev's gate re-derivation, and the type-check and lint families are green on the head.
  11. Pending CI — Test Core (1/6)–(6/6) in_progress at read time (①.5). Not waited on; the owning seat confirms green before the queue.

Implemented-by: claude/issue-24131-public-form-required
Reviewed-by: session_01BVm1pAXVXQ3PjC42j1kPFf

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · domain:cli seat 1 · os-sales · session session_01BVm1pAXVXQ3PjC42j1kPFf · 2026-10-17T07:52Z

Head cf5965828. This is the seat's review of record for #24131 (claim 6230121735, re-declared 6230321666). The contract faces are judged by the at-tier record 6231404578 on this head (Served-tier: CONTRACT_REVIEW_TIER, PASS, an isolated read-only agent fed the card, the PR and the check-runs only). This record covers what that one leaves to the seat: the form, the prose faces, the seat's own reading of the diff, and the landing.

  • Form: draft → main; title fix(rest, core)!: …. The first line is Fixes #24131, the body's only closing keyword (scanned). Clause-②: yes (narrowing) is line-initial and matches the claim's yes, with the arm the two-faced diff owes.
  • The ruling executed as ruled: the required-emptiness predicate lives in @objectstack/core (utils/required-emptiness.ts, exported from .), as the inherited ruling 6227928430 homes a shared function. objectql's private copies are gone, rest imports core (a declared runtime dependency), and no packages/spec path moves (AGENTS.md Prime Directive 2).
  • The seat's own read of the code:
    • form-routes.ts judges required off the same sections[].fields entries the allow-list reads, on what the route writes (filteredData, so a default or hook stamp cannot satisfy an omitted field), before the write.
    • It throws validationFailure(...), so the catch's mapDataError answers the flat 400 VALIDATION_FAILED the object-level refusal answers on this route.
    • The predicate bodies in required-emptiness.ts are byte-identical to the ones record-validator.ts deletes.
  • Prose faces, sentence by sentence against the diff:
    • The rest changeset: "absent, null, '', only whitespace, or [] on a multi-value field is refused, and no row is created" matches the predicate arms, and the pins cover each arm with the row count unchanged. ✓ "An entry the form hides … or shows only conditionally … is not judged" matches the hidden / visibleWhen / section-visibleWhen conditions in the loop. ✓ "A server-managed anchor … is never judged" matches the PUBLIC_FORM_SERVER_MANAGED_FIELDS filter. ✓ "An object defaultValue or a beforeInsert hook does not satisfy a field the visitor omitted" holds because the route judges filteredData. ✓
    • The core changeset: "isEmptyForRequired(def, value) is isMissing, plus an empty list [] on a field whose DECLARED value is a set" matches the code. ✓
    • The objectql changeset: "No behaviour change" holds because the bodies are identical and objectql's validation suites read 998 passed before and after (report). ✓
    • content/docs/ui/public-data-collection.mdx: the one added sentence states the judged set and the exemptions exactly as the loop does. ✓
  • The review's escalations, routed:
    • Item 6, the view.json sections liveness row's evidence: a pointer to the domain:spec seat, posted on its seat post (one comment carries it; no card).
    • Item 7, the private emptiness copies in rule-validator.ts:2864 and screen-input-contract.ts: Acceptance notes, no carrier. No reach was measured, so they are below the filing gate.
    • Item 8, a form-required lookup / master_detail / user entry on an anonymous form: Acceptance notes, no carrier. No producer in the tree and no door reading, so it is below the filing gate; its remedy is a new lint rule, which the four-axis framework defaults to "no" without a maintainer's naming. The route's loud refusal is the right direction meanwhile.
  • Landing: not governed (check-governed-merges --pr 24161: 539 changed lines ≤ 3000). At this record, Lint & Repo Gates and TypeScript Type Check are success on the head, and three Test Core shards are still running. With the review of record PASS on this head, the PR goes ready with auto-merge armed once every check on the head is green.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 07:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 07:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 2ac0616 Oct 17, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24131-public-form-required branch October 17, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(forms): a public form's field-level required is not enforced on the anonymous submit; a POST without the field answers 201 with null

2 participants