Skip to content

fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled - #19906

Merged
os-support-ai merged 7 commits into
mainfrom
claude/issue-19678-enum-options-derive-declared
Sep 24, 2026
Merged

os-support-ai merged 7 commits into
mainfrom
claude/issue-19678-enum-options-derive-declared

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19678
Fixes #19907

Clause-②: no

Executes ruling comment 5805845085 on #19907 (batch #218 item 3, letter 乙, maintainer 「其他同意」). It narrows item 1 of ruling 5793380467 on #19678 (batch #217 item 5, letter 不动 + 声明), which the first round of this PR executed to the letter:

  1. The rule as recorded on FormFieldSchema.options' describe and in defineForm's refusal: an enum-typed metadata-form row MAY carry an inline options list (human labels, a deliberate subset); a row whose members cannot be spelled as option values (a hyphen, a capital) OMITS options, the control derives the members from the served JSON Schema, and meanings go in helpText. The refusal names that path as the remedy.
  2. The 27 existing rows stay; #19188 split: 47 top-level zod-only keys are scalar controls needing one form row each #19331's labels stay.
  3. PR fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled #19906 lands with its describe and remedy sentence narrowed to that wording.

From ruling 5793380467, the parts 乙 does not narrow still hold: FormSelectOptionSchema.value keeps the system-identifier bound, and newTab vs new-tab stays a recorded boundary, untouched here. No value bound, no schema shape, no key and no export moves.

