Skip to content

docs(spec): each shape names itself in its rejection, and a select option gets the editability boundary (#8202, #8201) - #8339

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8202-per-shape-guidance-completion
Aug 13, 2026
Merged

docs(spec): each shape names itself in its rejection, and a select option gets the editability boundary (#8202, #8201)#8339
os-zhuang merged 2 commits into
mainfrom
claude/issue-8202-per-shape-guidance-completion

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #8202
Fixes #8201

Two text-face halves finishing the per-shape guidance story PR #8199 started. They land on the same shapes and the same pin file, which is why they ship as one PR. No key is added anywhere, every spelling rejected before is rejected after, and everything that parsed before parses identically — only the sentence an author reads moves.


Half A (#8202) — a rejection now says WHICH shape refused the key

FormFieldSchema / FormSectionSchema / PageComponentSchema shared one surface string, so all three opened their rejection with Unrecognized key(s) on this view/page schema.

That was free while the three answered a key identically. Since #8199 they do not:

# on a form field
Unrecognized key(s) on this form field: `disabled`. Did you mean `disabled` -> `readonly`? …

# on a form section
Unrecognized key(s) on this form section: `disabled`.
  • Editability is a FIELD-level concern. This shape gates VISIBILITY only … Write
    `readonly: true` (or the conditional `readonlyWhen` predicate) on the form field(s)
    inside it instead …

Those two answers contradict each other by design, and the contradiction is only coherent if the message says which shape the reader is on. Until this PR it did not: an author holding the section answer while looking at a field was being told to move a key that was already in the right place.

Each shape now names itself: this form field / this form section / this page component.

Where the strings landed, and why not the shared table. Per #8199's placement rule, a table shared by three shapes may carry only what is true of all three — it can no more carry one shape's name than it can carry one shape's prescription. So the override is filed at each of the three call sites (view.zod.ts twice, page.zod.ts once), and VISIBILITY_STRICT_OPTIONS keeps the family name it always had. That is the same rule #8199 drew, read from the surface end, and the docblocks on both shared tables now say so.

The one hazard that comes with a default nobody uses: a fourth consumer that spreads the shared options and forgets the override inherits the family string silently — the message goes vague rather than wrong, so nothing else fails. Pinned in two places rather than trusted: locally on the four live messages, and repo-wide over every table the alias-integrity walk constructs.

Half B (#8201) — SelectOptionSchema inherits the #7887 ruling, with its own answer

The 2026-08-12 ruling (a form section / page component gates visibility only; editability lives on fields) named two shapes, so #8199 deliberately left the third — the select option pinned alongside them in visible-when-alias-guidance.test.ts §3 — bare.

It has the boundary now, on the ruling's own premise re-measured for this shape rather than by analogy. Triage measured it on objectui origin/main @ aca27fa: the object-field pipeline that spec's SelectOptionSchema actually feeds has zero per-option disabled consumers — packages/fields/src/widgets/SelectField.tsx:161 calls root-level disabled "the single authority" in its own comment, RadioField.tsx:124 reads only props.disabled. A shown-but-unselectable option does exist in objectui's SDUI family (packages/components/src/renderers/form/select.tsx:62), but on that package's own select-option vocabulary in packages/types — a different shape from this one, so it makes no spec-side key enforced. Declaring one here would ship exactly the ADR-0049 declared-but-unenforced form.

Its prescription is not the siblings' text, because the siblings' destination does not exist here: a section redirects the author to "the form field(s) inside it", and an option has no inside. What is true for an option is that it is offered or withheld:

Unrecognized key(s) on this select option: `disabled`.
  • Editability is not a per-OPTION concern — a deliberate boundary, not a missing key
    (#8201): an option declares WHICH value may be picked and WHEN it is offered, and
    nothing in the field pipeline reads a per-option enabled/disabled flag today (the
    select and radio widgets treat the FIELD-level state as the single authority), so a
    key here would be metadata the renderer never honours (ADR-0049). To withdraw ONE
    option, give it the per-option `visibleWhen` predicate — the one `*When` surface that
    also binds `current_user` (ADR-0068), so an option can be withheld per record or per
    role, and the rule validator refuses a write of a value whose predicate is false. To
    freeze the WHOLE picker, write `readonly: true` (or the conditional `readonlyWhen`
    predicate) on the field that owns these options. If a shown-but-unselectable option
    ever earns a reader, that is a spec decision to ask for — not a key to write here.

The spelling it points at was checked against field.zod.ts rather than assumed: per-option visibleWhen is the declared key on this shape, and ADR-0068 binding current_user there is what makes it the real answer instead of a euphemism for "you cannot". It never names disabledWhen (no field surface declares it — #7832), and it points at readonly / readonlyWhen on the field, both of which really parse there.

The decision is not foreclosed. Triage explicitly left real product pull for non-selectable field options open as a maintainer decision that would widen the accepted set. The prose says what the platform honours today and names the route to change it — a spec decision, not a key an author writes. A pin fails a rewrite that hardens it into a permanent refusal.

No key, no alias row — the same red line the mother ruling drew. The set is filed on the option's own table and shares EDITABILITY_BOUNDARY_KEYS with the containers (one vocabulary, two answers), so a spelling added to the family is answered on every shape that has no editability semantics.


Acceptance invariance

The lane's admission criterion, evidenced in both directions on all four shapes:

  • Rejected set does not move. All six editability spellings (disabled, disabledWhen, readonly, readOnly, readonlyWhen, editable) are still rejected on section, page component and select option; pinned per key.
  • Accepted set does not move. A representative section, page component and select option all still parse, and the parsed OUTPUT is asserted key-by-key on the option (['color', 'default', 'label', 'value', 'visibleWhen']) with the ExpressionInputSchema fold intact.
  • The rename channel is untouched. The new set could have swallowed an existing alias key before its rename fires (a set match continues past the rename channel). Asserted directly: no member of the boundary family is one of the option's ten alias keys, and visible / colour still produce their Did you mean pointers.
  • FormFieldSchema still emits its disabled -> readonly rename and NOT the boundary textdocs(spec): a form section / page component gates visibility only — say so, and tell disabled where it belongs (#7887) #8199's invariant, unchanged and re-asserted.
  • Generated artifacts do not move. A full pnpm --filter @objectstack/spec build (which runs gen:schema and rewrites authorable-surface.base.json) leaves the tree clean; check:api-surface reports "public API surface + factory signatures unchanged ✓"; check:export-origins is current at 5012 exports. Nothing is added to the public barrel — editability-boundary.ts stays package-internal, the same posture docs(spec): a form section / page component gates visibility only — say so, and tell disabled where it belongs (#7887) #8199 gave it.

Existing assertions modified — four, each justified

File / line Change Why it is honest
ui/view.test.ts (#6416 order pin) expected front matter this view/page schema -> this form field The probe is a form FIELD. The assertion's subject is message ORDER (key statement first, nothing before it), and that is unchanged; only the shape's name inside the expected literal moved, which is the card's content.
ui/view.test.ts (full-message shape pin) expected literal -> this form section Same, on a SECTION probe. A comment now records that the two literals in this block deliberately disagree — if a future edit makes them agree again, one of the two is wrong.
shared/alias-integrity.test.ts (#6619 fold closure) selector surface === 'this view/page schema' -> the three per-shape names, plus new Set(...).size === 3 The three declarations no longer report one string, so the old selector matches zero — and zero against toBeGreaterThanOrEqual(3) fails loudly. The existing pin would have gone red and forced this update; it would not have gone quiet. (Only the for loop underneath it would have iterated zero times; the length assertion fires first and stops it, which is exactly why that assertion is there.) The new tripwire in this PR proves the count really is zero. The claim is identical (those three shapes declare through the folded table and carry VISIBILITY_KEY_PATTERN) and the selector is strictly stronger: it now also fails if a shape loses its own name — which it did, untouched, during reverse verification below.
shared/visible-when-alias-guidance.test.ts §3 comment only The prose said SelectOptionSchema "was out of that ruling's scope and stays bare", which Half B makes false. No assertion in §3 changed: all three rows stay green by construction because they measure rejection, and this card adds no key. Confirmed by removing the guidance set and watching §3 stay green while only the new #8201 pins went red.

Nothing was loosened. The two toBe/startsWith pins remain exact-literal pins; the alias-integrity selector gained an assertion rather than dropping one.

New pins — shared/editability-boundary.test.ts §5 and §6

Pin What it catches
each of the four shapes opens with its own name, and names no sibling a shape reverting to the shared string, or two shapes colliding on one name
the field's rename and the section's boundary are each stamped with their shape, and neither carries the other's answer the exact confusion #8202 records, asserted from both ends in one test
no shape reports the shared family string a consumer inheriting the default silently
the shared table still carries the FAMILY name the tempting "tidy-up" of renaming the shared string to one shape's name (#8199's placement rule from the surface end)
no live surface still reports the shared view/page FAMILY name (in alias-integrity.test.ts) the same thing repo-wide, over every table the walk constructs — the only place that can see a fourth consumer
disabled on an option reaches the boundary as the template bullet the guidance being filed where the shape does not read it
the whole six-spelling family reaches it, once per message a member dropped from the set; the set converted to N exact entries
the history sentence still comes last a regression of #5955's ordering on this surface
it is NOT the containers' text, in either direction the card's most likely wrong turn — copying #8199's "put it on the fields inside" onto a shape with no inside
it names per-option visibleWhen, and both advertised predicates really parse on the option prose claiming what the schema does not honour
it names readonly: true / readonlyWhen, and both really parse on FieldSchema the same, for the whole-picker escape hatch
the prescription never contains disabledWhen pointing at a key no field surface declares (#7832)
it names the route to change the answer and never reads as "never" a rewrite that forecloses the maintainer decision triage left open
no alias row for the family, and no set member collides with an existing alias key the ruling's forbidden half, and the set silently eating a working rename
every editability spelling still rejected; a representative option's parsed output asserted key-by-key; an unrelated typo still gets no bullet acceptance drift in either direction, and the set over-claiming beyond its family

Reverse verification (direction predicted before running)

Both fixes were committed first, so each restore came out of a real commit.

  1. Drop the section's surface override — predicted red in §5, in the view.test.ts shape pin, and in the new repo-wide tripwire. Observed: 6 red, including one I had not predicted and welcome — alias-integrity's [spec] Fold the three hand-written unrecognized_keys error maps into strictObject guidance — requires a set-keyed guidance form, and closes the alias-integrity gate's blind spot (#6416 direction 2) #6619 fold pin, which caught the section losing its name through the strengthened selector. Restored, green.
  2. Drop the option's guidanceSets entry — predicted red confined to the new §6 pins, with visible-when-alias-guidance.test.ts §3 staying green, since it measures rejection and this card adds no key. Observed exactly that: 6 red in §6, §3 green throughout. Restored, green.

Changeset

patch, in .changeset/per-shape-guidance-completion.md, confirmed the way this lane does it rather than inherited. The criterion is whether the prose reaches a consumer:

  • Parse-reachable error string — YES. Both halves are unrecognized_keys text an author reads out of a failed parse; every claim is pinned against a real safeParse error rather than against the options table. This alone settles it.
  • dist .d.ts hover — YES for Half B. The boundary paragraph is JSDoc on the exported SelectOptionSchema const.
  • Generated reference page — NO, measured. content/docs/references/data/field.mdx is built from the file-level doc block plus per-property .describe() text. Grepping that page for SelectOptionSchema's existing docblock text ("CRITICAL RULE", "machine identifier that gets stored") returns nothing, while its per-key .describe() string hits at line 262. No .describe() was touched, so neither the page nor authorable-surface.base.json moves.

No public export is added, so there is nothing additive for the bump to cover.

Verification

  • pnpm --filter @objectstack/spec test388 files / 10268 tests, green.
  • pnpm --filter @objectstack/spec typecheck — green (incl. check:scripts-typecheck, check:test-typecheck; the shrink-only debt ledger is unmoved).
  • pnpm --filter @objectstack/spec check:api-surface — "public API surface + factory signatures unchanged ✓" (after a real dts build; OS_SKIP_DTS=1 is invalid for this gate).
  • pnpm --filter @objectstack/spec check:export-origins — current, 5012 exports.
  • Gates, all exit 0: check:adr-anchors, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:docs-audit-scope, check:driver-conformance, check:merge-driver, check:release-body, check:spec-parsed-alias, check:type-source-resolution, check:nul-bytes, check:i18n (after building @objectstack/cli), @objectstack/lint check:doc-formula-expressions (after building @objectstack/formula).
  • Re-derived against the actual changed paths (scripts/pm/dispatch-gates.mjs), which surfaced four families the dispatch list did not name; all run, all green: check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, scripts/check-changeset-no-major.mjs.
  • Two gates are red on pre-existing repo state, neither reachable from this diff: scripts/check-dev-prereqs.mjs ("The workspace is not built — 1 unmet precondition, not a list of problems"; 66 of 67 packages have no dist/), and check:objectui-pin-fresh (.objectui-sha lags objectui main; not in this diff, and that workflow's own header says an ordinary code PR sitting behind objectui "is the normal state of the repo, not a defect" — it blocks only the release lane).

Scope

data/object.zod.ts is untouched, and no part of the visible/showWhen symmetry question is implemented — #8199's visibleWhen alias family and the shared options table are read, never widened. #7816 is not addressed here; it remains open and owns that question, per the cross-seat note posted there.


Generated by Claude Code

os-zhuang and others added 2 commits August 13, 2026 05:59
…n a select option (#8202, #8201)

Each of the three view/page shapes now names itself in its unknown-key
rejection, so the two deliberately contradictory answers to `disabled`
(rename on a field, boundary on a section / page component) can be told
apart. `SelectOptionSchema` inherits the #7887 ruling with its own
prescription: withdraw the option with per-option `visibleWhen`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9
…ditability boundary (#8202, #8201)

Reachability measured rather than inherited: both halves are parse-reachable
error strings, #8201's paragraph also reaches `dist/**/*.d.ts` hover, and the
generated reference page is measured NOT to render schema-level JSDoc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 13, 2026 6:26am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant