Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/per-shape-guidance-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@objectstack/spec": patch
---

docs(spec): a rejection says which shape refused the key, and a select option gets the editability boundary (#8202, #8201)

Two text-face halves finishing the story PR #8199 started. 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.

## #8202 — per-shape `surface` strings

`FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema` shared one
surface string, so every rejection opened `Unrecognized key(s) on this view/page
schema`. Harmless while the three answered a key identically; since #8199 they
do not:

- on a **field**, `disabled` gets a rename pointer — *Did you mean `disabled` →
`readonly`?*
- on a **section** or **page component**, it gets the editability-boundary
prescription — *write `readonly` on the form field(s) inside instead.*

Those two answers contradict each other by design, and the contradiction only
reads correctly if the message says which shape the author is on. It now does:
`this form field` / `this form section` / `this page component`. Per #8199's
placement rule the strings are filed at the three call sites rather than in the
shared options table — a table shared by three shapes can no more carry one
shape's name than it can carry one shape's prescription. The shared table keeps
the family name, and no live declaration may still report it
(`alias-integrity.test.ts`).

## #8201 — `SelectOptionSchema` inherits the #7887 ruling

The maintainer ruling of 2026-08-12 (a form section / page component gates
visibility only; editability lives on fields) was scoped to two shapes, and
#8199 left the third — a select option — with a bare rejection. It has the
boundary now, on the ruling's own premise re-measured for this shape rather than
by analogy: on objectui `origin/main` @ `aca27fa` the object-field pipeline these
options feed has **zero** per-option `disabled` consumers (`SelectField.tsx:161`
calls the root-level `disabled` "the single authority"; `RadioField.tsx:124`
reads only `props.disabled`). The shown-but-unselectable option that does exist
lives in objectui's SDUI vocabulary, which is not this shape.

Its prescription is **not** the siblings' text, because the siblings'
destination does not exist here — a section redirects to the fields inside, and
an option has no inside. Writing `disabled` on an option now points at the two
things that are real: per-option `visibleWhen` to withdraw that one option (the
only `*When` surface that binds `current_user`, ADR-0068 — and the rule
validator refuses a write of a value whose predicate is false), and
`readonly` / `readonlyWhen` on the **field** to freeze the whole picker. It
states what the platform honours today; a non-selectable field option remains a
spec decision someone may ask for, not a key an author writes.

## Why `patch`, measured rather than inherited

The criterion is whether the prose reaches a consumer:

- **Parse-reachable error string — YES.** Both halves are `unrecognized_keys`
message text an author reads out of a failed parse, and every claim is pinned
against a real `safeParse` error rather than against the options table.
- **`dist/**/*.d.ts` hover — YES for #8201.** 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; a schema's own JSDoc is not rendered (grepping the page
for `SelectOptionSchema`'s existing docblock text returns nothing, while its
per-key `.describe()` string hits). No `.describe()` was touched, so the page
and `authorable-surface.base.json` do not move.

One reachable consumer is enough and no public export is added, so `patch`.
36 changes: 36 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
import { z } from 'zod';
import { retiredKey } from '../shared/retired-key';
import { strictObject } from '../shared/strict-object';
// Package-internal, like `strict-object` itself — the `shared/index.ts` barrel
// deliberately does not re-export it, so nothing about the public API surface
// moves. No cycle back into this file: that module's only runtime import is
// `shared/visibility.ts`, which imports nothing at runtime.
import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
import { ExpressionInputSchema } from '../shared/expression.zod';
Expand Down Expand Up @@ -147,10 +152,41 @@ const FIELD_HISTORY =
'Until #4001 closed this shape these were dropped silently — the field was still created, '
+ 'minus whatever the key was meant to constrain, protect or compute.';

/**
* ## An option is offered or withheld — it is never "shown but unselectable"
* (#8201 — boundary, not gap)
*
* There is no `disabled`, `readonly` or `readonlyWhen` on a select option, and
* that is a **deliberate boundary** rather than a slot nobody added. It is the
* 2026-08-12 #7887 ruling reaching its third shape, on that ruling's own
* premise re-measured for this one: nothing in the object-field pipeline these
* options feed reads a per-option enabled/disabled flag — objectui's select and
* radio widgets treat the FIELD-level state as the single authority — so
* declaring one here would ship the ADR-0049 declared-but-unenforced shape.
* (A shown-but-unselectable option does exist in objectui's SDUI component
* family, but on that package's own option vocabulary, not this shape.)
*
* Writing one anyway stays a loud parse error — unchanged — and since #8201
* that error carries {@link SELECT_OPTION_EDITABILITY_GUIDANCE}, which points
* at the two things that are real: {@link SelectOptionSchema.visibleWhen} to
* withdraw THIS option (per record or, uniquely on this surface, per
* `current_user` — ADR-0068), and `readonly` / `readonlyWhen` on the FIELD to
* freeze the whole picker.
*
* If a non-selectable field option ever earns a real reader, that is a spec
* decision that widens the accepted set — this boundary records what the
* platform honours today, not a claim that the answer can never change.
*/
export const SelectOptionSchema = lazySchema(() => strictObject({
surface: 'this select option',
history: FIELD_HISTORY,
aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', isDefault: 'default', selected: 'default', colour: 'color', visible: 'visibleWhen', showWhen: 'visibleWhen' },
// #8201. No alias row for the editability family, per the same red line the
// mother ruling drew: an alias names a key the shape must then accept, and
// this shape accepts none of them. The set consumes those spellings before
// the rename channel runs, and none of the alias keys above is a member, so
// no existing pointer is shadowed (`alias-integrity.test.ts`, #7889).
guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE],
}, {
label: z.string().describe('Display label (human-readable, any case allowed)'),
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
Expand Down
34 changes: 31 additions & 3 deletions packages/spec/src/shared/alias-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,10 +971,18 @@ describe('alias integrity — every table is a true claim about its schema', ()
// re-opening the blind spot.
const bySurface = new Map(SURFACES.map((s) => [s.options.surface, s]));

const visibility = [...SURFACES].filter((s) => s.options.surface === 'this view/page schema');
// Three call sites share the options: FormFieldBase (via strictObjectError),
// FormSection, PageComponent — distinct shapes, so distinct declarations.
// Three call sites share the OPTIONS; since #8202 they no longer share the
// `surface` string, so the selector names the three shapes instead of the
// one string they used to report. Same claim as before — those three
// declarations exist and each carries the ADR-0089 set — and a strictly
// stronger selector: it fails if a shape stops declaring through the
// folded table AND if one of them silently loses its own name.
const VISIBILITY_FAMILY = ['this form field', 'this form section', 'this page component'];
const visibility = [...SURFACES].filter((s) => VISIBILITY_FAMILY.includes(s.options.surface));
// FormFieldBase (via strictObjectError), FormSection, PageComponent —
// distinct shapes, so distinct declarations.
expect(visibility.length).toBeGreaterThanOrEqual(3);
expect(new Set(visibility.map((s) => s.options.surface)).size).toBe(3);
for (const v of visibility) {
expect(v.options.guidanceSets?.map((g) => g.name)).toContain('VISIBILITY_KEY_PATTERN');
}
Expand All @@ -992,6 +1000,26 @@ describe('alias integrity — every table is a true claim about its schema', ()
expect(Object.keys(tenancy!.options.guidance ?? {}).sort()).toEqual(['crossTenantAccess', 'strategy']);
});

it('no live surface still reports the shared view/page FAMILY name (#8202)', () => {
// `VISIBILITY_STRICT_OPTIONS.surface` is the family's name, and every
// consumer overrides it with its own shape's (`'this form field'` /
// `'this form section'` / `'this page component'`) — because since #7887
// the shapes answer the same key in two contradictory ways, and the answer
// is only readable if the message says which shape it came from.
//
// 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. This walk sees every table the
// package constructs, which makes it the one place that can notice.
const inherited = SURFACES
.filter((s) => s.options.surface === 'this view/page schema')
.map((s) => `${s.options.surface} (keys: ${Object.keys(s.shape).slice(0, 4).join(', ')}…)`);
expect(
inherited,
'these declarations spread the shared visibility options without naming their own shape',
).toEqual([]);
});

it('the two maps #6619 MISSED are folded and judged here too (#6805)', () => {
// #6619's inventory was two short, and both survivors were the same shape
// as the three above — `unrecognized_keys` prescription tables attached to
Expand Down
Loading
Loading