From f51cbf43a73a85718c63a50feae6c84a39b9af09 Mon Sep 17 00:00:00 2001 From: os-sam Date: Sun, 13 Sep 2026 04:23:02 +0000 Subject: [PATCH] fix(types): retire `icon` from the record:highlights `fields[]` entry arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` `RecordHighlightsProps.fields[]`'s object arm declares exactly `name`/`label`/`type`/`readonly` behind a `never` catchall — it is `$strict`, so an unlisted key is REFUSED, not stripped, and the refusal takes the whole document with it. This repo declared a fifth key, `icon`, on three layers: the published type, the renderer's entry normalizer, and the registry manifest's `fields` input description. All three were already broken rather than working. No author could get an `icon` past the contract, so the normalizer's read was unreachable and the manifest's promise was impossible to honour. `HeaderHighlight` renders no `.icon` on the far side either. Retiring makes the three layers agree with the one layer that has authority; widening the contract instead is an upstream decision on its own card. Re-measured against the installed pin (17.4.0) with three controls: a declared key parses green, an arbitrary key is refused with the SAME `invalid_union` code as `icon`, and the bare-string arm is unaffected. `sections[].icon` on `RecordDetailsComponentProps` is a different key on a different face — the contract declares it and `DetailSection` draws it — and is deliberately untouched. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- ...80-record-highlights-entry-icon-retired.md | 70 +++++ ...recordHighlightsInputs.spec-parity.test.ts | 28 ++ packages/plugin-detail/src/index.tsx | 2 +- .../src/renderers/record-highlights.tsx | 13 +- ...record-highlights-fields-icon-9280.test.ts | 267 ++++++++++++++++++ packages/types/src/record-components.ts | 33 ++- 6 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 .changeset/9280-record-highlights-entry-icon-retired.md create mode 100644 packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts diff --git a/.changeset/9280-record-highlights-entry-icon-retired.md b/.changeset/9280-record-highlights-entry-icon-retired.md new file mode 100644 index 0000000000..5707b22816 --- /dev/null +++ b/.changeset/9280-record-highlights-entry-icon-retired.md @@ -0,0 +1,70 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +--- + +Retire `icon` from the `record:highlights` `fields[]` entry, across all three layers that +carried it (objectui#9280). + +**Breaking, deliberately.** `{ name: 'amount', icon: 'dollar-sign' }` on a +`record:highlights` entry no longer type-checks. Nothing that worked stops working: the key +was never authorable in the first place, and this change is what makes `tsc` say so. + +`@objectstack/spec` `RecordHighlightsProps.fields[]`'s object arm declares exactly +`name`/`label`/`type`/`readonly` behind a `never` catchall — it is `$strict`, so an unlisted +key is REFUSED, not stripped, and the refusal takes the WHOLE document with it. Re-measured +on this branch against the installed pin (17.4.0) rather than inherited from the card, with +three controls in the same pass so the instrument is not blind: + +``` +fields[] object-arm keys : ["label","name","readonly","type"] catchall: never ($strict) +{ fields: [{ name:'x', label:'L' }] } GREEN <- CONTROL +{ fields: [{ name:'x', icon:'star' }] } RED invalid_union at fields.0 +{ fields: [{ name:'x', zzzNonsense:1 }] } RED invalid_union at fields.0 <- CONTROL +{ fields: ['x'] } GREEN <- CONTROL +``` + +A declared key parses green, so the arm is not refusing everything; an arbitrary key is +refused with the SAME code as `icon`, so `icon` was not special-cased; the bare-string arm is +untouched, so only the object arm moved. + +FROM → TO, per layer: + +- `packages/types/src/record-components.ts` — `RecordHighlightsComponentProps.fields[]`'s + object arm: `{ name; label?; icon?; type?; readonly? }` → **`{ name; label?; type?; readonly? }`**. + The key is **removed, not tombstoned**: the contract's arm is `$strict`, so the refusal an + author needs already exists upstream and arrives named (`invalid_union` at the entry). A + `?: never` tombstone buys nothing here — it is the remedy for a non-strict mirror that + would otherwise strip in silence, which is not this arm. +- `packages/plugin-detail/src/renderers/record-highlights.tsx` — the entry normalizer stops + copying `icon: f?.icon` into the normalized entry. That read was **unreachable**, not + merely unused: no author could feed it past the `$strict` arm, and `HeaderHighlight` + renders no `.icon` on the far side either, so the copy had no consumer in either + direction. +- `packages/plugin-detail/src/index.tsx` — the registry manifest's `fields` input description + sketched the entry as `{name,label?,icon?,type?,readonly?}` → **`{name,label?,type?,readonly?}`**. + The `inputs` ARE the published contract (`gen-manifest.ts` serializes them into + `sdui.manifest.json` and `sdui-intrinsics.d.ts`), so leaving the sketch standing would have + gone on teaching AI and human authors a key that gets the whole document refused at publish. + +**Migration.** Nothing in this repository has to change. An in-repo census over every +`record:highlights` region (109 regions, all tracked files) scores an entry-level `icon` +**0**, against `readonly` **23** in the same pass over the same regions — the instrument was +not blind. If you author `icon` on a highlight entry in your own metadata, delete it: it was +already causing the publish to refuse the document whole. Whether a highlight chip *should* +be able to carry an icon is a separate question this change does not answer — the route for +that is an upstream `@objectstack/spec` widening, on its own card, ⛔ never a redeclaration +here. + +⚠️ `sections[].icon` on `RecordDetailsComponentProps` is a **different key on a different +face** and is **unaffected**: the contract declares it and `DetailSection` genuinely draws +it. Two keys sharing a word in one file are not the same key. + +Pinned in `packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts` across +three instruments that do not see the same thing — a `tsc` `@ts-expect-error` leg with a +`{name,label}` control that stays green, `safeParse` legs against the installed spec +artifact, and a source-text read whose lit control is that very `sections[].icon` member, so +an empty result on the highlights arm is a reading rather than a matcher that cannot match. +`packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts` gains the +REVERSE direction it was missing: it already failed when a spec entry key went undocumented, +and now also fails when the description advertises an entry key the spec refuses. diff --git a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts index dba48e422b..4d34ccf00f 100644 --- a/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts @@ -147,6 +147,34 @@ describe('record:highlights — registry inputs vs @objectstack/spec', () => { expect(description).toContain('readonly'); }); + it('the `fields` entry-shape sketch advertises no key the spec refuses', () => { + // The REVERSE direction of the check above, and the one objectui#9280 was + // filed for: the description sketched the entry as + // `{name,label?,icon?,type?,readonly?}` while the spec's object arm is + // `$strict` over four keys, so the manifest was teaching authors a key that + // gets the WHOLE document refused at publish. Under-documenting a key is a + // discoverability bug; over-advertising one is an impossible promise. + const description = fieldsInput()?.description ?? ''; + const sketch = /\{([a-zA-Z?,\s]+)\}/.exec(description)?.[1]; + expect(sketch, 'the description must keep an entry-shape sketch to check').toBeDefined(); + + const advertised = (sketch ?? '') + .split(',') + .map((k) => k.trim().replace(/\?$/, '')) + .filter(Boolean) + .sort(); + + // Derived from the spec at runtime, so a spec that WIDENS the arm fails + // here instead of leaving the sketch quietly short. + expect(advertised).toEqual(specEntryKeys().sort()); + + // ⭐ LIT CONTROL for this matcher: it really does read keys out of the + // sketch rather than returning an empty list that trivially compares + // equal. `name` is the one key the arm cannot lose. + expect(advertised).toContain('name'); + expect(advertised).not.toContain('icon'); + }); + it('declares no top-level input the spec does not accept', () => { const allowed = new Set(specTopLevelKeys()); const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 00667a4a67..3d02b88886 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -637,7 +637,7 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { // un-gated (pinned as `MULTI_KIND_MEMBER_CONTRACTS` in the repo-wide parity // gate). objectui#3407 / objectstack#5176. inputs: [ - { name: 'fields', type: 'array', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,icon?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook\'s own write-back.' }, + { name: 'fields', type: 'array', required: true, description: 'Key fields to highlight (1-7), bare names or {name,label?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook\'s own write-back.' }, { name: 'layout', type: 'enum', enum: ['horizontal', 'vertical'], description: 'Layout orientation for highlight fields' }, ], }); diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index 2e42232dda..5f48ac03b3 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -50,7 +50,8 @@ export const RecordHighlightsRenderer: React.FC = required.every((p) => perms.can(objectName, p as any)); const rawFields: any[] = Array.isArray(schema.fields) ? schema.fields : []; - // Normalize: accepts either bare strings or { name, label?, icon?, type?, readonly? }. + // Normalize: accepts either bare strings or { name, label?, type?, readonly? } + // — the four keys the contract's object arm declares, and no fifth. // // `readonly` is copied through deliberately: HeaderHighlight's editability // gate has always consulted `field.readonly`, but this map used to rebuild @@ -59,13 +60,21 @@ export const RecordHighlightsRenderer: React.FC = // never fire from authored metadata (objectstack#5077). Rebuilding key-by-key // rather than spreading keeps the entry shape closed — an undeclared key is // still not silently forwarded to the strip. + // + // `icon` was copied through here until objectui#9280 and that read was + // UNREACHABLE, not merely unused: `@objectstack/spec` + // `RecordHighlightsProps.fields[]`'s object arm is `$strict` (a `never` + // catchall over `name`/`label`/`type`/`readonly`), so a document carrying + // `icon` is refused WHOLE at publish and no author could ever feed this + // branch. `HeaderHighlight` renders no `.icon` either, so the copy also had + // no consumer on the far side. Retired in both directions rather than left + // standing as a read for a key nothing can author. const normalized = rawFields.map((f) => typeof f === 'string' ? { name: f } : { name: f?.name, label: f?.label, - icon: f?.icon, type: f?.type, readonly: f?.readonly === true, }, diff --git a/packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts b/packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts new file mode 100644 index 0000000000..15ffee8539 --- /dev/null +++ b/packages/types/src/__tests__/record-highlights-fields-icon-9280.test.ts @@ -0,0 +1,267 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#9280 — `RecordHighlightsComponentProps.fields[]`'s object arm + * declared a FIFTH key, `icon`, where the contract's arm is `$strict` over + * four. + * + * The defect: this package declared + * `{ name; label?; icon?; type?; readonly? }` while `@objectstack/spec` + * `RecordHighlightsProps.fields[]`'s object arm declares + * `name`/`label`/`type`/`readonly` behind a `never` catchall. `$strict` is the + * load-bearing half: an unlisted key is REFUSED, not stripped, and the refusal + * takes the whole document with it. So `{ name: 'amount', icon: 'dollar-sign' }` + * type-checked here and was refused at publish with `invalid_union` — a green + * local build and a rejection at the only layer that matters. Direction is + * contract-first (Commandment #0.1, and triage's ruling on the card): the + * declaration moves to the contract, the contract is not widened. + * + * ── Three instruments, and they do NOT see the same thing ────────────────── + * + * - `tsc` sees the `@ts-expect-error` leg and the `Equal` assertions. That + * is the half that reaches a TypeScript author, and it means nothing + * unless `type-check` runs — vitest strips types. + * - vitest runs the `safeParse` legs against the INSTALLED published spec + * artifact, each with a control that would have fired. ⚠️ Those legs read + * the SPEC ONLY: they were green before this change and are green after, + * so they are the PREMISE, never the evidence. They are labelled PREMISE + * below so nobody counts them as the fix. + * - vitest ALSO reads this package's own declaration as TEXT, so the + * reintroduction of the key is caught even by a run that never + * type-checks. Its control is `sections[].icon` on the sibling interface + * one screen up: the same matcher finds THAT `icon` member, so an empty + * result on the highlights arm is a reading rather than a regex that + * cannot match anything. + * + * ⛔ `sections[].icon` (`RecordDetailsComponentProps`) is NOT this card's to + * retire and this file must not grow into a pin that demands it: the contract + * declares it, `DetailSection` genuinely draws it, and it is a different + * member on a different face that merely shares the word. It appears here + * only as a firing control. + * + * ⚠️ This file does not decide whether a highlight chip SHOULD carry an icon. + * That route is an upstream `@objectstack/spec` widening on its own card. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { RecordHighlightsProps } from '@objectstack/spec/ui'; +import type { RecordHighlightsComponentProps } from '../record-components'; + +/** Rooted at THIS file, never at `process.cwd()` — the two differ per invocation. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const DECLARATION_PATH = join(HERE, '..', 'record-components.ts'); + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The only assertion form used here — its constraint is what refuses `false`. */ +type Expect = T; + +/* ── Direction proofs: a broken instrument makes THIS file red ─────────────── */ + +// @ts-expect-error objectui#9280 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578). +type _ExpectRefusesFalse = Expect; + +// @ts-expect-error objectui#9280 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through. +type _EqualRefusesNever = Expect>; + +// @ts-expect-error objectui#9280 — `any` must NOT read as equal to `true`, for the same reason. +type _EqualRefusesAny = Expect>; + +/* ── The object arm, member for member against the contract ───────────────── */ + +/** The object arm of the declared `fields[]` element union. */ +type DeclaredEntry = Exclude; + +/** + * RED before objectui#9280 (five keys against the contract's four), green + * after. `Equal` is invariant, so this fails in EITHER direction: adding + * `icon` back fails, and so would dropping `readonly`. + */ +type _EntryKeysMatchContract = Expect< + Equal +>; + +/** The bare-string arm is untouched by the narrowing — only the object arm moved. */ +type _StringArmSurvives = Expect< + Equal, string> +>; + +/* ── Literals: what a TypeScript author can and cannot write ───────────────── */ + +/** + * ⭐ THE LIT CONTROL for the `tsc` instrument: an entry the spec DOES accept, + * on the same interface, in the same position. It compiles. Without it, the + * refusal below is indistinguishable from an arm that refuses everything (or + * from a `fields` key that stopped taking objects at all). + */ +const nameLabelAccepted: RecordHighlightsComponentProps = { + fields: [{ name: 'amount', label: 'Amount' }], +}; + +/** The other two declared keys, so "closed set of four" is not read as "two". */ +const typeReadonlyAccepted: RecordHighlightsComponentProps = { + fields: [{ name: 'supply_share', type: 'number', readonly: true }], +}; + +/** The bare-string arm, the other half of the union, still compiles. */ +const bareStringAccepted: RecordHighlightsComponentProps = { + fields: ['amount'], +}; + +/** + * The card's repro. `icon` compiled before objectui#9280 and was refused at + * publish, taking the whole document with it; now `tsc` refuses it here, which + * is the whole point of the change. Reintroduce the key on the arm and this + * directive goes unused (TS2578) — that is the ablation leg. + */ +const iconRefused: RecordHighlightsComponentProps = { + // @ts-expect-error objectui#9280 — the contract's arm is `$strict` over `name`/`label`/`type`/`readonly`; `icon` is refused at publish with `invalid_union`, whole-document. + fields: [{ name: 'amount', icon: 'dollar-sign' }], +}; + +/** Any other undeclared key is refused the same way, by the same mechanism. */ +const arbitraryKeyRefused: RecordHighlightsComponentProps = { + // @ts-expect-error objectui#9280 — outside the contract's closed arm, exactly as `icon` now is. + fields: [{ name: 'amount', zzzNonsense: 1 }], +}; + +/** + * Read the object arm off the live schema rather than transcribing it. + * + * Reached through `RecordHighlightsProps.fields` — the path that actually + * governs an authored entry — rather than through the exported + * `RecordHighlightsField` alias, so the arm measured here is the arm a + * document is parsed against. Walks both the zod v4 (`def`) and v3 (`_def`) + * internal spellings, which is why the hops are typed structurally. + */ +type ZodInternals = { + def?: Record; + _def?: Record; +}; +const internals = (node: unknown): Record => { + const n = (node ?? {}) as ZodInternals; + return { ...(n._def ?? {}), ...(n.def ?? {}) }; +}; + +function specEntryArm(): { keys: string[]; strict: boolean } { + const shapeOfProps = internals(RecordHighlightsProps).shape as + | Record + | undefined; + const fields = shapeOfProps?.fields; + const fieldsDef = internals(fields); + const element = fieldsDef.element ?? fieldsDef.type; + const arms = (internals(element).options ?? []) as unknown[]; + + for (const arm of arms) { + const armDef = internals(arm); + const rawShape = armDef.shape; + const resolved = ( + typeof rawShape === 'function' ? (rawShape as () => unknown)() : rawShape + ) as Record | undefined; + if (resolved && typeof resolved === 'object') { + const catchall = internals(armDef.catchall); + return { + keys: Object.keys(resolved).sort(), + strict: (catchall.type ?? catchall.typeName) === 'never', + }; + } + } + return { keys: [], strict: false }; +} + +describe('objectui#9280 — record:highlights `fields[]` entry against the installed spec', () => { + it('PREMISE: the contract declares exactly four entry keys and refuses a fifth', () => { + // Read off the schema object rather than transcribed, so a spec that adds + // or drops a key fails HERE first — before the pins above start asserting + // a shape the contract no longer has. ⭐ If this ever reports `icon` among + // the keys, the contract WIDENED and this whole retirement is reversed by + // a new card, not by loosening the assertions below. + const arm = specEntryArm(); + expect(arm.keys).toEqual(['label', 'name', 'readonly', 'type']); + + // The load-bearing half. A non-strict arm would STRIP `icon` silently + // instead of refusing, which is a different defect with a different repair. + expect(arm.strict).toBe(true); + }); + + it("PREMISE: an entry carrying `icon` is refused with `invalid_union` at `fields.0` — the card's repro", () => { + const refused = RecordHighlightsProps.safeParse({ fields: [{ name: 'x', icon: 'star' }] }); + expect(refused.success).toBe(false); + const issue = refused.error?.issues.find((i) => i.path.join('.') === 'fields.0'); + expect(issue?.code).toBe('invalid_union'); + + // CONTROL A — an arbitrary key is refused with the SAME code, so `icon` + // is not special-cased by some bespoke branch. + const arbitrary = RecordHighlightsProps.safeParse({ + fields: [{ name: 'x', zzzNonsense: 1 }], + }); + expect(arbitrary.success).toBe(false); + expect(arbitrary.error?.issues.find((i) => i.path.join('.') === 'fields.0')?.code).toBe( + 'invalid_union', + ); + + // CONTROL B — a declared key parses green and survives, so the arm is not + // refusing everything. A refusal with no control that would have fired is + // not a measurement. + const accepted = RecordHighlightsProps.safeParse({ fields: [{ name: 'x', label: 'L' }] }); + expect(accepted.success).toBe(true); + expect(accepted.data?.fields[0]).toMatchObject({ name: 'x', label: 'L' }); + + // CONTROL C — the bare-string arm is unaffected, so the refusal above is + // scoped to the object arm rather than to `fields` as a whole. + const bare = RecordHighlightsProps.safeParse({ fields: ['x'] }); + expect(bare.success).toBe(true); + }); + + it('the DECLARATION carries the contract’s four keys and no fifth', () => { + const source = readFileSync(DECLARATION_PATH, 'utf8'); + + // Anchor on the interface so the match cannot drift onto another `icon`. + const block = source.slice(source.indexOf('interface RecordHighlightsComponentProps')); + expect(block).not.toBe(''); + const arm = /fields: Array<([\s\S]*?)>;/.exec(block)?.[1] ?? ''; + expect(arm).not.toBe(''); + expect(arm).not.toContain('icon'); + + // Derived, not transcribed: the arm's members ARE the contract's members. + const declaredKeys = Array.from(arm.matchAll(/(\w+)\??:/g)) + .map((m) => m[1]) + .sort(); + expect(declaredKeys).toEqual(specEntryArm().keys); + + // ⭐ THE LIT CONTROL for this instrument: the SAME `icon` matcher, run + // against the sibling interface one screen up, FINDS the `sections[].icon` + // member there (a different key on a different face — the contract + // declares it and `DetailSection` draws it, ⛔ not this card's to retire). + // So `not.toContain('icon')` above is a reading, not a matcher that can + // never see the word. If that member ever moves, this control moves with + // it — it must never be deleted outright. + const siblingBlock = source.slice( + source.indexOf('interface RecordDetailsComponentProps'), + source.indexOf('interface RecordHighlightsComponentProps'), + ); + expect(siblingBlock).toContain('icon?: string;'); + }); + + it('the literals above are real values, not type-only decoration', () => { + // vitest strips types, so these expectations are NOT the assertion — the + // annotations are. They exist so the file also fails visibly if the + // literals are ever silently emptied out. + expect(nameLabelAccepted.fields[0]).toMatchObject({ name: 'amount', label: 'Amount' }); + expect(typeReadonlyAccepted.fields[0]).toMatchObject({ readonly: true }); + expect(bareStringAccepted.fields[0]).toBe('amount'); + expect(iconRefused.fields[0] as unknown).toMatchObject({ icon: 'dollar-sign' }); + expect(arbitraryKeyRefused.fields[0] as unknown).toMatchObject({ zzzNonsense: 1 }); + }); +}); diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index ae8b6c5786..c3c500d8fb 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -242,15 +242,44 @@ export interface RecordDetailsComponentProps { export interface RecordHighlightsComponentProps { /** * Fields to display as highlights — bare names or - * `{name,label?,icon?,type?,readonly?}` for inline overrides. + * `{name,label?,type?,readonly?}` for inline overrides, as the CLOSED SET + * the contract declares. * * `readonly: true` suppresses the chip's inline-edit affordance * (objectstack#5077) without touching the object field, which is what * hook-maintained columns need: marking the object field `readonly` would * also strip the hook's own write-back. + * + * The object arm offered a fifth key, `icon`, until objectui#9280, and the + * contract never accepted it. `@objectstack/spec` + * `RecordHighlightsProps.fields[]`'s object arm declares exactly + * `name`/`label`/`type`/`readonly` and carries a `never` catchall, i.e. it is + * `$strict`: an unlisted key is REFUSED, not stripped, and the refusal takes + * the WHOLE document with it. Measured on the installed pin, 17.4.0, + * `RecordHighlightsProps.safeParse({ fields: [{ name: 'x', icon: 'star' }] })` + * is RED with `invalid_union` at `fields.0`. So `{ name: 'amount', icon: + * 'dollar-sign' }` type-checked here and was refused at the door — a green + * local build and a rejection at the only layer that matters. Three controls + * on the same instrument fired as they should: a declared key + * (`{ name, label }`) parses green, so the arm is not refusing everything; an + * arbitrary key (`zzzNonsense`) is refused with the SAME `invalid_union` + * code, so `icon` was not special-cased; and the bare-string arm parses + * green, so only the object arm moved. Contract-first (Commandment #0.1): + * the declaration moves to the contract, the contract is not widened. + * + * ⚠️ Whether a highlight chip SHOULD be able to carry an icon is a separate + * question this narrowing does not answer. The route for it is an upstream + * spec widening (an `@objectstack/spec` decision, on its own card), ⛔ never + * a redeclaration here. + * + * ⚠️ Do NOT copy this retirement onto `sections[].icon` one screen up. That + * is a DIFFERENT key on a different face — `DetailSection` genuinely draws + * it and the contract declares it — the same word, not the same member. + * `__tests__/record-highlights-fields-icon-9280.test.ts` pins this arm + * against the installed spec in both directions. */ fields: Array< - string | { name: string; label?: string; icon?: string; type?: string; readonly?: boolean } + string | { name: string; label?: string; type?: string; readonly?: boolean } >; /** * Layout mode for the highlights strip, as the CLOSED SET the contract