From 71e7e5dbb857b89eac29f857cf27f9b84da47e3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 15:21:22 +0000 Subject: [PATCH] fix(types,fields): correct three false spec-alignment claims and pin the real boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doc comments asserted that the installed `@objectstack/spec` declares a key it in fact REFUSES BY NAME. Measured on 17.2.0, each with a control that accepts the same payload minus the key: SelectOptionSchema.description unrecognized_keys (shape is exactly {label,value,color,default,visibleWhen}) FieldSchema.rows unrecognized_keys (all four of textarea/markdown/html/richtext) The keys stay declared and stay consumed (objectui#6153 LookupField searches an option's `description`; objectui#6140 RichTextField reads `rows`). Only the attribution was wrong, and it mattered: FieldSchema routes a select field's `options` through the strict option schema, so authoring `description` on an option fails the WHOLE field with 422 INVALID_METADATA. The comments were inviting that write; they now name the boundary instead. A false canonical claim is a planted premise for the next agent — the failure class scripts/check-spec-symbol-derivation.mjs exists to prevent. It could not see these: it reads only the comment block attached to a DECLARATION, and validates a citation only at SYMBOL granularity. Both claims sit on MEMBERS and dangle at the member (`SelectOptionSchema` is a live export; `.description` is not a key of it). No type, schema, export or runtime path changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- ...014-select-option-spec-claim-correction.md | 35 +++++++ .../LookupField.optionDescription.test.tsx | 5 +- .../select-option-spec-extension-7014.test.ts | 99 +++++++++++++++++++ packages/types/src/field-types.ts | 28 ++++-- 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 .changeset/7014-select-option-spec-claim-correction.md create mode 100644 packages/types/src/__tests__/select-option-spec-extension-7014.test.ts diff --git a/.changeset/7014-select-option-spec-claim-correction.md b/.changeset/7014-select-option-spec-claim-correction.md new file mode 100644 index 0000000000..c086db0875 --- /dev/null +++ b/.changeset/7014-select-option-spec-claim-correction.md @@ -0,0 +1,35 @@ +--- +'@object-ui/types': patch +--- + +Correct three false `@objectstack/spec` alignment claims on field metadata, and pin the +real boundary (objectui#7014). + +**No contract change.** No type, schema, export or runtime path moves. What changes is +published JSDoc — the text that reaches your editor tooltips through `.d.ts` — which was +asserting the opposite of what the spec does. + +Three doc comments claimed the installed `@objectstack/spec` DECLARES a key that it in +fact **refuses by name**. Measured on `@objectstack/spec@17.2.0`, each paired with a +control that accepts the same payload minus the key: + +- `SelectOptionMetadata.description` said it "Aligns `@objectstack/spec` + `SelectOptionSchema.description`". That schema is `.strict()` over exactly + `{label, value, color, default, visibleWhen}`; `description` fails with + `unrecognized_keys`. +- `MarkdownFieldMetadata.rows` and `HtmlFieldMetadata.rows` said `@objectstack/spec` + `FieldSchema.rows` declares the key "authorable on exactly the multiline editor + types". `FieldSchema` refuses `rows` by name on all four of + textarea/markdown/html/richtext. + +The keys themselves stay declared and stay consumed — `LookupField` searches an option's +`description` (objectui#6153) and `RichTextField` reads `rows` (objectui#6140). Only the +attribution was wrong, and it mattered in a specific way: `FieldSchema` routes a select +field's `options` through the strict option schema, so authoring `description` on an +option makes `PUT /api/v1/meta/object/:name` fail the **whole field** with a 422 +`INVALID_METADATA`. The comments were inviting exactly that write. They now say these are +objectui-side read-model extensions that must never reach authored object metadata. + +A new pin (`select-option-spec-extension-7014.test.ts`) asserts the spec's option key set +and each by-name refusal, so if the spec ever adopts one of these names the claim is +re-opened loudly instead of silently becoming true. diff --git a/packages/fields/src/widgets/LookupField.optionDescription.test.tsx b/packages/fields/src/widgets/LookupField.optionDescription.test.tsx index d455bc3f5b..51593164b9 100644 --- a/packages/fields/src/widgets/LookupField.optionDescription.test.tsx +++ b/packages/fields/src/widgets/LookupField.optionDescription.test.tsx @@ -8,8 +8,9 @@ * `opt.description` alongside the label, and `recordToOption` emits the same * key for fetched records. What the card changed is the CONTRACT: * `SelectOptionMetadata` (the declared type of `LookupFieldMetadata.options`) - * now declares `description?: string`, aligned with `@objectstack/spec`'s - * `SelectOptionSchema.description`, so the fixture below is an ANNOTATED + * now declares `description?: string` as an objectui-side extension - the + * installed `@objectstack/spec` 17.2.0 has no such key and REFUSES it BY NAME + * on `SelectOptionSchema` (objectui#7014) - so the fixture below is an ANNOTATED * literal — the excess-property check that used to refuse this exact document * is the compile half of the pin, and the search behaviour is the runtime * half. Behaviour unchanged by design; the test would have passed before the diff --git a/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts b/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts new file mode 100644 index 0000000000..11a1a4bf6e --- /dev/null +++ b/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The objectui-side select-option / editor keys are extensions the spec + * REFUSES BY NAME — pinned so the comments that say so cannot rot (objectui#7014). + * + * Why this exists. `SelectOptionMetadata` (packages/types/src/field-types.ts) + * and the two `rows` declarations beside it each carried a doc comment + * asserting the installed `@objectstack/spec` DECLARES the key: + * + * "Aligns `@objectstack/spec` `SelectOptionSchema.description`" + * "`@objectstack/spec` `FieldSchema.rows` (a positive integer, authorable …)" + * + * Measured on `@objectstack/spec@17.2.0`, all three are false: the spec has no + * such key and rejects it BY NAME. A false canonical claim is not stale + * documentation — it is a planted premise for the next agent, which is the + * whole failure class `scripts/check-spec-symbol-derivation.mjs` exists to + * prevent. That gate could not see these, because it reads only the comment + * block attached to a DECLARATION and validates a citation only at SYMBOL + * granularity; both claims sit on MEMBERS and dangle at the member + * (`SelectOptionSchema` is a live export, `.description` is not a key of it). + * + * The keys themselves are legitimate and consumed — objectui#6153 for the + * option `description` (LookupField searches it), objectui#6140 for `rows` + * (RichTextField reads it). What was wrong was the attribution. So this pin + * asserts the BOUNDARY rather than removing anything: these are read-model + * extensions that must never reach authored object metadata. + * + * Every assertion below pairs the refusal with a CONTROL that accepts the same + * payload minus the key, so a red here means "the key's status changed", never + * "the fixture drifted". + */ + +import { describe, it, expect } from 'vitest'; +import { SelectOptionSchema as SpecSelectOptionSchema, FieldSchema } from '@objectstack/spec/data'; + +/** Keys of the spec's select option, as installed. */ +const SPEC_OPTION_KEYS = Object.keys(SpecSelectOptionSchema.shape).sort(); + +/** A valid option — `value` is a system identifier, min length 2. */ +const validOption = { label: 'High', value: 'high' } as const; + +/** Pull the `unrecognized_keys` issue naming `key`, or undefined. */ +const refusedByName = (result: { success: boolean; error?: { issues: readonly any[] } }, key: string) => + result.success + ? undefined + : result.error!.issues.find( + (i) => i.code === 'unrecognized_keys' && (i.keys ?? []).includes(key) + ); + +describe('spec SelectOptionSchema is the boundary these extensions sit outside', () => { + it('declares exactly the five keys the corrected comments name', () => { + // If the spec ever ADDS `description`/`icon`/`disabled`, this fails and the + // comments in field-types.ts must be re-corrected rather than left stale. + expect(SPEC_OPTION_KEYS).toEqual(['color', 'default', 'label', 'value', 'visibleWhen']); + }); + + it('accepts the control option', () => { + expect(SpecSelectOptionSchema.safeParse(validOption).success).toBe(true); + }); + + for (const key of ['description', 'icon', 'disabled'] as const) { + it(`refuses the objectui-only key \`${key}\` BY NAME`, () => { + const res = SpecSelectOptionSchema.safeParse({ ...validOption, [key]: key === 'disabled' ? true : 'x' }); + expect(res.success).toBe(false); + expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); + }); + } +}); + +describe('FieldSchema routes options through that strict schema', () => { + const field = (options: unknown[]) => ({ name: 'status', type: 'select', label: 'Status', options }); + + it('accepts a field whose options carry only spec keys', () => { + expect(FieldSchema.safeParse(field([validOption])).success).toBe(true); + }); + + it('fails the WHOLE field when an option carries `description`', () => { + const res = FieldSchema.safeParse(field([{ ...validOption, description: 'help' }])); + expect(res.success).toBe(false); + expect(refusedByName(res, 'description')).toBeDefined(); + }); +}); + +describe('FieldSchema refuses `rows` by name on every multiline editor type', () => { + const base = (type: string) => ({ name: 'body', type, label: 'Body' }); + + for (const type of ['textarea', 'markdown', 'html', 'richtext'] as const) { + it(`control: \`${type}\` without \`rows\` is accepted`, () => { + expect(FieldSchema.safeParse(base(type)).success).toBe(true); + }); + + it(`\`${type}\` with \`rows\` is refused BY NAME`, () => { + const res = FieldSchema.safeParse({ ...base(type), rows: 4 }); + expect(res.success).toBe(false); + expect(refusedByName(res, 'rows'), `expected unrecognized_keys naming 'rows' on ${type}`).toBeDefined(); + }); + } +}); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 29c9c8c918..a7f2436216 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -205,9 +205,13 @@ export interface MarkdownFieldMetadata extends BaseFieldMetadata { * `RichTextField` — the one widget behind the `markdown`/`html`/`richtext` * registry keys — has always read `rows` off this metadata (default 8) while * no rich-content type declared it, so the running widget honoured a key an - * annotated literal rejected. Aligns the `TextareaFieldMetadata` precedent - * and `@objectstack/spec` `FieldSchema.rows` (a positive integer, authorable - * on exactly the multiline editor types textarea/markdown/html/richtext). + * annotated literal rejected. Follows the `TextareaFieldMetadata` precedent. + * + * WARNING - NOT a spec key. Measured on the installed `@objectstack/spec` + * 17.2.0: `FieldSchema` REFUSES `rows` BY NAME (`unrecognized_keys`) on all + * four of textarea/markdown/html/richtext, with the same payload minus + * `rows` accepted as the control. It is an objectui render hint and must not + * be written into authored object metadata (objectui#7014). * The four inert editor keys the same ruling measured (`toolbar`/`preview`/ * `minHeight`/`maxHeight`) stay deliberately undeclared — nothing reads them. */ @@ -224,8 +228,8 @@ export interface HtmlFieldMetadata extends BaseFieldMetadata { * Height of the INLINE editor, in text rows. Same declaration as * `MarkdownFieldMetadata.rows` (objectui#6140 Option A ruling — see the * docblock there): `RichTextField` reads it for all three registry keys it - * serves, and `@objectstack/spec` `FieldSchema.rows` declares it for the - * multiline editor types. + * serves. WARNING - NOT a spec key either; see the measured refusal in the + * docblock there (objectui#7014). */ rows?: number; } @@ -320,9 +324,17 @@ export interface SelectOptionMetadata { * (`opt.description && opt.description.toLowerCase().includes(q)`) and its * `recordToOption` emits the same key for fetched records — while this type * never declared it, so the behaviour was real for a key no annotated - * literal could carry. Aligns `@objectstack/spec` - * `SelectOptionSchema.description`; renderers may show it as supporting - * text. + * literal could carry. Renderers may show it as supporting text. + * + * WARNING - objectui-side extension, NOT a spec key. Measured on the + * installed `@objectstack/spec` 17.2.0: `SelectOptionSchema` is `.strict()` + * over exactly `{label, value, color, default, visibleWhen}` and REFUSES + * `description` BY NAME (`unrecognized_keys`), with the same option minus + * the key accepted as the control. `FieldSchema` routes `options` through + * that schema, so writing this key into authored object metadata fails the + * whole field. It lives on the runtime READ model the renderers consume and + * must never reach the metadata payload. Pinned by + * `__tests__/select-option-spec-extension-7014.test.ts` (objectui#7014). */ description?: string; color?: string;