Skip to content

fix(rest)!: the public form submit door refuses a value outside a form row's declared options subset - #27766

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-x0hhfv
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-x0hhfv

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Fixes #27606

domain:server#4 · os-elon-musk · session_016WV4czUVLWPn8CY8QLDrjQ

Scope: the submit door only, per triage 6500544109. The renderer half (ui/apps/console/src/components/FormPage.tsx listing every option) is the domain:ui card and is not touched here.

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none (no published skill describes how the public form submit door judges a select value; objectstack-api names the door as a whitelist-filtered insert, which stays true)

维护者速读

  • 改了什么:公开表单提交口 POST /forms/SLUG/submit 现在拒收不在表单行 options 子集里的值,返回 400 VALIDATION_FAILED,fields[] 点名字段(代码 invalid_option,只列出子集内的可选值);子集内的值照常保存。
  • 为什么改:表单作者给字段只开放部分选项(如 HotCRM 工单表单只给访客 low/medium/high),读取口已只发子集,但提交口不判,访客或脚本照样能存下 critical。
  • 风险与回滚:只收窄一个匿名入口;没列 options 的行、同一字段另有不带子集的行、空值都不受影响。控制台公开表单页目前仍列出全部选项(另一张 ui 卡修),访客若选了子集外的值会看到点名字段的 400。回滚即还原 form-routes.ts 两处改动。
  • 先看:packages/rest/src/form-routes.ts 的 optionSubsets 收集段与 refused 信封里新增的子集判断循环;packages/rest/src/public-form-option-subset.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/public-form-option-subset.test.ts,预期 11/11 通过。
  • 需要你做的:无,席位落地。

What changes

  • While it reads the form's entries for the allow-list and required, the submit handler now also collects each field's offered options subset: the union of every entry that lists options. An entry that lists none, or a bare-string entry, offers the object's full set and marks the field not narrowed, whatever another entry lists.
  • Inside the existing refusal envelope (one validationFailure, the flat 400 VALIDATION_FAILED with fields[], already shared by the required and file judgements), a value outside a field's subset is refused before the write with the closed ADR-0114 catalog's invalid_option. The message is the catalog's (invalid_option, or invalid_option_value naming the element for a multi-valued field), the field is named by the object's label, and constraint.allowed lists the subset's values only. No second envelope and no new code.
  • Judged on what the door writes, with the same emptiness rule as required (isEmptyForRequired): an empty or absent value is not judged. A multi-valued value is judged element by element, the first outside element named, as the engine judges the object's set.
  • Changeset: @objectstack/rest patch, with the BREAKING note and the ADR-0087 not-required (no-migration-prescription) marker.

Deviation from the dispatch's mechanism assumption

The order guessed the refusal would join the envelope as invalid_value. Measured: the catalog has invalid_option ("not a member of the field's declared options"), and it is what the engine answers for a value outside the object's options on this same route. The row subset is a declared option set, so the door uses invalid_option and the catalog's own sentences, keeping one vocabulary for "not an offered option" on both levels.

Tests

packages/rest/src/public-form-option-subset.test.ts, through the real route (RestServer → ObjectStackProtocolImplementation → ObjectQL → SqlDriver sqlite in memory; only the metadata read is stubbed):

  • refused: a value outside the subset (critical on a [low, medium, high] row) answers 400 naming priority with invalid_option, constraint.allowed is low, medium, high, the excluded member appears nowhere in the body, the write is never attempted and no row is stored; a value that is no member of the object is refused the same way before the write; a multi-valued element outside the subset is refused naming it; with a missing required field, both are told in one envelope;
  • control: a value inside the subset saves; absent, null, '' and [] submit;
  • boundary: a row with no options keeps the object's full set; a field another row offers without a subset is not narrowed.

Acceptance notes

  • The console's public form page still offers every option of the field until the domain:ui half reads the row's subset; until then a visitor who picks an outside value gets the 400 naming the field and the allowed values.
  • An entry's options: [] is read as listing no subset (the object's full set), not as offering nothing.
  • An entry that is hidden: true or conditionally visible is still judged: the door does not evaluate visibleWhen, and a value outside every subset the form lists is never one the form offers.

Verification

Read at head 09cd48e0.

