Skip to content

fix(spec): the form option-value refusal and the options describe name the derive path for enum rows - #19906

Draft
objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-19678-enum-options-derive-declared
Draft

objectstack-fleet[bot] wants to merge 3 commits into
mainfrom
claude/issue-19678-enum-options-derive-declared

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19678

Clause-②: no

Executes ruling comment 5793380467 on the card (batch #217 item 5, letter 不动 + 声明, maintainer 「217 同意」):

  1. FormSelectOptionSchema.value keeps the system-identifier bound. For a metadata-form row whose key is a spec enum, options is omitted and the control derives the members from the served JSON Schema; meanings go in helpText (the #19188 split: 47 top-level zod-only keys are scalar controls needing one form row each #19331 shape).
  2. That rule is written where an author meets it: the form field's options describe states it, and the module-load refusal for an unspellable option value names the derive path as the remedy — the wall says what to do.
  3. newTab vs new-tab (two spellings of one idea in action.zod.ts) is recorded as a boundary, ⛔ not in this ruling

Item 3 is 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), a row whose key is a spec enum omits options: the control derives the members from the served JSON Schema, and their meanings go in helpText. An option value is a lowercase system identifier, so an enum member carrying a hyphen or a capital cannot be listed here at all. The TSDoc above the row says the same thing and names the ruling.
  • The wall. defineForm now calls FormViewSchema.safeParse. When the parse fails, it throws a ZodError with the parse's own issues. Only one thing changes: 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, 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. Two table rows changed (the options row of the two FormField tables). This is check:generated --fix output, and check:docs was the only stale artifact out of 15. No other artifact moved: api-surface, authorable-surface, json-schema manifest and declaration-map were all up to date.
  • Changeset: .changeset/19678-form-option-enum-derive-remedy.md, @objectstack/spec: patch.

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 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), through its FormViewSchema.parse. 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

  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 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. The census found 27 rows. None of them is broken by this change.

Tests

New file packages/spec/src/ui/form-option-enum-derive.test.ts (16 tests). Its assertions name subjects (omitting options, the JSON Schema, helpText) rather than whole sentences:

  • the refusal for new-tab, perRecord, system-data (invalid_format) and x (too_small) names the derive path. The grammar message is kept verbatim ahead of the remedy, and it is read live off the object face. A nested row (composite fields) gets the same remedy.
  • firing control: the same predicate is 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 verdict did not move: the same values are refused and new_tab is still accepted.
  • 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, read from the served JSON Schema (z.toJSONSchema(FormFieldSchema)), names the derive path and keeps the per-option default sentence.

Ablation (one-shot, at 2aa26de218, through scripts/ablation-replace.mjs under the verify lock): the anchor throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues)); was replaced with throw parsed.error; (today's behaviour). Anchor count went x1 → x0 and the blob went 998be8399ebe5eb7. Result: Tests 9 failed | 7 passed (16). The 9 red are exactly the remedy and nested-row assertions. The firing control, verdict, scope and describe tests stayed green. Restore: blob after restore 998be839 == HEAD, and git diff HEAD was empty.

Suite runs, all at ebd7fc2fa8 (the PR head):

run result
@objectstack/spec vitest run --project local Test Files 527 passed (527) · Tests 15517 passed | 1 todo (15518)
@objectstack/spec vitest run --project repo Test Files 35 passed (35) · Tests 602 passed (602)
@objectstack/spec typecheck (tsc + scripts + test layer) exit 0
@objectstack/platform-objects vitest run Test Files 54 passed (54) · Tests 883 passed (883) (after building its dependency closure; the first attempt could not load 13 files because metadata-core / formula / core had no dist, which is a prerequisite and not a finding)
@objectstack/platform-objects typecheck exit 0
@objectstack/spec check:generated All 15 generated artifacts are up to date

Downstream readers of the old text: git grep over packages/ for the old describe sentence and for System identifier must found no test that pins either one. The only reader of the old describe was the generated view.mdx, which is regenerated above.

Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at ebd7fc2fa8 derived 107 commands. The --ran reconciliation reports 107 derived famil(ies) accounted for — 104 run, 3 NOT-MEASURED. All 104 that ran exited 0. Four of them first exited 3 and went green after the @objectstack/objectql... closure was built: check:doc-formula-expressions, check:doc-security-posture, check:docs-transcript-drift, check:lean-entry-closure. NOT MEASURED, left to CI:

  • check:skill-examples: needs the built client / client-react closure (35 packages).
  • check:dual-build-cjs-loads: needs every workspace package built.
  • check:type-check-debt: needs the whole packages/* build that lint.yml does.

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. 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 lists across 11 of the 17 metadata forms. Each row's key was resolved in the served JSON Schema (z.toJSONSchema of getMetadataTypeSchema(type), input side), at dabf8d795e.
    • All 27 keys are spec enums. None lists a non-member. None contains an unspellable member, so no row is broken by this change and none is converted here.
    • 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 and could use the derive path, with their option labels moving into helpText: 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. The derive path would widen what they offer: object fields.type omits secret and user, and the two master_detail deleteBehavior rows (object fields.deleteBehavior, field deleteBehavior) omit set_null.
  • ⚠️ Read literally, ruling item 1 covers these 27 rows too. It says to omit options on a spec-enum row, and the new describe states it that way. object.form.ts carries a comment from #19188 split: 47 top-level zod-only keys are scalar controls needing one form row each #19331 that says the opposite for five of its rows ("Each enum gets an explicit options list because the bare member reads as a word…"). This PR converts nothing, as dispatched. Whether the 24 full-list rows convert, and how the 3 subsets are expressed, is left for a follow-up.
  • 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.
  • The branch is 4 commits behind origin/main (44ce049a8c). None of them touches packages/spec, and none of them touches any file in this diff, so the branch was not updated.

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

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 44ce049a8c52b533f8a4ae195c0dca697309e400packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 44ce049a8c52b533f8a4ae195c0dca697309e400

⚠️ 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 44ce049a8c52b533f8a4ae195c0dca697309e400 → 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

This branch has not been deployed

No deployments
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 protocol:ui size/m tests tooling

Projects

None yet

2 participants