Repository navigation
fix(rest)!: the public form submit door refuses a value outside a form row's declared options subset - #27766
Conversation
…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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czUVLWPn8CY8QLDrjQ
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
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
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 4e67f5a · 2026-10-29T23:03Z |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract reviewServed-tier: Face: narrowing ( ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on Attempts to break it, each read in the cited code:
Findings:
No must-fix. Implemented-by: 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
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract-review-claim: session_016WV4d1Np3hU7RQnA13YZcL · head 4245716 · 2026-10-29T23:23Z |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL Contract reviewServed-tier: Delta review on top of the PASS record for ① Derived judgments
② Semver levelUnchanged by the delta: the changeset and the docs sentence are byte-identical to ③ Boundary flags
④ Design judgmentThe 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 No must-fix, no should-fix. Implemented-by: VERDICT: PASS |
|
domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL 维护者速读
席位意见:ACCEPT。 席位核对Head-sha:
|
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.tsxlisting every option) is thedomain:uicard 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-apinames the door as a whitelist-filtered insert, which stays true)维护者速读
POST /forms/SLUG/submit现在拒收不在表单行options子集里的值,返回 400VALIDATION_FAILED,fields[]点名字段(代码invalid_option,只列出子集内的可选值);子集内的值照常保存。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
required, the submit handler now also collects each field's offeredoptionssubset: the union of every entry that listsoptions. 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.validationFailure, the flat400 VALIDATION_FAILEDwithfields[], already shared by therequiredand file judgements), a value outside a field's subset is refused before the write with the closed ADR-0114 catalog'sinvalid_option. The message is the catalog's (invalid_option, orinvalid_option_valuenaming the element for a multi-valued field), the field is named by the object's label, andconstraint.allowedlists the subset's values only. No second envelope and no new code.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.@objectstack/restpatch, with the BREAKING note and the ADR-0087not-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 hasinvalid_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 usesinvalid_optionand 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→SqlDriversqlite in memory; only the metadata read is stubbed):criticalon a[low, medium, high]row) answers 400 namingprioritywithinvalid_option,constraint.allowedislow, 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;null,''and[]submit;optionskeeps the object's full set; a field another row offers without a subset is not narrowed.Acceptance notes
domain:uihalf reads the row's subset; until then a visitor who picks an outside value gets the 400 naming the field and the allowed values.options: []is read as listing no subset (the object's full set), not as offering nothing.hidden: trueor conditionally visible is still judged: the door does not evaluatevisibleWhen, 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
main(81d00a4e), the new test before the fix: 4 failed, 7 passed.criticalon the[low, medium, high]row answered201and stored the row;["billing", "security"]on a[billing, outage]row answered201. All 7 controls were already green.scripts/ablation-replace.mjs, the anchorif (!subset) continue;replaced by a barecontinue, 1 hit to 0, blobacba7d5bto95a39697): 4 failed, 7 passed, the four refusal cases. Restored: blob equals HEAD (acba7d5b),git diff HEADempty. The test imports the route fromsrc, 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.eslinton the two changed source files,--no-inline-config: exit 0. The repo-widepnpm lintis left to CI'sLint & Repo Gates.dispatch-gates --commandsderived 59 families; 58 exited 0.check:dual-build-cjs-loadsexited 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.--ranwith 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.(narrowing)arm): running on this head under the verification lock; its reading lands in theos-dev-reportcomment on public forms: the submit door accepts a value outside a form row's declaredoptionssubset, and the renderer offers every option #27606.form-routes.ts(the claim's read of the open PRs).🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4czUVLWPn8CY8QLDrjQ