What changed

  • The describe. FormFieldSchema.options (packages/spec/src/ui/view.zod.ts, the FormFieldBaseSchema row) keeps its per-option default sentence and now adds: On a metadata form (schema-bound, built by defineForm), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option value is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits options: the control derives the members from the served JSON Schema, and their meanings go in helpText. The TSDoc above the row says the same thing and names both rulings.
  • The wall. defineForm calls FormViewSchema.safeParse. When the parse fails, it throws a ZodRealError built from the parse's own issues. That is the class FormViewSchema.parse threw before this PR: an Error whose name is ZodError. Its stack is captured at the defineForm call, 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_format or too_small) at an inline option's value (path ending options.INDEX.value, also when nested inside the field-row union's errors) 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 option value, which is a lowercase system identifier. When this row edits a spec enum whose members cannot be spelled as option values, omit options: the control derives the members from the served JSON Schema, and their meanings go in helpText. No issue is added, removed or re-coded.
  • Generated: content/docs/references/ui/view.mdx, regenerated by pnpm --filter @objectstack/spec gen:docs after a spec build. Two table rows changed (the options row of the two FormField tables). check:generated then reported all 15 artifacts up to date.
  • Changeset: .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

  • The describe no longer says a row whose key is a spec enum omits 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.
  • The remedy no longer says When this row edits a spec enum, omit options. It now conditions the same derive path on members that cannot be spelled as option values.
  • The verdict did not move. The same values are refused and the same values are accepted as on the first round's head ebd7fc2fa8.
  • The branch is merged with origin/main at c8399867b8 (merge commit 61ff3aebe6, through scripts/pm/os-regen-merge.sh). The wording commit is 182ed4c154 and the regeneration commit is 74ea5dbba3.

Round 3 (at-tier record 5818584341: FAIL): the refusal is an Error with a stack again

  • What the record found. Round 2 threw new z.ZodError(…). In zod v4 classic (zod@4.6.1 here) that constructor has no Error parent, so the thrown object was not an Error and had no stack. An uncaught module-load throw printed only ZodError { name: 'ZodError', message: [Getter/Setter] }. That hid the issues and the remedy, the very wall both rulings require. refusal() asserted only toBeInstanceOf(z.ZodError), and both shapes pass that.

  • The fix (76a053e9d0). defineForm now throws new z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues)) and captures its trace with z.core.util.captureStackTrace(refusal, defineForm). ZodRealError is the class FormViewSchema.parse threw before this PR: its name is ZodError, it is an Error, and it passes instanceof z.ZodError. The walker is unchanged. It returns copies, it grows only invalid_format and too_small at a …options.INDEX.value path, it still walks invalid_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 source FormViewSchema (run by tsx).

    1. Neither spelling has a frame. zod builds every ZodRealError with Error.stackTraceLimit = 0 (newError in zod/v4/core/core.js). It captures a trace only inside parse (util.captureStackTrace(e, callee)), and safeParse never does. So throw parsed.error and a bare throw new z.ZodRealError(…) both print the issues as [ZodError: …], with 0 at frames and no source line. Read directly in node: new z.ZodError([]) is not an Error and its stack is undefined, new z.ZodRealError([]) and a safeParse error are Errors whose stacks hold 0 frames, and the error parse throws holds 8. That stack is still a string, so the record's two assertions pass on both spellings. The fix captures the trace the way zod's own parse does. The callee is defineForm, so the first frame is the author's defineForm(…) call.
    2. A copy, not a mutation in place. A mutation in place would not leak into another caller. Two parses of the same input share 0 issue objects, because zod's finalizeIssue builds each issue fresh and lazySchema caches 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's message on 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 .message or .stack before the mutation. Measured on FormViewSchema.parse's own error, which has its frames. Grown with no earlier read, the remedy is in the issues, the message and the stack once each, and the uncaught printout carries it. After one earlier read of .message, the issues still carry it once, but the message, the stack and 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, imports defineForm from the built packages/spec/dist/ui/index.mjs and 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 under node 22.22.2, and stderr was captured:

    read on stderr the fix (76a053e9d0, dist built) negative control: round 2's new z.ZodError(…) line, dist rebuilt
    node exit 1 1
    what it printed ZodError: [ followed by the issue list as JSON ZodError { name: 'ZodError', message: [Getter/Setter] } and nothing else
    the issue path (sections.0.fields.0, then options.0.value in the union's branch) present absent
    the grammar message (System identifier must be lowercase…) 1 0
    the remedy sentence (omit options, the members come from the served JSON Schema) 1 0
    the remedy's scope (cannot be spelled as option values) 1 0
    stack frames 4. The first is action-behavior.form.mjs:5:35, and node's caret points at defineForm({ in that module 0

    For the negative control, view.zod.ts was byte-identical to round 2's blob d6471f538d06, and ablation-dist-preflight found the old line in 11 built files. After the restore the dist was 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 sha256 7d118336d597 both times).

  • The pin. On every refusal, refusal() now asserts toBeInstanceOf(Error) and a string stack, the record's two. It also asserts that the stack names this test file, the module that called defineForm. The third assertion is the one that tells a trace-less ZodRealError apart. 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, through scripts/ablation-replace.mjs under the verify lock, one leg at a time. The test imports ./view.zod as source, so no dist is in its path.

    leg mutation anchor blob result
    1 the throw put back to round 2's throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues)); x1 → x0 e2feed0106e6 → d6471f538d06 (round 2's blob, byte for byte) Tests 19 failed | 14 passed (33), every one at expect(thrown).toBeInstanceOf(Error)
    2 only the trace capture deleted: a ZodRealError with no frame, the shape both spellings in the record give x1 → x0 e2feed0106e6 → 11a3b9cfae81 Tests 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 shape

    The 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 reach refusal(). Both legs were restored: after each, the blob was e2feed0106e6, equal to HEAD, git diff HEAD was empty, and git status --porcelain read 0 lines.

  • The changeset is not reworded. Its sentence "defineForm still throws a ZodError at module load with the same issues and codes" is literally true at this head. The thrown object is a ZodRealError, the class FormViewSchema.parse threw 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/main moved 23 commits past the round-2 merge base c8399867b8, to e8f163fc3a. None of them touches this PR's four paths, identifiers.zod.ts or field.zod.ts (git diff --name-only: 0 hits). Derived on a probe tree at e8f163fc3a with 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:generated reports all 15 generated artifacts up to date at 76a053e9d0, and view.mdx is unchanged from round 2, so nothing was regenerated.

Where the refusal lives (found by content), and why the remedy is attached at defineForm

  • The text is SystemIdentifierSchema's regex message, declared in packages/spec/src/shared/identifiers.zod.ts (lines 104 and 107 on the first round's base). It reaches the form face through SelectOptionSchema.value (data/field.zod.ts). FormSelectOptionSchema reuses that value by reference, and the property schemas are shared BY REFERENCE pin in form-select-option.test.ts holds it there.
  • The thrower at module load is defineForm (ui/view.zod.ts). On the base it threw through FormViewSchema.parse; since the first round it runs safeParse and throws the refusal itself. All 17 packages/spec/src/**/*.form.ts modules call it at module scope.
  • The remedy cannot go where the text is declared. The same grammar also bounds object-field options (Field.select.options) and three object-storage names. For those, "omit options, derive from the served JSON Schema" is the wrong advice. A form-face-only message would need a second value schema, 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 own error resolves first. defineForm is the one door where the remedy is true: it stamps data.provider: 'schema' on every form it builds. So the sentence is appended there, and only there.