Premise on main, ablation, package and gate readings
  • Premise on main (81d00a4e), the new test before the fix: 4 failed, 7 passed. critical on the [low, medium, high] row answered 201 and stored the row; ["billing", "security"] on a [billing, outage] row answered 201. All 7 controls were already green.
  • After the fix: the new file 11/11, and the six public-form test files (option subset, required, file field, routes, stored row, submit answer) 65/65.
  • Ablation (scripts/ablation-replace.mjs, the anchor if (!subset) continue; replaced by a bare continue, 1 hit to 0, blob acba7d5b to 95a39697): 4 failed, 7 passed, the four refusal cases. Restored: blob equals HEAD (acba7d5b), git diff HEAD empty. The test imports the route from src, so no build sat between the mutation and the run.
  • pnpm --filter @objectstack/rest build: exit 0. pnpm --filter @objectstack/rest typecheck: exit 0. pnpm --filter @objectstack/rest test: exit 0, 343/343 files, 6706 passed, 326 skipped.
  • eslint on the two changed source files, --no-inline-config: exit 0. The repo-wide pnpm lint is left to CI's Lint & Repo Gates.
  • dispatch-gates --commands derived 59 families; 58 exited 0. check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET (it reads every package's dist and needs a whole-tree build), so it is NOT MEASURED locally and CI carries it. --ran with exit codes: 59 derived, 58 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN.
  • check:commit-card-trailers --range origin/main..HEAD: exit 0. check:nul-bytes: exit 0.
  • Dogfood battery ((narrowing) arm): running on this head under the verification lock; its reading lands in the os-dev-report comment on public forms: the submit door accepts a value outside a form row's declared options subset, and the renderer offers every option #27606.
  • No open PR touches form-routes.ts (the claim's read of the open PRs).

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4czUVLWPn8CY8QLDrjQ

…m row's declared options subset

A form entry may list `options` to offer a deliberate subset of a select
field's members, and `GET /forms/:slug` serves that subset. The submit door
judged keys and the form's `required` only, and the engine judges a value
against the object's options, so `POST /forms/:slug/submit` stored a member
the form never offered (a guest filing `critical` through a form offering up
to `high`).

The door now refuses such a value with 400 VALIDATION_FAILED, naming the field
with the catalog's `invalid_option` and only the subset's values, inside the
one refusal envelope the `required` and file judgements already use. A
multi-valued value is judged element by element; an empty or absent value is
not judged; a field no entry narrows keeps the object's full set.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czUVLWPn8CY8QLDrjQ
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_option (literal, a string literal in registerFormEndpoints), invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/api/plugin-endpoints.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/automation/flows.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/data-modeling/field-types.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/data-modeling/validation-rules.mdx (via invalid_value (literal, a string literal in registerFormEndpoints))
  • content/docs/protocol/kernel/error-handling.mdx (via invalid_option (literal, a string literal in registerFormEndpoints))
  • content/docs/protocol/objectql/state-machine.mdx (via invalid_option (literal, a string literal in registerFormEndpoints))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/ui/public-data-collection.mdx (via invalid_option (literal, a string literal in registerFormEndpoints), /forms/:slug (route, a path literal in a comment in registerFormEndpoints))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via invalid_option (literal, a string literal in registerFormEndpoints))
  • content/docs/releases/v17/17-6.mdx (via invalid_option (literal, a string literal in registerFormEndpoints), /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/releases/v17/17-7.mdx (via /forms/:slug (route, a path literal in a comment in registerFormEndpoints))
  • content/docs/releases/v18/18-2.mdx (via invalid_value (literal, a string literal in registerFormEndpoints), /forms/:slug (route, a path literal in a comment in registerFormEndpoints))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 58 of 210 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 — 19 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 ca64f6f9ea9e23a1c444d01faa09ea648aa32b21 → packageMentionDocs.

Which tree this was computed on

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

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ca64f6f9ea9e23a1c444d01faa09ea648aa32b21 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 09cd48e · 2026-10-29T22:59Z

…ry's options subset

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

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 4e67f5a · 2026-10-29T23:03Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4e67f5a7dff8fff7c4717f352130fe1496865921
Local-runs: none
Scope: packages/rest/src/form-routes.ts, packages/rest/src/public-form-option-subset.test.ts + packages/spec/src/ui/view.zod.ts (FormFieldSchema options, FormSelectOptionSchema), packages/spec/src/data/field.zod.ts (SelectOptionSchema), packages/spec/src/api/errors.zod.ts (FieldErrorCode), packages/spec/src/system/validation-message.ts, packages/objectql/src/validation/record-validator.ts (the engine's invalid_option producer, optionValues, buildFieldError), packages/core/src/utils/required-emptiness.ts, packages/types/src/validation-failure.ts, content/docs/api/error-catalog.mdx, scripts/check-adr-0087-registration.mjs, ui/apps/console/src/components/FormPage.tsx (consumer), HotCRM src/service/views/case.view.ts and src/service/objects/case.object.ts (consumer). Prose faces read for ② and non-contradiction only: .changeset/27606-public-form-option-subset.md, content/docs/ui/public-data-collection.mdx.

Face: narrowing (Clause-②: no (narrowing) on the PR body and the changeset). Net diff against main at this head: 4 files, +353/-9; the two non-prose paths are the floor and both are read whole. Inputs: card #27606 body and all three comments (triage 6500544109, the claim 6509299862, the dev report 6510493301 read as a claim to verify), PR #27766 body, file list and diff, the head's check-runs.

① Derived judgments

  • Accept-set change (the narrowing). POST /forms/:slug/submit now refuses, before createData, a non-empty value in a declared field whose form entries list options, when the value (or any element of an array value) is not among the union of those entries' values. Everything else the door accepted it still accepts. Right: this is the one narrowing the card asks for, and nothing is widened (allowedFields, requiredFields, the file judgement and the 201 answer are untouched; fileValued is renamed valued and reused, same predicate).
  • invalid_option is an existing ledgered code, not a new one. packages/spec/src/api/errors.zod.ts lists it in the closed FieldErrorCode enum ("not a member of the field's declared options"); content/docs/api/error-catalog.mdx lists it under "Closed sets and references"; the engine answers it on this same route for a value outside the object's options (record-validator.ts, the single select arm and the multi-value arm, with the invalid_option / invalid_option_value message keys the door now reuses). The deviation from the order's invalid_value is right: invalid_value is the catalog's "a reason no other member names", and a non-member of a declared option list is exactly what invalid_option names. No new public error code, no new message key (invalid_option and invalid_option_value exist in all four locales of BUILTIN_VALIDATION_MESSAGES), so Clause-② stays no (narrowing).
  • One refusal envelope. The new entries are pushed into the same refused array the required and file judgements fill and thrown through the same validationFailure(...), which mapDataError already maps to the flat 400 VALIDATION_FAILED with fields[]; the local code union widens to admit 'invalid_option' and an optional constraint, both keys FieldErrorSchema declares. No second envelope, no exported type changes (registerFormEndpoints and FormRouteHost signatures unchanged). Right.
  • Multi-select values. Array.isArray(value) ? value : [value], judged element by element with String(v) against String(o.value), the first outside element named through invalid_option_value: the engine's own reading (optionValues stringifies the same way). A scalar sent to a multi-valued field is judged as a one-element list, which is what normalizeMultiValueFields would have made of it downstream. Right.
  • Empty optional values. valued already drops isMissing values (absent, null, blank string); the loop re-applies isEmptyForRequired(def, value) so [] on a declared multi-valued field is not judged either. Same emptiness rule as required. Right.
  • Fields without a row subset. A bare-string entry, or an object entry with no options (or options: []), writes null for the field and null is sticky: once any entry offers the full set the field is not narrowed, whatever another entry lists; both orderings of the two entries land on null. A field no entry narrows falls through to the engine as before. Right, and the WIDE_FORM test pins the two-entry case.
  • Label vs value. The door compares stored values only. The spec's FormSelectOptionSchema is SelectOptionSchema minus default/kind: a strict object with label required and value a lowercase SystemIdentifierSchema, so a parsed row cannot carry a label-only or bare-string option; translateMetadataDocument('view', ...) rewrites option labels keyed by opt.value and never the value. The console posts values. Right.
  • Hidden / conditionally visible entries. The subset is collected off every entry, including hidden: true and visibleWhen ones, where required is collected only off unconditional ones. The asymmetry is in the permissive direction (a hidden entry's options can only ADD offered values to the union or, listing none, un-narrow the field), and a value outside every subset the form lists was never offered to anyone. Acceptable; the PR body states it.
  • Test file. Through the real route (RestServer, protocol, ObjectQL, sqlite in memory; only getMetaItems stubbed), it pins the four refusal classes (outside the subset, outside the object, multi-valued element, one envelope with required), the stored control, the four emptiness controls and the two boundaries, and asserts createData is never called and no row is stored on a refusal. installDefaultOrganization exists at packages/rest/src/default-organization.testkit.ts; packages/rest typecheck runs tsc --noEmit && pnpm check:test-typecheck. Shape is right; its 11/11 reading is the dev's claim, carried by the head's Test Core shards (in progress at this read).

② Semver level

  • Changeset: '@objectstack/rest': patch. Right by the rule: Metadata-protocol: none (no metadata key, value or type changes; a REST door enforces a key the form entry already declares and GET /forms/:slug already serves), so the level is patch with the BREAKING banner and the ADR-0087 disposition kept. Both line-initial declarations are present and match the PR body: Clause-②: no (narrowing), Metadata-protocol: none.
  • **BREAKING** banner present, with Before / Now / Unchanged / Who sees it / The remedy.
  • ADR-0087 disposition: exactly one marker, not-required (no-migration-prescription), a member of the gate's CATEGORIES. The body carries no FROM/TO rewrite, no migration heading and no two-column rewrite table ("The remedy" says what value to send, not how to rewrite a source), so the category's one mechanical refusal does not fire; Check Changeset and Lint · CI, release & engine gates are both success on this head. The honest category: no schema key moves, no export changes, nothing for migrate meta to rewrite.
  • Docs sentence (content/docs/ui/public-data-collection.mdx section 2) states the code's behaviour: refused with 400 VALIDATION_FAILED naming the field, invalid_option, the subset's values only; an entry listing none offers the full set. No contradiction with the tree. The Docs Drift Check's other eight named docs cite the invalid_option / invalid_value literals in general; the catalog page already lists invalid_option, so none contradicts the change.

③ Boundary flags

  • Dev deviation 1, invalid_option instead of invalid_value: answered in ①, right.
  • Dev deviation 2, eslint on the two changed files only: Lint · ESLint and Lint & Repo Gates are success on this head, so the repo-wide run is carried.
  • check:dual-build-cjs-loads NOT MEASURED locally: Lint & Repo Gates success carries it.
  • "An entry's options: [] is read as listing no subset": judged right. The row face is z.array(FormSelectOptionSchema).optional(); the FIELD face refuses an empty list at parse, the row face does not, and the renderer derives options from the object field when a row lists none, so "no subset" is the consistent reading.
  • "A hidden or conditionally visible entry is still judged": judged acceptable, see ①.
  • "The console's public form page still offers every option until the domain:ui half": true on main (FormPage.tsx buildSections sets options: normalizeOptions(def.options) off the OBJECT field and never reads the entry's options). Until that half lands, a visitor who picks an excluded value on such a form sees the 400 naming the field and the allowed values instead of storing a value the form never offered. Triage 6500544109 routed the renderer half to its own domain:ui card; this record does not verify that card exists, and that is the one open dependency to escalate: the domain:ui seat's card for FormPage.tsx reading the entry's options (blocked by Public form: the "Required: Name, Email, Message" banner stays after those fields are filled and the next submit fails for a different reason #27496 per triage).
  • open_questions: []; out_of_scope_findings: the renderer half, noted as above. No flag left unanswered.
  • Check-runs on 4e67f5a7 as read: every completed, non-skipped run is success (Build Core, Build Docs, Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, all Lint · legs, Type Check · consumer / debt ledger / source gates, Dogfood Regression Gate, Test UI, Live E2E UI, PR-body guards, Spec property liveness). Still in_progress: Test Core (1/8 to 8/8) and Type Check · workspace. No failure read. The verdict below is the review's; the gate verdicts are theirs.

④ Design judgment

The narrowing against the published spec on main: FormFieldSchema.options is described as letting a row "offer a deliberate subset" of the field's members, GET /forms/:slug serves the row's entries whole (safeForm filters entries by field type only), and the engine judges a write against the OBJECT's options alone. The door therefore accepted what the spec declared withheld. This change makes the spec's own refusal real at the only anonymous write door; it takes no declared capability away from any consumer, because no spec surface ever declared that a value outside a row's options could be stored through the form.

Attempts to break it, each read in the cited code:

  • Label-vs-value shapes. Strict row-option shape (label + lowercase value), so a parsed row has values; the door reads value only. Holds.
  • A row subset naming a value the field lacks. The door admits it (it is in the subset); the engine then refuses it with the same invalid_option code in the same envelope, naming the object's options. Nothing is stored that the object does not allow. Holds; the door's allowed would name a value the object lacks, which is the author's mistake surfaced, not a hole.
  • Picklist-bound and dependent fields. A field with picklist: still goes through the engine's picklist arm after the door; a row's dependsOn is a client-side cascade the door never evaluated before either, and the row's declared options list is the ceiling the renderer cascades inside. No record-type option sets exist in packages/spec to judge. Holds.
  • The console's public form page. Renders the object's full option list today; an excluded value now answers a 400 naming the field and the allowed values. A UX regression only for a form that declares a row subset, and on main's own corpus no shipped public form does: examples/app-crm and examples/app-showcase contact-us forms declare no row options, nor does HotCRM's web_to_lead. Holds, with the renderer card as the follow-through.
  • HotCRM's web-to-case form. web_to_case carries no priority row at all (the maintainer's fallback for exactly this defect, with case_sla_defaults capping a guest's critical at high), so the door's allow-list drops a posted priority before this judgement runs. Unaffected today; the fix is what lets HotCRM put { field: 'priority', options: [low, medium, high] } back once the renderer reads it. Holds.
  • Fail direction on malformed data. See should-fix 1: a row whose option entries carry no value fails closed (refuses everything on that field), never open.

Findings:

  • should-fix 1 (form-routes.ts, the optionSubsets collection): an entry whose options are non-empty but carry no value (bare strings, or objects without value) yields an EMPTY Set, which is truthy, so if (!subset) continue; does not skip it and every non-empty value is refused with allowed empty. The engine's optionValues and the console's normalizeOptions both read a bare-string option as its own value. Unreachable through a parsed view (the strict FormSelectOptionSchema refuses that shape; every stored-row seam replays the conversion chain before the strict parse), so not a must-fix; read a string entry as its value, or treat a subset that collected no values as null.
  • should-fix 2 (form-routes.ts, the refused.push for the subset): the engine's invalid_option refusal (buildFieldError) ships the offending element as value beside constraint.allowed; the door's entry ships constraint.allowed only and names the element in message alone. Two producers of one code on one route should ship the same keys; add value: String(elements[outside]) on the multi-valued branch (and the scalar on the single branch), as FieldErrorSchema.value declares.

No must-fix.

Implemented-by: claude/project-thread-x0hhfv
Reviewed-by: session_016WV4d1Np3hU7RQnA13YZcL

VERDICT: PASS

…, narrows nothing when it collected none, and ships the offending element

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

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 4245716 · 2026-10-29T23:23Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4245716d349c26b21542e5f3bc3b12502f38784b
Local-runs: none
Scope: packages/rest/src/form-routes.ts, packages/rest/src/public-form-option-subset.test.ts (the delta 4e67f5a..4245716, one commit, +48/-5, these two paths only; the prose faces are byte-identical to the head reviewed in comment 6510723571) + packages/objectql/src/validation/record-validator.ts (optionValues, buildFieldError, the single and multi-value invalid_option arms), packages/spec/src/ui/view.zod.ts (FormSelectOptionSchema), packages/spec/src/api/errors.zod.ts (FieldErrorSchema value).

Delta review on top of the PASS record for 4e67f5a7 (comment 6510723571): that record's ①–④ stand for everything the delta does not touch; this record judges the delta and re-reads the head's check-runs. The PR's file list is unchanged (4 files); the net diff against main is now +396/-9.

① Derived judgments

  • Should-fix 1 applied (bare-string option, empty subset). In the optionSubsets collection, each offered option is read as typeof o === 'string' ? o : o?.value, added when neither undefined nor null; this is the engine's optionValues reading (String(o) for a non-object, String(o.value) for an object) and the console's normalizeOptions. The refusal loop now skips !subset || subset.size === 0, so a subset that collected no values narrows nothing and the field falls through to the engine's own judgement. Accept-set effect against 4e67f5a7: a row whose options carry no readable value no longer refuses every value (that head failed closed on malformed data); a row with bare-string options now narrows to those strings instead of refusing everything. Both are inside the one narrowing already declared; neither widens past what main accepts (the engine still judges every value against the object). Right.
  • Should-fix 2 applied (value on the wire). The multi-valued arm ships value: String(elements[outside]), the offending element, beside constraint.allowed; the single arm ships none. That is exactly the engine's shape: its multi-value fail(...) passes String(v) as value, its single-select fail(...) passes undefined. The local refused element type gains value?: string; FieldErrorSchema.value is declared z.unknown().optional(). Same envelope, same validationFailure, no exported surface moved. Right.
  • Nothing else moved. The union/null logic for multiple entries, the emptiness rule, the required and file judgements, the invalid_option / invalid_option_value catalog keys, createData and the 201 answer are untouched by the delta.
  • Tests. LOOSE_FORM (served through the stubbed getMetaItems, the only way an unparsed shape reaches the door) pins: bare-string options narrow to their values (critical refused with allowed: 'low, medium', medium stored); value-less options narrow nothing (channel: 'phone' stored); the single-arm refusal has no value key; the multi-arm refusal ships value: 'security' and constraint.allowed: 'billing, outage'. expectRefused gained a slug parameter; the earlier 11 cases are unchanged. 13 cases now; their green reading is the dev's claim, carried by the head's Test Core shards.

② Semver level

Unchanged by the delta: the changeset and the docs sentence are byte-identical to 4e67f5a7 (@objectstack/rest: patch, Clause-②: no (narrowing), Metadata-protocol: none, BREAKING banner, one not-required (no-migration-prescription) marker). The delta ships no new key and no new code, and the changeset's "Unchanged" paragraph ("an entry that lists no options keeps the object's full set") is not contradicted by a value-less list also keeping it. Right.

③ Boundary flags

  • The two should-fixes of record 6510723571: both answered by the delta, as judged in ①. No new dev flag; no open_questions.
  • The renderer-half escalation (the domain:ui card for FormPage.tsx reading the entry's options) stands as recorded on 4e67f5a7; the delta does not bear on it.
  • Check-runs on 4245716d as read: completed, non-skipped, all success — Auto Label, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint · scoped · tooling self-tests, PR-body guards, Spec property liveness, Test UI, filter. Still in_progress: Build Core, Build Docs, Check Changeset, every other Lint · leg (CI release and engine gates, ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, two scoped legs), Live E2E UI, Plan Test Core shards, and all four Type Check · legs. No failure read. The gate verdicts are theirs; this verdict is the review's.

④ Design judgment

The delta moves the door's fail direction on a malformed row from closed (refuse every value) to the engine's judgement, and makes the two producers of invalid_option on this route ship the same keys. Attempts to break it: a row mixing one value-less entry with one valued entry for the same field unions to the valued set (the empty collection adds nothing and is not null), so the field is narrowed to what was readable, which is what the form publishes; a null option value is skipped rather than stringified to "null", stricter than the engine's String(o.value) and in the safe direction; a bare-string option that names a value the object lacks is admitted by the door and refused by the engine as before. The console and HotCRM readings on 4e67f5a7 are unaffected (neither declares a row subset today). Holds.

No must-fix, no should-fix.

Implemented-by: claude/project-thread-x0hhfv
Reviewed-by: session_016WV4d1Np3hU7RQnA13YZcL

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

维护者速读

席位意见:ACCEPT。

席位核对

Head-sha: 4245716d349c26b21542e5f3bc3b12502f38784b

  • Shape: the submit handler judges each value (each element on a multi-valued field) against the row's declared options subset inside the existing refused envelope (400 VALIDATION_FAILED, fields[]); new 13-case pin file; one docs sentence in content/docs/ui/public-data-collection.mdx §2; changeset patch @objectstack/rest, BREAKING banner, ADR-0087 not-required (no-migration-prescription). check-governed-merges --pr 27766: NOT governed, 405 changed lines.
  • Premise measured: on main a value outside the subset was stored (201); ablations of the subset check, the bare-string read and the value spread each turn their pin red; rest 343 files / 6708 passed; gates 89/89; dogfood 2867 passed / 0 failed at this head (dev report 6510493301 and the thread's final reading).
  • Gate check-runs on the head: 50 runs, 38 success, 12 skipped, 0 failure/pending.
  • Contract review: CONTRACT_REVIEW_TIER PASS 6510723571 on 4e67f5a7 (two should-fixes) and delta PASS 6510910557 on this head (both should-fixes applied, none left).
  • Accepted deviations: the refusal uses the existing ledgered invalid_option code (FieldErrorCode; the engine's own code for a non-member option) instead of the order's invalid_value — no new public code, so Clause-②: no (narrowing) stands. value ships on the multi-valued arm only, exactly as the engine does.
  • Out of scope: the renderer half (the console public form offers the field's full option list) is domain:ui, carried by public forms: a form row's options subset is ignored by the renderer and the submit door, an empty currency field shows 0.00, and the thank-you page cannot name the record it created #27398 per triage 6500544109.
  • Landing: under the maintainer's landing grant for this seat term (seat 4 thread, 2026-10-29 22:04Z): pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 00:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 00:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 04141a6 Oct 30, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-x0hhfv branch October 30, 2026 00:45
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

public forms: the submit door accepts a value outside a form row's declared options subset, and the renderer offers every option

1 participant