fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled - #19906
Conversation
…-value bound The form field's `options` describe states the rule for a metadata-form row whose key is a spec enum: omit `options`, the control derives the members from the served JSON Schema, and their meanings go in `helpText`. `defineForm`'s module-load refusal of an unspellable inline option value keeps the system-identifier grammar message and appends that remedy. The value bound and every schema shape are unchanged. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
… add changeset Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Generator output of `check:generated --fix` (check:docs was the one stale artifact); not hand-edited. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 1478d53a228516e00841140cd9ba506ecc6fe8eb && git checkout 1478d53a228516e00841140cd9ba506ecc6fe8eb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b 76a053e9d0f3c6dbe6efc3fac683713ff66b8ba6 && git checkout -B drift-repro e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b && git merge --no-ff 76a053e9d0f3c6dbe6efc3fac683713ff66b8ba6
node scripts/docs-audit/affected-docs.mjs --json e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b
|
|
CI red on head
Generated by Claude Code |
…um-options-derive-declared
… members that cannot be spelled An enum-typed metadata-form row may carry an inline options list, for human labels or a deliberate subset. Only a row whose members cannot be spelled as option values omits options and derives them from the served JSON Schema, with meanings in helpText. The describe on FormFieldSchema.options and the remedy sentence defineForm appends to an option-value grammar refusal now state exactly that. What is refused and accepted is unchanged. Tests: each case carries a firing and a dark control on real spec enums (object.managedBy refused with options and green without; a labelled object.sharingModel list and the field.deleteBehavior subset green, and refused with one member mis-spelled). The old blanket-rule pins are reversed into assertions of the narrowed wording. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
… describe Generator output only (pnpm --filter @objectstack/spec gen:docs after a spec build): the two FormField options rows. Against the merged main tip the page differs in exactly those two rows. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
Wrong — the thrown object is no longer an
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Required fixes
Isolated at-tier reviewer adopted by the director seat, summon #29, on the maintainer's instruction 「执行契约复审」 · fed only the card(s), the governing rulings, the PR body, the dev's flags and the code Generated by Claude Code |
The refusal was rebuilt with `new z.ZodError(...)`, which in zod v4 classic
is a trait object with no `Error` parent and no `stack`: an uncaught
module-load throw printed `ZodError { name, message: [Getter/Setter] }` and
hid the issues and the remedy. It is now a `z.ZodRealError` built from the
remedied issue copies, with its trace captured at the `defineForm` call
(zod builds every ZodRealError with `Error.stackTraceLimit = 0` and captures
a trace only in `parse`, so the parse's own `safeParse` error carries no
frame either).
The refusal pin now asserts an `Error`, a string `stack`, and a frame in the
calling module; a new case reads the printed wall (the stack's head) for the
grammar message and the remedy.
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsThe blocking defect is fixed — No harmful mutation. The pin now discriminates the class — Everything the prior review found right is still right on the full diff No new defect in the delta. The delta is 13 lines in ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Isolated at-tier reviewer adopted by the director seat, summon #29, on the maintainer's instruction 「执行契约复审」 · fed only the card(s), the governing rulings, the PR body, the dev's flags and the code Generated by Claude Code |
Fixes #19678
Fixes #19907
Clause-②: no
Executes ruling comment
5805845085on #19907 (batch #218 item 3, letter 乙, maintainer 「其他同意」). It narrows item 1 of ruling5793380467on #19678 (batch #217 item 5, letter 不动 + 声明), which the first round of this PR executed to the letter:From ruling
5793380467, the parts 乙 does not narrow still hold:FormSelectOptionSchema.valuekeeps the system-identifier bound, andnewTabvsnew-tabstays a recorded boundary, untouched here. No value bound, no schema shape, no key and no export moves.What changed
FormFieldSchema.options(packages/spec/src/ui/view.zod.ts, theFormFieldBaseSchemarow) keeps its per-optiondefaultsentence and now adds: On a metadata form (schema-bound, built bydefineForm), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An optionvalueis a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omitsoptions: the control derives the members from the served JSON Schema, and their meanings go inhelpText. The TSDoc above the row says the same thing and names both rulings.defineFormcallsFormViewSchema.safeParse. When the parse fails, it throws aZodRealErrorbuilt from the parse's own issues. That is the classFormViewSchema.parsethrew before this PR: anErrorwhosenameisZodError. Its stack is captured at thedefineFormcall, so an uncaught module-load throw prints the issues, the remedy and the author's call site (round 3, below). Only one thing changes in the issues: a grammar refusal (invalid_formatortoo_small) at an inline option'svalue(path endingoptions.INDEX.value, also when nested inside the field-row union'serrors) keeps its message and gets this sentence after it: An enum member carrying a hyphen, a capital or a single character cannot be a form optionvalue, which is a lowercase system identifier. When this row edits a spec enum whose members cannot be spelled as option values, omitoptions: the control derives the members from the served JSON Schema, and their meanings go inhelpText. No issue is added, removed or re-coded.content/docs/references/ui/view.mdx, regenerated bypnpm --filter @objectstack/spec gen:docsafter a spec build. Two table rows changed (theoptionsrow of the two FormField tables).check:generatedthen reported all 15 artifacts up to date..changeset/19678-form-option-enum-derive-remedy.md,@objectstack/spec: patch, rewritten to state ruling 乙's rule.Round 2 (ruling 乙): what moved from the first round
options. It now permits an inline list on an enum-typed row and scopes the derive path to a row whose members cannot be spelled.options. It now conditions the same derive path on members that cannot be spelled as option values.ebd7fc2fa8.origin/mainatc8399867b8(merge commit61ff3aebe6, throughscripts/pm/os-regen-merge.sh). The wording commit is182ed4c154and the regeneration commit is74ea5dbba3.Round 3 (at-tier record
5818584341: FAIL): the refusal is anErrorwith a stack againWhat the record found. Round 2 threw
new z.ZodError(…). In zod v4 classic (zod@4.6.1here) that constructor has noErrorparent, so the thrown object was not anErrorand had nostack. An uncaught module-load throw printed onlyZodError { name: 'ZodError', message: [Getter/Setter] }. That hid the issues and the remedy, the very wall both rulings require.refusal()asserted onlytoBeInstanceOf(z.ZodError), and both shapes pass that.The fix (
76a053e9d0).defineFormnow throwsnew z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues))and captures its trace withz.core.util.captureStackTrace(refusal, defineForm).ZodRealErroris the classFormViewSchema.parsethrew before this PR: itsnameisZodError, it is anError, and it passesinstanceof z.ZodError. The walker is unchanged. It returns copies, it grows onlyinvalid_formatandtoo_smallat a…options.INDEX.valuepath, it still walksinvalid_union, and an unrelated refusal is still answered without the remedy. The verdict did not move.Why this route, and not either spelling in the record as written. Both were measured on
zod@4.6.1, each thrown uncaught from a scratch form module that parses with the sourceFormViewSchema(run bytsx).ZodRealErrorwithError.stackTraceLimit = 0(newErrorinzod/v4/core/core.js). It captures a trace only insideparse(util.captureStackTrace(e, callee)), andsafeParsenever does. Sothrow parsed.errorand a barethrow new z.ZodRealError(…)both print the issues as[ZodError: …], with 0atframes and no source line. Read directly innode:new z.ZodError([])is not anErrorand itsstackisundefined,new z.ZodRealError([])and asafeParseerror areErrors whose stacks hold 0 frames, and the errorparsethrows holds 8. Thatstackis still a string, so the record's two assertions pass on both spellings. The fix captures the trace the way zod's ownparsedoes. The callee isdefineForm, so the first frame is the author'sdefineForm(…)call.finalizeIssuebuilds each issue fresh andlazySchemacaches the schema, never a result. A mutation of the first parse's issues showed up 0 times in the second parse. The hazard is order. zod 4.6.1 computes an error'smessageon its first read and caches it (_zod.message), and V8 formats the stack header on the first read of.stack. So a message grown in place reaches the printout only if nothing read.messageor.stackbefore the mutation. Measured onFormViewSchema.parse's own error, which has its frames. Grown with no earlier read, the remedy is in the issues, themessageand thestackonce each, and the uncaught printout carries it. After one earlier read of.message, the issues still carry it once, but themessage, thestackand the printout carry it 0 times. An error built from issues that already carry the remedy does not depend on that order.The wall, proved with a real uncaught throw. A scratch form module, shaped like
packages/spec/src/**/*.form.ts, importsdefineFormfrom the builtpackages/spec/dist/ui/index.mjsand calls it at module scope with{ field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }. A second module imports it, and nothing catches. Both ran undernode22.22.2, and stderr was captured:76a053e9d0,distbuilt)new z.ZodError(…)line,distrebuiltZodError: [followed by the issue list as JSONZodError { name: 'ZodError', message: [Getter/Setter] }and nothing elsesections.0.fields.0, thenoptions.0.valuein the union's branch)System identifier must be lowercase…)options, the members come from the served JSON Schema)cannot be spelled as option values)action-behavior.form.mjs:5:35, and node's caret points atdefineForm({in that moduleFor the negative control,
view.zod.tswas byte-identical to round 2's blobd6471f538d06, andablation-dist-preflightfound the old line in 11 built files. After the restore thedistwas rebuilt. The old line is absent from all 216 built files, the tree is clean, and the fix's wall reads the same as before (stderr sha2567d118336d597both times).The pin. On every refusal,
refusal()now assertstoBeInstanceOf(Error)and a stringstack, the record's two. It also asserts that the stack names this test file, the module that calleddefineForm. The third assertion is the one that tells a trace-lessZodRealErrorapart. A new case reads the wall itself: the head of the stack (ZodError:and the message) carries today's grammar message, read live off the object face and JSON-escaped, and the derive path with its scope.Ablation, round 3. One-shot, at
76a053e9d0, throughscripts/ablation-replace.mjsunder the verify lock, one leg at a time. The test imports./view.zodas source, so nodistis in its path.throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues));e2feed0106e6→d6471f538d06(round 2's blob, byte for byte)Tests 19 failed | 14 passed (33), every one atexpect(thrown).toBeInstanceOf(Error)ZodRealErrorwith no frame, the shape both spellings in the record givee2feed0106e6→11a3b9cfae81Tests 19 failed | 14 passed (33), every one at the stack names no frame in the module that called defineForm. The record's two assertions passed on this shapeThe 19 red cases are the ones that go through
refusal(). The 14 green ones build a form, parse a schema or read the describe, and never reachrefusal(). Both legs were restored: after each, the blob wase2feed0106e6, equal to HEAD,git diff HEADwas empty, andgit status --porcelainread 0 lines.The changeset is not reworded. Its sentence "
defineFormstill throws aZodErrorat module load with the same issues and codes" is literally true at this head. The thrown object is aZodRealError, the classFormViewSchema.parsethrew before this PR. Its issues are the parse's own, copied, with the same codes, and only the matching messages grow.No base merge.
origin/mainmoved 23 commits past the round-2 merge basec8399867b8, toe8f163fc3a. None of them touches this PR's four paths,identifiers.zod.tsorfield.zod.ts(git diff --name-only: 0 hits). Derived on a probe tree ate8f163fc3awith this PR's four files, the gate list is the same 107 commands as in this worktree (the two sorted lists do not differ). No generated artifact moved:check:generatedreports all 15 generated artifacts up to date at76a053e9d0, andview.mdxis unchanged from round 2, so nothing was regenerated.Where the refusal lives (found by content), and why the remedy is attached at
defineFormSystemIdentifierSchema's regex message, declared inpackages/spec/src/shared/identifiers.zod.ts(lines 104 and 107 on the first round's base). It reaches the form face throughSelectOptionSchema.value(data/field.zod.ts).FormSelectOptionSchemareuses that value by reference, and theproperty schemas are shared BY REFERENCEpin inform-select-option.test.tsholds it there.defineForm(ui/view.zod.ts). On the base it threw throughFormViewSchema.parse; since the first round it runssafeParseand throws the refusal itself. All 17packages/spec/src/**/*.form.tsmodules call it at module scope.Field.select.options) and three object-storage names. For those, "omitoptions, derive from the served JSON Schema" is the wrong advice. A form-face-only message would need a secondvalueschema, and that breaks the by-reference derivation the ruling cites. A zod error map on a parent object cannot rewrite the issue either, because the regex check's ownerrorresolves first.defineFormis the one door where the remedy is true: it stampsdata.provider: 'schema'on every form it builds. So the sentence is appended there, and only there.Measured first, on
origin/main@dabf8d795e(first round)defineForm({ schemaId: 'action', type: 'simple', sections: [{ label: 'X', fields: [{ field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }] }] }): aZodErrorfromdefineForm, with oneinvalid_unionissue atsections.0.fields.0. Its object branch carries{ code: 'invalid_format', format: 'regex', pattern: '/^[a-z][a-z0-9_.]*$/', path: ['options', 0, 'value'] }with this message, verbatim:System identifier must be lowercase, starting with a letter, and may contain letters, numbers, underscores, or dots (e.g., "user_profile" or "order.created")perRecordandsystem-datagave the same issue shape and the same text. A one-character value givestoo_smallwithSystem identifier must be at least 2 characters.view.zod.ts:3235on that base):Options for select/multiselect/radio/checkboxes fields (per-option \default` is not accepted here — declare the pre-selected choice on the object definition). It does not name a JSON Schema,helpTextor omittingoptions`.Tests
packages/spec/src/ui/form-option-enum-derive.test.ts(33 tests). Its assertions name subjects (omittingoptions, the JSON Schema,helpText, members that cannot be spelled) rather than whole sentences.refusal(), which asserts az.ZodError, anError, a stringstack, and a stack that names this test file, the module that calleddefineForm. A new case reads the head of the stack, which is what an uncaught throw prints:ZodError:, today's grammar message JSON-escaped, and the derive path with its scope.new-tab,perRecord,system-data(invalid_format) andx(too_small) names the derive path and scopes it to members that cannot be spelled. The grammar message is kept verbatim ahead of the remedy, read live off the object face. A nested row (compositefields) gets the same remedy.z.toJSONSchema(getMetadataTypeSchema(type)), input side), so "unspellable", "spellable" and "subset" are measured, not assumed.object.managedBy(members that cannot be spelled): with inlineoptionsit is REFUSED. Every unspellable member is refused with the remedy, and no spellable one is. The same row withoutoptions, meanings inhelpText, is GREEN.object.sharingModelwith a labelled full list (the #19188 split: 47 top-level zod-only keys are scalar controls needing one form row each #19331 shape): GREEN, labels kept. The same list with one member re-spelled with a hyphen is REFUSED at that member.field.deleteBehaviormaster_detail subset (cascade,restrict, noset_null): GREEN, not widened. The lit precondition showsset_nullis a served member. The same subset with one member capitalised is REFUSED at that member.new_tabis still accepted, and a spellable inline option still builds.z.toJSONSchema(FormFieldSchema)). It permits an inline list (human labels, a deliberate subset). It names the derive path, scoped to members that cannot be spelled. It no longer states the blanket rule. It keeps the per-optiondefaultsentence.Old-wording pins, reversed rather than deleted. A
git grepfor the old describe, the old remedy and the old ruling's 「never hand-listed」 found one assertion pinning the old wording: the describe test'stoContain('spec enum'). It became the assertions above: the permission and the scoped derive path are present, and the blanket rule is absent. The file header's restatement of the old rule is rewritten to ruling 乙. The other 「never hand-listed」 hits in the repository (nine test and source comments) describe unrelated derived vocabularies.../objectuihas no hit for either old sentence.Ablation, round 2 (one-shot, at
74ea5dbba3, throughscripts/ablation-replace.mjsunder the verify lock, one leg at a time, with the old wording put back). The test imports./view.zodas source, so nodistis in the path.optionsd6471f538d06→c91e601551c2Tests 6 failed | 26 passed (32): the four scoped-remedy cases, the nested row, themanagedByFIRING caseoptionsd6471f538d06→1e03c6756624Tests 3 failed | 29 passed (32): the permission, scoped-derive and no-blanket-rule describe casesBoth legs went red in the expected direction. Both restored: blob after restore
d6471f538d06== HEAD, andgit diff HEADwas empty. The first round's ablation, at2aa26de218, removed the remedy altogether (throw parsed.error;) and gaveTests 9 failed | 7 passed (16), which showed the remedy itself is load-bearing.Suite runs, all at
76a053e9d0(the PR head):@objectstack/specvitest run --project localTest Files 532 passed (532)·Tests 15688 passed | 2 todo (15690)@objectstack/specvitest run --project repoTest Files 35 passed (35)·Tests 602 passed (602)@objectstack/spectypecheck(tsc + scripts + test layer)tsconfig.test.json's program, andview.zod.tsintsconfig.json's (--listFilesOnly: 1 hit each)@objectstack/speccheck:generatedAll 15 generated artifacts are up to date, against adistbuilt at this head@objectstack/speccheck:docs225 generated files in sync with packages/spec.tsfiles--no-inline-config --format json: 2 files, 0 errors, 0 warnings. Both are in eslint's population (--print-configresolves a config for each). The config is not type-aware (noparserOptions.project/projectService), so this diff cannot move a verdict on an untouched file. The changeset andview.mdxresolve no eslint configThe regenerated page against
main. Against the mergedmaintipc8399867b8,view.mdxdiffers in exactly the twooptionsrows. The six PRs that last moved that page onmainare95fb417ec8,48c91e9e46,9dcdb775a0,2b52a5b013,b01bdbc4d9and1ff3a8f210. Every line they added that is still onmain, 51 in all, was grepped quoted-exact (git grep -F -c). Each has the same count onc8399867b8as on this branch, with 0 mismatches. In round 3 neither side moved the page:git diff --quietexits 0 forview.mdxfromc8399867b8toorigin/maine8f163fc3a, and from74ea5dbba3to76a053e9d0.Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat76a053e9d0derived 107 commands. The count matches round 2's 107 at74ea5dbba3, and the list is identical to the one derived on a probe tree atorigin/maine8f163fc3awith this PR's four files. The--ranreconciliation reports107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED. All 107 exited 0 on the first run. Their prerequisites were built before it: a spec build, then a turbo build of every package except docs (73 successful, 73 total). In round 2, seven of them first exited 3 and went green once those prerequisites were built:check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift: after the@objectstack/lint...closure.check:lean-entry-closure: after the@objectstack/objectql...closure.check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt: after a turbo build of every package except docs (73 tasks).The
Test Core (1/6)walker race. On the first round's head,Test Core (1/6)was red onscripts/check-error-status-conformance.mjs'swalk(): an ENOENT from a transienttsup.config.bundled_*.mjs(the open finding #19667; #19916 is closed). This base merge re-measured it. On74ea5dbba3every check run completedsuccess, includingTest Core (1/6)and all seven required contexts. That script is not edited here.Changeset:
patchRuntime text in a released package changes. The
defineFormrefusal ships in@objectstack/spec'sdist, and the describe is served in the JSON Schema. That is a released-package change, so there is a changeset. Round 3 changes no word of it: the thrown class isZodRealErroragain, so its sentence "defineFormstill throws aZodErrorat module load with the same issues and codes" is literally true. It isClause-②: no: every value accepted or refused before is accepted or refused now, and nothing an author can write is added or removed. So it takes the checklist'spatch, notminor.Sibling PRs
checkViewFilterRuleValueShape/ViewFilterRuleSchema) has landed onmainand came in with the base merge. It merged without a conflict, and this PR does not touch it.limit#19809 is the one open PR that also editsview.zod.tsandview.mdx. That was re-derived from the file lists of all 34 open PRs on 2026-09-24. Its regions (PaginationConfigSchema, the per-kind Gallery / Timeline / Kanban / AddRecord configs,rowLimitKey, theCalendarConfigtype exports) do not overlap theFormFieldBaseSchema.optionsrow ordefineForm. Itsview.mdxhunks do not touch the twooptionsrows. If the two collide, the page is regenerated, never hand-merged.Acceptance notes
optionsrows in 9 of the 17packages/spec/src/**/*.form.tsmodules (git grepat74ea5dbba3: object 12, field 3, hook 3, action 3, page 2, and agent, skill, permission and email_template 1 each). The first round's census grouped them as 11 of 17 metadata forms. This round did not re-derive that grouping. Each row's key was resolved in the served JSON Schema atdabf8d795e.object.managedBy(4:system-data,engine-owned,append-only,better-auth),action.execution(perRecord) andaction.openIn(new-tab).fields.valueDomain,fields.deleteBehavior(lookup row),fields.returnType,fields.summaryOperations.function,ownership,sharingModel,editMode,lifecycle.class,lifecycle.storage.strategy,lifecycle.storage.unit; fieldreturnType,summaryOperations.function; hookbody.language,onError,runAs; actionmode,body.language,operation; pagetype,interfaceConfig.recordAction; agentsurface; skillsurface; permissionmanagedBy; email_templatecategory.fields.typeomitssecretanduser, and the two master_detaildeleteBehaviorrows (objectfields.deleteBehavior, fielddeleteBehavior) omitset_null.object.form.ts("Each enum gets an explicitoptionslist because the bare member reads as a word…") and the served describe now agree. The first round's contradiction between them is what [Decision] Ruling 5793380467 says metadata-form enum rows omitoptions— but 27 in-repo rows hand-list enum members, 5 of them deliberately (#19331), and 3 are subsets the derive path cannot express #19907 decided.defineForm(a stack'sviewmetadata withdata: { provider: 'schema' }, parsed at compose or publish) still gets the bare grammar message. The ruling names the module-load refusal. The object-field option face is unchanged by design.Generated by Claude Code