Measured first, on origin/main @ dabf8d795e (first round)

  1. Today's refusal for the card's own example, defineForm({ schemaId: 'action', type: 'simple', sections: [{ label: 'X', fields: [{ field: 'openIn', options: [{ label: 'New tab', value: 'new-tab' }] }] }] }): a ZodError from defineForm, with one invalid_union issue at sections.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")
    perRecord and system-data gave the same issue shape and the same text. A one-character value gives too_small with System identifier must be at least 2 characters.
  2. The describe authors read (view.zod.ts:3235 on 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`.
  3. Census of hand-listed enum members: see Acceptance notes. None of the 27 rows is broken by this change, and under ruling 乙 every one of them is the permitted shape.

Tests

packages/spec/src/ui/form-option-enum-derive.test.ts (33 tests). Its assertions name subjects (omitting options, the JSON Schema, helpText, members that cannot be spelled) rather than whole sentences.

  • The thrown class, and the printed wall (round 3). Every refusal the file reads goes through refusal(), which asserts a z.ZodError, an Error, a string stack, and a stack that names this test file, the module that called defineForm. 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.
  • Refusal. The refusal for new-tab, perRecord, system-data (invalid_format) and x (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 (composite fields) gets the same remedy.
  • Firing controls for the predicates. Both predicates are RED on today's message: the object face raises the grammar issue through the very property schema the form face shares, with no remedy. The blanket-rule predicate is LIT on the two spellings the first round shipped, so its "states no blanket rule" assertions cannot be vacuous.
  • Ruling 乙 item 1, on real spec enums. Each case has a firing and a dark control. Every enum is read off the served JSON Schema (z.toJSONSchema(getMetadataTypeSchema(type)), input side), so "unspellable", "spellable" and "subset" are measured, not assumed.
    • object.managedBy (members that cannot be spelled): with inline options it is REFUSED. Every unspellable member is refused with the remedy, and no spellable one is. The same row without options, meanings in helpText, is GREEN.
    • object.sharingModel with 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.deleteBehavior master_detail subset (cascade, restrict, no set_null): GREEN, not widened. The lit precondition shows set_null is a served member. The same subset with one member capitalised is REFUSED at that member.
  • The verdict did not move. The same values are refused, new_tab is still accepted, and a spellable inline option still builds.
  • The remedy is scoped. An unknown key on the option, and an unrelated refusal on the same form, are both answered without it.
  • The describe states ruling 乙's rule in the served JSON Schema (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-option default sentence.

Old-wording pins, reversed rather than deleted. A git grep for the old describe, the old remedy and the old ruling's 「never hand-listed」 found one assertion pinning the old wording: the describe test's toContain('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. ../objectui has no hit for either old sentence.

Ablation, round 2 (one-shot, at 74ea5dbba3, through scripts/ablation-replace.mjs under the verify lock, one leg at a time, with the old wording put back). The test imports ./view.zod as source, so no dist is in the path.

leg mutation anchor blob result
1 remedy constant back to When this row edits a spec enum, omit options x1 → x0 d6471f538d06 → c91e601551c2 Tests 6 failed | 26 passed (32): the four scoped-remedy cases, the nested row, the managedBy FIRING case
2 describe back to a row whose key is a spec enum omits options x1 → x0 d6471f538d06 → 1e03c6756624 Tests 3 failed | 29 passed (32): the permission, scoped-derive and no-blanket-rule describe cases

Both legs went red in the expected direction. Both restored: blob after restore d6471f538d06 == HEAD, and git diff HEAD was empty. The first round's ablation, at 2aa26de218, removed the remedy altogether (throw parsed.error;) and gave Tests 9 failed | 7 passed (16), which showed the remedy itself is load-bearing.

Suite runs, all at 76a053e9d0 (the PR head):

run result
@objectstack/spec vitest run --project local Test Files 532 passed (532) · Tests 15688 passed | 2 todo (15690)
@objectstack/spec vitest run --project repo Test Files 35 passed (35) · Tests 602 passed (602)
@objectstack/spec typecheck (tsc + scripts + test layer) exit 0. The test file is in tsconfig.test.json's program, and view.zod.ts in tsconfig.json's (--listFilesOnly: 1 hit each)
@objectstack/spec check:generated All 15 generated artifacts are up to date, against a dist built at this head
@objectstack/spec check:docs 225 generated files in sync with packages/spec
eslint, narrowed to the two changed .ts files --no-inline-config --format json: 2 files, 0 errors, 0 warnings. Both are in eslint's population (--print-config resolves a config for each). The config is not type-aware (no parserOptions.project / projectService), so this diff cannot move a verdict on an untouched file. The changeset and view.mdx resolve no eslint config

The regenerated page against main. Against the merged main tip c8399867b8, view.mdx differs in exactly the two options rows. The six PRs that last moved that page on main are 95fb417ec8, 48c91e9e46, 9dcdb775a0, 2b52a5b013, b01bdbc4d9 and 1ff3a8f210. Every line they added that is still on main, 51 in all, was grepped quoted-exact (git grep -F -c). Each has the same count on c8399867b8 as on this branch, with 0 mismatches. In round 3 neither side moved the page: git diff --quiet exits 0 for view.mdx from c8399867b8 to origin/main e8f163fc3a, and from 74ea5dbba3 to 76a053e9d0.

Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 76a053e9d0 derived 107 commands. The count matches round 2's 107 at 74ea5dbba3, and the list is identical to the one derived on a probe tree at origin/main e8f163fc3a with this PR's four files. The --ran reconciliation reports 107 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 on scripts/check-error-status-conformance.mjs's walk(): an ENOENT from a transient tsup.config.bundled_*.mjs (the open finding #19667; #19916 is closed). This base merge re-measured it. On 74ea5dbba3 every check run completed success, including Test Core (1/6) and all seven required contexts. That script is not edited here.

Changeset: patch

Runtime text in a released package changes. The defineForm refusal ships in @objectstack/spec's dist, 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 is ZodRealError again, so its sentence "defineForm still throws a ZodError at module load with the same issues and codes" is literally true. It is Clause-②: 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's patch, not minor.

Sibling PRs

Acceptance notes

  • Census: 27 inline options rows in 9 of the 17 packages/spec/src/**/*.form.ts modules (git grep at 74ea5dbba3: 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 at dabf8d795e.
    • All 27 keys are spec enums. None lists a non-member. None contains an unspellable member. So under ruling 乙 every row is the permitted shape, and item 2 keeps all of them. None is converted.
    • Lit control: the same instrument, run on the three option-less reference rows, reports the unspellable members it should: object.managedBy (4: system-data, engine-owned, append-only, better-auth), action.execution (perRecord) and action.openIn (new-tab).
    • 24 rows list every member with human labels: object fields.valueDomain, fields.deleteBehavior (lookup row), fields.returnType, fields.summaryOperations.function, ownership, sharingModel, editMode, lifecycle.class, lifecycle.storage.strategy, lifecycle.storage.unit; field returnType, summaryOperations.function; hook body.language, onError, runAs; action mode, body.language, operation; page type, interfaceConfig.recordAction; agent surface; skill surface; permission managedBy; email_template category.
    • 3 rows are deliberate subsets: object fields.type omits secret and user, and the two master_detail deleteBehavior rows (object fields.deleteBehavior, field deleteBehavior) omit set_null.
  • The #19188 split: 47 top-level zod-only keys are scalar controls needing one form row each #19331 comment in object.form.ts ("Each enum gets an explicit options list 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 omit options — 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.
  • Boundary: a schema-bound form view authored outside defineForm (a stack's view metadata with data: { 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

…-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>
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>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

3 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_format (literal, a string literal in OPTION_VALUE_GRAMMAR_CODES))
  • content/docs/api/error-handling-server.mdx (via too_small (literal, a string literal in OPTION_VALUE_GRAMMAR_CODES))
  • content/docs/kernel/runtime-services/settings-service.mdx (via invalid_format (literal, a string literal in OPTION_VALUE_GRAMMAR_CODES))

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

  • content/docs/releases/v17/17-0.mdx (via defineForm (symbol, a top-level function), invalid_union (literal, a string literal in withOptionValueDeriveRemedy), too_small (literal, a string literal in OPTION_VALUE_GRAMMAR_CODES))

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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 136 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 e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1478d53a228516e00841140cd9ba506ecc6fe8eb — the merge of head 76a053e9d0f3c6dbe6efc3fac683713ff66b8ba6 into base e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b, 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 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

⚠️ 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 e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

CI red on head ebd7fc2fa8, and it is not this PR's, read at 2026-09-23T18:19Z.


Generated by Claude Code

… 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>
@os-support-ai os-support-ai changed the title fix(spec): the form option-value refusal and the options describe name the derive path for enum rows fix(spec): the form option-value refusal and the options describe name the derive path for enum members that cannot be spelled Sep 24, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 74ea5dbba3b964bce1a5825ffeeb36958b0144f7

① Derived judgments

packages/spec/src/ui/view.zod.ts — the describe (line 3298). Right. The only non-comment change on the FormFieldBaseSchema.options row is the .describe() string; the schema stays z.array(FormSelectOptionSchema).optional(), no refine/transform moved. The text matches ruling 乙 item 1 verbatim in substance: an enum-typed row on a defineForm metadata form "may list its members here, to give them human labels or to offer a deliberate subset"; the derive path is scoped to "a row whose members cannot be spelled as option values (a hyphen, a capital)", members "from the served JSON Schema", meanings in helpText. The per-option default sentence is kept. The blanket "a row whose key is a spec enum omits options" wording from round 1 is gone. FormSelectOptionSchema (line ~2761) and SystemIdentifierSchema (shared/identifiers.zod.ts:102-109) are untouched, so the accepted/refused value set is byte-for-byte the same.

view.zod.ts — defineForm (lines 6364-6372) and withOptionValueDeriveRemedy (6539-6559). The verdict is unchanged (safeParse success returns parsed.data; failure rethrows the parse's own issue list, spread-copied, only invalid_format/too_small at a …options.N.value path gaining a trailing sentence; invalid_union branches are walked with the prefix carried down). The path guard is adequately scoped: inside FormViewSchema (line 4230 ff.) the only options.N.value node is the field row's own FormSelectOptionSchema (the UserFilterFieldSchema / GanttQuickFilterSchema option arrays at 1738 / 2001 are list-view shapes, not reachable from a form parse). The remedy sentence itself matches ruling 乙 ("When this row edits a spec enum whose members cannot be spelled as option values, omit options…").

Wrong — the thrown object is no longer an Error, so at the module-load wall the remedy is invisible. view.zod.ts:6372 does throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues)). In zod v4 classic (the repo pins zod ^4.6.1, packages/spec/package.json:324; verified against a bundled v4 classic build since no node_modules is present in the worktree), ZodError = $constructor("ZodError", init) has no Parent and constructs a plain object, while parse/safeParse throw/return ZodRealError = $constructor("ZodError", init, { Parent: Error }). Before this PR defineForm threw FormViewSchema.parse's ZodRealError (an Error, with .stack). After it, defineForm throws an object for which instanceof Error is false and .stack is undefined; instanceof z.ZodError still passes (trait check), which is why every pin in the new test is green. Reproduced with a faithful re-implementation of $constructor (scratch throw-shape.mjs): an uncaught throw of the new shape prints _ { name: 'ZodError', message: [Getter] } and nothing else — no issues, no remedy, no location — whereas the old shape prints ZodError: [ { "code": "invalid_format", "path": [...], "message": … } ] with a stack through defineForm into the form module. The card's failure mode is exactly this uncaught module-load throw ("breaks the module's import"), and ruling 5793380467 item 2 / 乙 item 1 require that "the wall says what to do". The PR body/changeset claim ("still throws a ZodError at module load with the same issues and codes… Only one thing changes") is therefore not accurate: the error class changed, and the user-facing wall regressed from full issue text + stack to a bare [Getter]. No consumer in-repo pins instanceof Error on a defineForm throw (grep: none), so nothing in CI catches it.

packages/spec/src/ui/form-option-enum-derive.test.ts. The pins do discriminate the wording claims: namesDerivePath / scopesDeriveToUnspellable / statesBlanketOmitRule (lines 120-143) have lit and dark controls (lines 237-254), the objectFaceMessage control reads today's grammar message live off SelectOptionSchema and asserts the remedy is appended after it verbatim (line 234), the three real-enum cases (managedBy, sharingModel, deleteBehavior subset, lines 270-375) each have firing and dark legs measured off the served JSON Schema, scope is pinned negatively (lines 395-408), and the describe is read from z.toJSONSchema(FormFieldSchema) (lines 410-435). What they do NOT discriminate is the thrown object's class: refusal() (line 164-173) asserts only toBeInstanceOf(z.ZodError), which both the old ZodRealError and the new plain ZodError satisfy.

content/docs/references/ui/view.mdx. Right. Only the two options rows of the two FormField tables changed (lines 184, 349), each carrying the describe string verbatim; the other five per-option hits on the page (266, 382, 1594, 1720, 1758) are different describes and are unchanged, consistent with a regeneration rather than a hand edit.

② Semver level

@objectstack/spec: patch is the right level: runtime text in a released package changes (served describe, refusal message) and no key, export, shape or accepted value moves. Clause-②: no is honest for the accepted set — every value refused before is refused now and vice versa (FormSelectOptionSchema / SystemIdentifierSchema untouched). The changeset prose is true except for the sentence "defineForm still throws a ZodError at module load with the same issues and codes" — same issues and codes, yes, but not the same error class (see ①); once the fix below lands the sentence becomes fully true without rewording.

③ Boundary flags

  1. Census 9 vs 11 modules — noted, not blocking. The two counts describe different groupings (source modules vs registered forms); nothing in the diff, the ruling's operative rule or the tests depends on the number. No action.
  2. Schema-bound form views authored outside defineForm still get the bare grammar message — correctly recorded as a boundary; both rulings name the module-load refusal (defineForm) only, and the PR explains why the remedy cannot live on SystemIdentifierSchema. Consistent with the rulings.
  3. #19667 walker race — script not edited here, CI green on the head. Untouched, no action.
  4. PR title — the title this review was given already reads "…for enum members that cannot be spelled", i.e. the seat's note is resolved. No action.

Implemented-by: claude/issue-19678-enum-options-derive-declared
Reviewed-by: session_01EcrTi7s5oDYPHS4Pi7h31d

VERDICT: FAIL

Required fixes

  1. packages/spec/src/ui/view.zod.ts:6372 — do not construct a new z.ZodError (a non-Error in zod v4 classic). Keep the parse's own ZodRealError and grow its messages in place, e.g. have withOptionValueDeriveRemedy mutate issue.message on the issue objects of parsed.error.issues (the .message getter re-stringifies that same array, so the remedy shows in the uncaught printout) and throw parsed.error; or, if a copy is preferred, throw new z.ZodRealError(...) (exported from zod). Why: the refusal must remain an Error with a stack so that the module-load wall prints the issues and the remedy — the very outcome both rulings require — instead of { name: 'ZodError', message: [Getter] }.
  2. packages/spec/src/ui/form-option-enum-derive.test.ts refusal() (line 164-173) — add expect(thrown).toBeInstanceOf(Error) and expect(typeof (thrown as Error).stack).toBe('string') so the pin discriminates the thrown class (today it is green on both shapes). Why: without it the regression in fix 1 is invisible to the suite.

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 76a053e9d0f3c6dbe6efc3fac683713ff66b8ba6

① Derived judgments

The blocking defect is fixed — packages/spec/src/ui/view.zod.ts:6364-6383 (defineForm). Right, verified at runtime against the locked zod@4.6.1 (fetched from the registry into the scratch dir, since the worktree has no node_modules; scratch script r3-verify.mjs reproduces the exact safeParse → withOptionValueDeriveRemedy → new z.ZodRealError → z.core.util.captureStackTrace(refusal, defineForm) → throw sequence with a union-of-rows schema shaped like FormViewSchema). Measured on the thrown value: instanceof Error true, instanceof z.ZodError true, typeof stack === 'string', the stack's first frame is the calling module (captureStackTrace(refusal, defineForm) trims defineForm's own frame, which is the intended "author's call is the first frame" behavior the comment describes), and the issue list is code-and-path identical to the pre-PR FormViewSchema.parse throw ([invalid_format, fields.0.options.0.value], [too_small, fields.0.options.1.value]). The uncaught print is now ZodError: [ …issues with the remedy sentence appended to both grammar issues… ] followed by a stack ending at the author's module, whereas the round-2 shape printed ZodError { name: 'ZodError', message: [Getter/Setter] }. Both exports used exist in zod 4.6.1 classic (v4/classic/errors.js:53 ZodRealError with Parent: Error; v4/core/util.js:214 captureStackTrace). The comment's two "traps" are both true as measured: parsed.error.stack from safeParse is "ZodError: [...]" with no frame, and a bare new z.ZodRealError([]) has stack "ZodError: []" — so the explicit capture is necessary, not decorative. The message is JSON.stringify(self.issues, …) computed off the issues the constructor received (classic/errors.js:27-42), so building the error from the remedied copies is the right order; nothing has to be patched after construction.

No harmful mutation. withOptionValueDeriveRemedy is unchanged from round 2: it maps and spread-copies, so the input parsed.error.issues array and its issue objects are untouched (measured: the original issue JSON is byte-identical before and after the call, and a parse-thrown error's messages carry no remedy). parsed.error is a local that is discarded after the throw; nothing shared is written.

The pin now discriminates the class — form-option-enum-derive.test.ts:124-137 (refusal). Right. toBeInstanceOf(Error) fails on the round-2 new z.ZodError shape (measured false), typeof stack === 'string' fails on it too (measured undefined), and toContain(THIS_MODULE) additionally fails on both a bare new z.ZodRealError and on a rethrown parsed.error (neither carries a frame) — so the pin rejects every alternative the commit message names, not only the one that failed. The new case (lines 220-231) reads the wall as node prints it (the stack up to the first \n at ), asserts it starts ZodError: , contains the live grammar message JSON-escaped exactly as JSON.stringify(issues, null, 2) escapes it, and passes namesDerivePath / scopesDeriveToUnspellable, whose lit/dark controls from round 2 are still in the file. CI on this head is green (39 success / 7 skipped / 0 failed), so the frame-name assertion holds under vitest's transform.

Everything the prior review found right is still right on the full diff c8399867b8...HEAD (4 files: changeset, view.mdx, the test, view.zod.ts). view.zod.ts has exactly three hunks (docblock + describe on FormFieldBaseSchema.options, the defineForm docblock, defineForm body + the remedy helpers); packages/spec/src/shared and packages/spec/src/data are untouched, so FormSelectOptionSchema / SystemIdentifierSchema and the accepted set are byte-identical. The describe text matches ruling 乙 item 1 (inline list allowed for labels or a deliberate subset; omit options only where a member cannot be spelled; members from the served JSON Schema; meanings in helpText), and the identical string appears verbatim in both regenerated view.mdx options rows (2 hits) and only there (the 4-line docs diff is the two rows). The remedy sentence still names the derive path in ruling 乙's narrowed wording.

No new defect in the delta. The delta is 13 lines in view.zod.ts and 31 in the test; no export, key, shape or accepted value moves; the remedy text and the describe are unchanged from the round-2 head.

② Semver level

@objectstack/spec: patch is right: served describe text and a refusal message change in a released package; no key, export, shape or accepted value moves. Clause-②: no is honest — every value refused before is refused now and vice versa. The changeset sentence that was false in round 2 ("defineForm still throws a ZodError at module load with the same issues and codes") is now fully true: same class family (ZodRealError is what parse threw before this PR), same issues, same codes, and it is an Error again.

③ Boundary flags

  1. Census 9 vs 11 modules — unchanged from round 2; nothing in the diff, rulings or tests depends on the count. No action.
  2. Schema-bound form views authored outside defineForm still get the bare grammar message — still a correctly recorded boundary; both rulings name the module-load refusal only. No action.
  3. #19667 walker race — not touched; CI green on this head. No action.
  4. captureStackTrace(refusal, defineForm) deliberately omits defineForm's own frame; the first frame is the author's module. That is the intent stated in the code comment and what the pin measures (THIS_MODULE), not a gap. On a runtime without Error.captureStackTrace zod's shim is a no-op and the error would still be an Error with V8-less stack semantics — outside the repo's node target, noted only.

Implemented-by: claude/issue-19678-enum-options-derive-declared
Reviewed-by: session_01EcrTi7s5oDYPHS4Pi7h31d

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

@hotlong
hotlong marked this pull request as ready for review September 24, 2026 23:08
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 655e8c0 Sep 24, 2026
51 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-19678-enum-options-derive-declared branch September 24, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment