Commit c52c49d
feat(spec)!: FieldSchema refuses a select / radio with neither options nor picklist (#21390)
Fixes #20827
Clause-②: yes
Ruling A (record `5910124148`), step two: `FieldSchema` refuses a
`select` / `radio` field with neither `options` nor `picklist` at parse,
on the `lookup`-without-`reference` precedent (`0fb8760bec`). Step one
(`5917187437`) measured the census and the Studio order; the objectui
half landed and `.objectui-sha` `31971ff1e28f` carries it
(`5947557233`).
## The door
- `packages/spec/src/data/field.zod.ts`, `FieldSchema`'s superRefine,
beside the `reference` check: a `custom` issue on the `options` path
whose message names the field type and both remedies (`options: [{
label, value }]`, or `picklist: 'NAME'` for a shared list), says what
the hole costs (an empty control, and no server-side value validation),
and offers `text` when any value is meant to be allowed. No tracker
number in the runtime string.
- **One predicate, not a second one.** The door applies the ADR-0078
completeness predicate itself: `checkFieldCompleteness(field)` has a
`field/choice-without-options` finding at `error` severity. That module
imports nothing at runtime, so the new edge (data to kernel) closes no
cycle, and no export is added. Three facts follow from it rather than
being restated: `options: []` is the same hole as a missing key; a
`picklist` reference is a source; the types are exactly `select` /
`radio` (`checkboxes` stays a warning, `multiselect` / `tags` stay
free-form). The same reading is what objectui's guard derivation test
uses (`deriveChoiceTypesRequiringOptions`).
- The exclusivity block's comment no longer claims "neither" is only the
completeness finding. The `options` and `picklist` TSDoc and
`.describe()` texts state the rule; the reference pages were regenerated
through `check:generated --fix` (only `check:docs` was stale).
- ADR-0078's author-time rule and the registration warning are not
edited. The door is one more gate.
## Fixture census, measured with the door on
Re-measured at base `5fd4855a9a` with the full `@objectstack/spec` suite
(6 red in 5 files) and the full `@objectstack/metadata-protocol` suite
(9 red in 2 files). That matches step one exactly.
| Red with the door on | Why it went red | Disposition |
|:--|:--|:--|
| `field.test.ts:2155` (radio + `multiple: false`) | oversight: pins
`multiple`, not choices | one option added |
| `field-autonumber-default-unique.test.ts:45` (`minimalField`) |
oversight: "minimal valid input per type" | `select` / `radio` get one
option |
| `filter-number-comparand-declared-type.ts` fixture (`fixtureFieldFor`)
| oversight: "each is a legal FieldSchema input" | single-choice types
get one option (`SINGLE_OPTION_TYPES`); the fixture interface gains an
optional `options` member |
| `filter-text-operator-declared-type.ts` fixture (`fixtureFieldFor`) |
same | same |
| `picklist.test.ts:129`, 2 tests | **pinned the old acceptance by
design** ("neither is the completeness gate's error, not a parse
refusal") | flipped into a refusal pin; its `checkFieldCompleteness`
assertions are kept unchanged |
| `metadata-protocol`: 9 tests from `legacyObjectRow`'s `status: { type:
'select' }` (`protocol.stored-conversions.test.ts`,
`protocol.stored-migration.test.ts`) | oversight: the row exists to test
`conditionalRequired` | one option added (`sent`, the value its
`requiredWhen` reads) |
One fixture beyond the census: `canonicalObjectRow` in
`protocol.stored-migration.test.ts` carries the same optionless
`select`. It caused no red (the pass never validates a row that needs no
conversion), but its doc says "already canonical — the shape every row
ends up in", which the door makes false, so it got the same option.
## Stored rows: the disposition
No migration can invent the options an author meant, so a stored row is
read and named, never rewritten. That was step one's reading, and it is
now pinned in `@objectstack/metadata-protocol`:
- `getMetaItem` still serves such a row, with `_diagnostics.valid:
false` naming `fields.status.options`;
- `loadMetaFromDb` counts it `invalid: 1` and still registers it;
- `migrateStoredMetadata({ apply: true })` on a legacy row that also
carries an optionless `select` reports it `failed` (spec validation),
writes no history row, and leaves the stored bytes as they were.
The changeset tells an operator to find such rows through `GET
/api/v1/meta/diagnostics` or the boot log's
`field/choice-without-options` lines, and says the `os migrate meta
--stored` preview does not find them, because it does not validate.
**ADR-0087 marker:** `not-required (no-migration-prescription)`. Two
existing keys are narrowed in validity, and none is removed, renamed or
re-shaped. No conversion entry can supply the missing intent, and the
gate's other categories are closed on facts (the marker says why). That
is the precedent's category. Production `sys_metadata` cannot be
measured from here, so the marker text takes the ruling's "some rows
exist" arm and names the read-and-name path, not a migration.
## Semver
`@objectstack/spec`: `minor` with a **BREAKING** header, as the
precedent shipped. The changeset is
`.changeset/20827-choice-door-select-radio-needs-options.md`. Gate
lines:
- `check-adr-0087-registration`: `1 declared-breaking changeset(s), each
carrying an ADR-0087 disposition` · `[BREAKING+bang] not-required
(no-migration-prescription)`
- `check-changeset-no-major`: `This diff introduces no major bump`
- `check-empty-changeset`: `No empty-frontmatter changeset introduced by
this diff (1 declaring changeset(s) added)`
No other package publishes a change: the `metadata-protocol` edits are
tests only, and `content/docs` does not publish.
## Reverse verification (ablation)
The door's predicate was mutated through `scripts/ablation-replace.mjs`
(`FIELD_CHOICE_WITHOUT_OPTIONS` to the literal `'ablation-20827-never'`;
anchor 1 to 0, blob `ea9313c768` to `9654443823`). Then
`@objectstack/spec` was rebuilt, and `ablation-dist-preflight` found the
marker in 24 built files.
- **Mutate leg: red.** 8 spec pins red: the 6 refusal pins in
`field.test.ts` and the 2 rewritten pins in `picklist.test.ts`. 3
`metadata-protocol` pins red: the two `stored-conversions` pins and the
`stored-migration` `failed` pin. The positive controls stayed green (one
option, `picklist` only, and `multiselect` / `checkboxes` / `tags` with
neither).
- **Restore leg: green.** Restored with `git checkout HEAD --`: blob
equal to HEAD and `git diff HEAD` empty. After a rebuild, `--absent`
reported the marker in 0 of 230 built files and the tree clean. Then
325/325 spec tests and 56/56 `metadata-protocol` tests passed.
## Tests (head `ee5b089bbe`, after merging `origin/main`)
- `@objectstack/spec` full suite: 646 files, 18391 passed, 1 todo.
- `@objectstack/spec` `typecheck`: green, including
`check:test-typecheck`.
- `@objectstack/spec` `check:generated`: all 15 artifacts up to date.
- `@objectstack/metadata-protocol` `typecheck`: green.
- These consumer suites ran with the door on, before the merge, at
`a9473b8d91`. The merge brought no change to these packages' sources.
- `@objectstack/metadata-protocol`: 201 files, 2983 passed, 19 skipped.
- `@objectstack/objectql`: 363 files, 7277 passed. This includes the two
engine door suites that consume the changed spec fixtures.
- `@objectstack/lint`: 119 files, 5575 passed.
- `@objectstack/metadata`: 56 files, 836 passed.
- `@objectstack/runtime`: 306 files, 5081 passed, 11 skipped.
- `dispatch-gates --ran`: 110 derived families, 109 run, 1 NOT MEASURED,
0 unrun. The one: `check:dual-build-cjs-loads` exits 3 because it needs
every package's `dist`. As a declared narrowing, all 19 `require`
entries of `@objectstack/spec` load under CJS, and the door is live
through `dist/data/index.js`.
- `check:doc-authoring` and `check:nul-bytes` are green.
## Acceptance notes
- **Blueprint (A6).** `packages/spec/src/ai/solution-blueprint.zod.ts`,
`BlueprintFieldSchema.options` (optional) and `StrictField.options`
(`.nullable()`), still let a blueprint `select` / `radio` carry no
options. Inside objectstack and objectui, nothing expands a blueprint
into `FieldSchema` input. `apply_blueprint` lives outside both
repositories; objectui only renders its progress and plan cards. The
module's own header says that the expansion validates against the
per-type schema at write time. So once this door ships, an optionless
blueprint `select` is refused loudly at that write, not silently stored.
The gap is that the blueprint accepts what the door refuses, so the
refusal lands one step late, on the expanded artifact, not on the AI's
structured output. **Not edited here.** For the PM to file: align
`BlueprintFieldSchema` / `StrictField` `options` with the door for
`select` / `radio`.
- **objectui pin (A8).**
`packages/data-objectstack/src/object-metadata-write-guard.derivation.test.ts`,
the describe "the installed server still ACCEPTS a choice with no
options", goes red when objectui next bumps `@objectstack/spec` to a
release carrying this door. Its own comment prescribes the follow-up:
rewrite the guard's docblock paragraph into the relationship form and
turn the block into a refusal pin at `options`. That is the objectui
lane's follow-up. No objectui edit here.
- **`skills/**` hit, not edited (governed surface).**
`skills/objectstack-upgrade/references/examples-upgrade.md:105` teaches
`status: { type: 'select', required: true, storage: { notNull: true } }`
inside `ObjectSchema.create(...)`. With this door that example is
refused at parse. It needs one `options` entry, in a skills-lane PR.
- **Docs.** `content/docs/deployment/troubleshooting.mdx` had an entry
quoting an error message no code emits ("Required property missing:
options") and listing `multiselect` / `checkboxes` as refused. It now
quotes the real refusal, the right two types and the stored-row reading.
`validation-rules.mdx` called `options` "Required" on all four option
types; `multiselect` / `checkboxes` now read optional, as the runtime
treats them. `field-types.mdx` names `picklist` as the alternative. The
`formulas.mdx` `ObjectSchema.create` example got options. No content
page said an optionless `select` parses or is only a lint finding.
- **The Clause-② line** is copied from the claim (`yes`). The diff
narrows `FieldSchema`'s accept set, which is BREAKING and is carried by
the changeset banner. It also widens one published type: the two
exported door-fixture interfaces gain an optional `options` member. Read
together, `yes (narrowing)` is the fuller spelling. The claim's spelling
is kept, and the two gates pass on it.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 69a12a0 commit c52c49d
16 files changed
Lines changed: 307 additions & 37 deletions
File tree
- .changeset
- content/docs
- data-modeling
- deployment
- references
- data
- system
- packages
- metadata-protocol/src
- spec/src/data
Lines changed: 50 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
234 | 234 | | |
235 | 235 | | |
236 | 236 | | |
237 | | - | |
| 237 | + | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
290 | 290 | | |
291 | 291 | | |
292 | 292 | | |
| 293 | + | |
293 | 294 | | |
294 | 295 | | |
295 | 296 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | | - | |
| 218 | + | |
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
| |||
224 | 224 | | |
225 | 225 | | |
226 | 226 | | |
227 | | - | |
| 227 | + | |
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
231 | 231 | | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | | - | |
| 235 | + | |
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | 242 | | |
243 | | - | |
| 243 | + | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
| 54 | + | |
55 | 55 | | |
56 | | - | |
| 56 | + | |
57 | 57 | | |
58 | | - | |
| 58 | + | |
59 | 59 | | |
60 | 60 | | |
61 | 61 | | |
62 | | - | |
| 62 | + | |
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
73 | 76 | | |
74 | 77 | | |
| 78 | + | |
| 79 | + | |
75 | 80 | | |
76 | 81 | | |
77 | 82 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | | - | |
78 | | - | |
| 77 | + | |
| 78 | + | |
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | | - | |
242 | | - | |
| 241 | + | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
| |||
572 | 572 | | |
573 | 573 | | |
574 | 574 | | |
575 | | - | |
576 | | - | |
| 575 | + | |
| 576 | + | |
577 | 577 | | |
578 | 578 | | |
579 | 579 | | |
| |||
0 commit comments