diff --git a/.changeset/7122-lookup-target-required-before-save.md b/.changeset/7122-lookup-target-required-before-save.md new file mode 100644 index 0000000000..d69ad817df --- /dev/null +++ b/.changeset/7122-lookup-target-required-before-save.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-designer': patch +--- + +Refuse to save a `lookup` / `master_detail` field with no target, instead of +PUTting it and blocking the object (objectui#7122). + +`@objectstack/spec` 17.3.0 makes `reference` a hard requirement on the two +relationship field types — a `custom` refinement at path `reference`, measured +on the installed build; at 17.2.0 the requirement was prose only and +`{ type: 'lookup', label: 'L' }` parsed green. The designer relied on that +latitude and PUT half-filled drafts. + +Against a matched 17.3.0 backend that PUT returns `422 INVALID_METADATA` for the +WHOLE object document, so the damage is not confined to the incomplete field: +every later save of that object fails the same way until the draft is completed +or removed by hand. + +Both metadata writers now raise before the request — `MetadataService.saveFields` +and `MetadataFieldsPage`'s own field-map conversion — naming the field and what +to do about it. The message lands in the page's existing error banner, the same +one a nameless or duplicated field already produces; no new UI affordance, and +no request is issued. A relationship field WITH a target is unaffected. + +Picked over the cheaper alternative deliberately: flipping the parity pin green +while the product still PUT the draft would have pinned a known-broken save +path. diff --git a/.changeset/7122-objectstack-family-17-3-0.md b/.changeset/7122-objectstack-family-17-3-0.md new file mode 100644 index 0000000000..7fe38e7a4e --- /dev/null +++ b/.changeset/7122-objectstack-family-17-3-0.md @@ -0,0 +1,22 @@ +--- +'@object-ui/data-objectstack': patch +--- + +Reconcile `@object-ui/data-objectstack` with the `@objectstack/*` family at +17.3.0 (objectui#7122). + +`@objectstack/client`, `core`, `formula` and `lint` each pin `@objectstack/spec` +EXACTLY, so resolving the spec alone to 17.3.0 left the console bundling TWO +copies of it. Moving the family with it in `pnpm-lock.yaml` collapses the +duplicate; every declared range already admitted 17.3.0, so no manifest moved. + +The one source change the family bump forces is a type reconciliation, not a +behaviour change. `client.analytics.query` resolved to `Promise` at 17.2.0 +and resolves to `Promise` at 17.3.0, so the pre-envelope +branches of `aggregate`'s row-shape fallback stopped type-checking. Those +branches are read through a widened alias rather than deleted: the client's own +docblock records the runtime change behind the narrower type ("BREAKING since +objectstack#13079 — read `result.rows`, not `result.data.rows`"), and deleting +them is a runtime compatibility decision about servers older than that, not a +type repair. The alias restores exactly the compile-time latitude 17.2.0 gave +the same expression and changes no runtime byte of it. diff --git a/.changeset/7122-postcss-dedupe.md b/.changeset/7122-postcss-dedupe.md new file mode 100644 index 0000000000..71203b4c10 --- /dev/null +++ b/.changeset/7122-postcss-dedupe.md @@ -0,0 +1,19 @@ +--- +--- + +Dev-time only: collapse the `postcss` duplicate this branch's first lockfile +bump introduced (objectui#7122). + +`aabc527cb` resolved `@objectstack/spec` to 17.3.0 and, as a side effect of that +install, re-resolved `autoprefixer`'s auto-installed `postcss` peer to 8.5.28 +while `@tailwindcss/postcss` kept 8.5.26. `@object-ui/cli` depends on both and +declares `postcss` itself, so its type-check program held TWO postcss copies +with two structurally distinct `Plugin` types — and comparing +`Plugin & ExportedAPI` against `Plugin` across them exceeded the compiler's +instantiation budget: `TS2321: Excessive stack depth`, in a file this branch +never touched. `origin/main` carries one copy for that pair and is green. + +The split is resolved in the lockfile only. All eight manifests that declare +`postcss` already declare `^8.5.26`, which admits 8.5.28, so no declared range +moved and no published dependency declaration differs by a byte. Empty +frontmatter is the deliberate "no release" declaration. diff --git a/.changeset/7122-spec-17-3-0-parity-reconciliation.md b/.changeset/7122-spec-17-3-0-parity-reconciliation.md new file mode 100644 index 0000000000..28a1f21a2f --- /dev/null +++ b/.changeset/7122-spec-17-3-0-parity-reconciliation.md @@ -0,0 +1,39 @@ +--- +'@object-ui/types': patch +'@object-ui/components': patch +'@object-ui/cli': patch +'@object-ui/plugin-detail': patch +--- + +Reconcile the declared surface with `@objectstack/spec` 17.3.0 (objectui#7122). + +**`ObjectSchema.editMode` is now the spec's.** 17.3.0 adopted the key (measured: +the accept set went 42 → 43, gained set exactly `['editMode']`, lost set empty, +declared as the same `'page' | 'modal'` union objectui carried). Its local copy +is retired from `ObjectSchemaClientExtensions`, which is what that type's own pin +prescribed for this event, leaving the client delta empty. Nothing is removed +from the product: `editMode` stays authorable and stays typed on +`ObjectSchemaMetadata`, carried by the spec's `ServiceObject` instead of by a +local member — and a published, spec-validated object document may now carry it, +which at 17.2.0 was refused by name. + +**`user:profile` is retired across all three sites.** 17.3.0 dropped it from +`PageComponentType` (measured: the enum went 34 → 32 options, lost set exactly +`['user:profile', 'element:form']`, gained set empty). objectui went on knowing +it in three places, so all three moved together: the Studio palette exclusion +ledger, `PROTOCOL_COMPONENTS` in `renderers/placeholders.tsx`, and the +regenerated `known-schema-types.ts` the CLI checks schemas against. Nothing +user-reachable went with it — neither type had a renderer, `user:profile` had +only the dashed "Component Placeholder" scaffold, and the app shell's own +profile affordance is a React slot, never this block type. A page schema still +naming it now draws the loud "Unknown component type" panel rather than a silent +grey box, which is this repo's standing treatment for a type outside the +supported surface. + +**`record:details` sections document the eight keys 17.3.0 added.** +`group`, `hideEmpty`, `collapsible`, `showBorder`, `defaultCollapsed`, `icon`, +`description` and `headerColor` are now declared on a section entry (4 → 12 +members). Six of the eight are already honoured by `DetailSection`, so the +`sections` input description now teaches all of them, and says plainly which two +are not read here. Designer controls for them are a separate feature and are +deliberately not added. diff --git a/.changeset/7122-spec-17-3-0-reconciliation.md b/.changeset/7122-spec-17-3-0-reconciliation.md new file mode 100644 index 0000000000..d098104278 --- /dev/null +++ b/.changeset/7122-spec-17-3-0-reconciliation.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/auth': patch +--- + +Reconcile `@object-ui/app-shell` with `@objectstack/spec` 17.3.0 (objectui#7122). + +`SchemaDiffEntryKind` gained an `unreachable` member in 17.3.0, and the external +datasource validation panel labels those kinds through a map that is TOTAL over +the union on purpose — so an upstream addition fails the build rather than +rendering a blank cell. That mechanism fired: the package did not compile against +17.3.0 until the kind was labelled. It now reads "Not checked — remote +unreachable", following the spec's own ruling that this kind asserts nothing +about the remote schema and must never be surfaced as "schema changed": it means +introspection could not complete, which is often transient, and labelling it like +a mismatch would tell an operator to repair a schema nobody has read. + +Two internal flow-inspector types were renamed (`FlowNodeLike` → +`InspectorFlowNode` / `ScopeFlowNode`) because 17.3.0 began exporting its own +`FlowNodeLike`; neither name is in this package's published entry, so no consumer +import changes. + +`@object-ui/auth`: a README sentence claiming the preview-mode prop aligns with +the spec's `PreviewModeConfig` is corrected — 17.3.0 removed that symbol. The +`previewMode` prop itself is host-supplied, unchanged, and unaffected. diff --git a/.changeset/spec-17-3-0-lockfile-bump.md b/.changeset/spec-17-3-0-lockfile-bump.md new file mode 100644 index 0000000000..167ba120b8 --- /dev/null +++ b/.changeset/spec-17-3-0-lockfile-bump.md @@ -0,0 +1,8 @@ +--- +--- + +Dev-time only: move the `@objectstack/spec` resolution in `pnpm-lock.yaml` from +17.2.0 to 17.3.0. No published package changes — every manifest's declared range +(`^17.0.0` / `^17.1.0` / `^17.2.0`) already admitted 17.3.0, so no floor moved and +no package's dependency declaration differs by a byte. Empty frontmatter is the +deliberate "no release" declaration for a change that publishes nothing. diff --git a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts index acfcde1fb8..547d4b6e40 100644 --- a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts +++ b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts @@ -439,7 +439,7 @@ describe('ObjectFieldGroup derives from the spec schema INPUT side', () => { // Still the real spec vocabulary, not a hand copy that merely agrees. type _HasSpecKeys = Assert< Extends< - 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded', + 'key' | 'label' | 'icon' | 'description' | 'visibleWhen' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded', keyof ObjectFieldGroup > >; @@ -447,7 +447,7 @@ describe('ObjectFieldGroup derives from the spec schema INPUT side', () => { Equal< Exclude< keyof ObjectFieldGroup, - 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded' + 'key' | 'label' | 'icon' | 'description' | 'visibleWhen' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded' >, never > diff --git a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts index fc8c45c1e8..88c7aca355 100644 --- a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts +++ b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts @@ -137,8 +137,14 @@ describe('objectui#6238 · the schema oracle — both tombstone keys are refused // key-name gate could never have stated it. `enable` passes on the name and // fails on the value, which is why this is objectui#4687's resolution // (delete the declaration) rather than objectui#6041's (rename it). + // 43, not the 42 this pin carried against 17.2.0: `@objectstack/spec` 17.3.0 + // ADOPTED `editMode` (measured — gained set exactly `['editMode']`, lost set + // empty). That adoption is unrelated to this file's subject and is pinned in + // `packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts`; + // the count rides here only as the corpus guard for the membership claims + // below, so it is re-pointed, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(42); + expect(accept.size).toBe(43); expect(accept.has('enable')).toBe(true); expect(accept.has('enabled')).toBe(false); expect(accept.has('_deleted')).toBe(false); diff --git a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts index c70ab88fed..342bdb93e6 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts @@ -19,8 +19,9 @@ * shapes: `toObjectPayload` builds it and `saveObject` PUTs it whole to * `PUT /api/v1/meta/object/:name`. * - * Measured against the installed `@objectstack/spec` 17.2.0 (ESM build), whose - * `ObjectSchema` accept set is 42 keys: + * Measured against the installed `@objectstack/spec` (ESM build), whose + * `ObjectSchema` accept set is 43 keys at 17.3.0 (42 at 17.2.0, when this file + * was written; the one gained key is `editMode`, unrelated to the three below): * * ObjectSchema.safeParse({ ...base, group: 'Sales' }) => unrecognized_keys ["group"] * ObjectSchema.safeParse({ ...base, sortOrder: 3 }) => unrecognized_keys ["sortOrder"] @@ -140,8 +141,12 @@ describe('the instrument', () => { }); it('has no near-spelling for any of them — unlike objectui#6041, nothing here is a rename', () => { + // 43 at `@objectstack/spec` 17.3.0, which adopted `editMode` (measured — + // gained set exactly `['editMode']`, lost set empty). The count is this + // file's corpus guard, not its subject; every claim it guards is below and + // unchanged. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(42); + expect(accept.size).toBe(43); // `fieldGroups` is the only grouping key on the object, and it groups the // FIELDS INSIDE one object — it is not a category for objects themselves, // so `group` has no mapping target here. diff --git a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts index 90bdabb7f5..b5c958b74b 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts @@ -141,24 +141,94 @@ describe('objectui#6041 · saveFields PUTs the relationship target as `reference expect(def.reference).toBe('account'); }); - it('a HALF-FILLED draft — type `lookup`, target left empty — still saves, exactly as before', async () => { - // The behavioural edge this card had to measure. The spec's prose calls - // `reference` "Required for relationship types", but that requirement is - // NOT enforced by the zod parse at 17.2.0: `{ type: 'lookup', label: 'L' }` - // parses green at field level AND through `ObjectSchema`. `undefined` is - // dropped by `JSON.stringify` under either spelling, so the wire bytes are - // byte-identical before and after this fix. + it('a HALF-FILLED draft — type `lookup`, target left empty — is REFUSED before any PUT', async () => { + // ⭐ This assertion is INVERTED from what it said at 17.2.0, and the + // inversion is the point of objectui#7122's ruled item 4. // - // ⚠ This case would still pass on a revert, and says so deliberately: it - // is here to prove the rename did NOT newly block a draft, which is a - // claim about the unchanged half. + // It used to read "still saves, exactly as before". The spec's prose called + // `reference` "Required for relationship types" while the zod parse did not + // enforce it — `{ type: 'lookup', label: 'L' }` parsed green at field level + // AND through `ObjectSchema` — so the designer was free to persist a + // target-less draft, and did. + // + // `@objectstack/spec` 17.3.0 closes that declared-but-unenforced gap: the + // same document is now refused by a `custom` refinement at path + // `reference`. Against a matched backend the PUT comes back 422 + // `INVALID_METADATA` for the WHOLE object document, which blocks every + // later save of that object — so flipping this pin green on its own would + // have pinned a known-broken save path. The product half was fixed first + // (`assertRelationshipTargetPresent` in `MetadataService.ts`), and this + // asserts THAT: the incomplete draft never reaches the wire. const { adapter, puts } = makeCapturingAdapter(); - await new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: undefined }]); + await expect( + new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: undefined }]), + ).rejects.toThrow(/needs a `reference`/); + + // The load-bearing half: refusing is only an improvement if it happens + // BEFORE the request. A guard that threw after the PUT would leave the + // object in exactly the state this change exists to prevent. + expect(puts).toHaveLength(0); + }); + + it('and it is the MISSING TARGET that is refused, not the type — the control', async () => { + // Without this, the assertion above is satisfied by a guard that refuses + // every `lookup`, which would break the feature rather than fix it. + const { adapter, puts } = makeCapturingAdapter(); + + await new MetadataService(adapter).saveFields('account', [LOOKUP]); const [def] = savedFields(puts); - expect('reference' in def).toBe(false); - expect('referenceTo' in def).toBe(false); + expect(def.reference).toBe('account'); expect(FieldSchema.safeParse(def).success).toBe(true); }); + + it('an empty-string target is refused too — `reference: ""` is not a target', async () => { + // The spec refuses it (`.min(1)`-shaped: measured, `reference: ''` fails the + // same `custom` issue at path `reference`), and a designer whose picker was + // opened and cleared emits exactly this. + const { adapter, puts } = makeCapturingAdapter(); + + await expect( + new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: '' }]), + ).rejects.toThrow(/needs a `reference`/); + expect(puts).toHaveLength(0); + }); +}); + +describe('objectui#7122 · the guarded type list is the spec’s, not a hand-kept guess', () => { + // `RELATIONSHIP_TYPES_REQUIRING_REFERENCE` is a named list in + // `MetadataService.ts` (a full `FieldSchema` parse before the PUT is refused + // on purpose — it would reject plugin-registered keys the server accepts). + // A named list can go stale in both directions, so it is DERIVED here and + // compared, which turns any upstream movement into a red test rather than a + // silent hole in the guard. + const REQUIRE_REFERENCE = ['lookup', 'master_detail']; + + it('every spec field type that needs a target is guarded, and no other type is', () => { + const typeSchema = FieldSchema.shape.type as unknown as { + options?: string[]; + def?: { entries?: Record }; + }; + const allTypes: string[] = + typeSchema.options ?? Object.keys(typeSchema.def?.entries ?? {}); + + // Non-vacuity: a shape read that answered `[]` would make the derivation + // agree with an empty list and assert nothing at all. + expect(allTypes.length).toBeGreaterThan(20); + + const derived = allTypes.filter((type) => { + const parsed = FieldSchema.safeParse({ type, label: 'L' }); + return !parsed.success && parsed.error.issues.some((i) => i.path.join('.') === 'reference'); + }); + + expect([...derived].sort()).toEqual([...REQUIRE_REFERENCE].sort()); + }); + + it('a guarded type parses green once the target is supplied — the requirement is the VALUE, not the type', () => { + for (const type of REQUIRE_REFERENCE) { + expect(FieldSchema.safeParse({ type, label: 'L', reference: 'account' }).success).toBe(true); + expect(FieldSchema.safeParse({ type, label: 'L', reference: '' }).success).toBe(false); + } + }); }); diff --git a/packages/app-shell/src/services/MetadataService.ts b/packages/app-shell/src/services/MetadataService.ts index e41a23fa26..af4c15f701 100644 --- a/packages/app-shell/src/services/MetadataService.ts +++ b/packages/app-shell/src/services/MetadataService.ts @@ -187,6 +187,82 @@ function toObjectPayload(obj: ObjectDefinition, fields?: FieldMetadataPayload[]) }; } +/** + * Field types whose `reference` (the target object a relationship links to) + * `@objectstack/spec` requires to be present and non-empty. + * + * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for + * every one of `FieldSchema`'s 49 declared types: exactly two are refused for a + * missing target — `lookup` and `master_detail` — and no other type is refused + * at all on that minimal document. Kept as a named list rather than derived by + * probing at runtime, and pinned against the spec by + * `MetadataService.specKeyReference.test.ts` so it cannot go stale in either + * direction (a third type gaining the requirement, or one of these losing it). + * + * ⛔ Deliberately NOT "parse every field through `FieldSchema` before the PUT". + * That would refuse plugin-registered keys the SERVER accepts — measured on + * 17.2.0, `x_plugin_thing` is `unrecognized_keys` to the installed spec while + * the server that sent it takes it back — which is the same reason + * {@link RETIRED_FIELD_KEYS} is a named list instead of a schema filter. This + * guard states one invariant, not a client-side revalidation of the document. + */ +const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; + +/** + * Refuse a relationship field whose target is missing — BEFORE the PUT. + * + * ## Why this raises instead of letting the server answer + * + * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` + * and `master_detail` (a `custom` refinement at path `reference`, not an + * `unrecognized_keys` name refusal). Against a matched backend the PUT of a + * half-filled relationship draft comes back `422 INVALID_METADATA` — and this + * is the expensive part, the same trap `RETIRED_FIELD_KEYS` exists for: the + * refused document is the WHOLE object, so the failure is not confined to the + * incomplete field. Every later save of that object fails the same way until + * the draft is completed or removed. + * + * At 17.2.0 the requirement was prose only — `{ type: 'lookup', label: 'L' }` + * parsed green — so the designer was free to persist a target-less draft and + * did. 17.3.0 closes a declared-but-unenforced gap (ADR-0049's direction), and + * the reconciliation the maintainer ruled for it (objectui#7122, 2026-09-05, + * ruled item 4) is this one: the incomplete draft stays in the client and is + * never PUT. ⛔ The alternative — flipping the pin green and leaving the + * product PUTting it — was refused on the ground that it pins a known-broken + * save path. + * + * ## Why an exception, and why HERE + * + * Same mechanism, same reason and the same call site as the nameless-field and + * duplicate-name refusals below: it raises before the request, so a refused + * list issues no PUT at all, and the designer page runs its save inside a + * `try` whose `catch` already renders the message in the page's existing error + * surface (`data-testid="metadata-fields-page-error"`). No new UI affordance is + * introduced by this guard — the author sees the same banner they already see + * for a nameless or duplicated field. + * + * `MetadataFieldsPage` carries the sibling copy of this check for the same + * reason it carries the sibling `toFieldsMap` and `carryOver`: the two writers + * convert different input types on different paths, and neither owns the + * other's. Both are pinned. + */ +function assertRelationshipTargetPresent( + field: { type?: string; reference?: unknown }, + fieldName: string, + writer: string, +): void { + if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; + const reference = field?.reference; + if (typeof reference === 'string' && reference.trim() !== '') return; + throw new Error( + `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + + '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + + 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + + '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + + 'this field. Pick the target object, or change the field to a non-relationship type.', + ); +} + /** * Key a list of field payloads by field NAME — the shape `ObjectSchema.fields` * requires (objectui#6240). @@ -251,6 +327,7 @@ function toFieldsMap(fields: FieldMetadataPayload[]): Record = { missing_table: 'Missing table', @@ -50,6 +55,7 @@ const DIFF_LABEL: Record = { index_mismatch: 'Index mismatch', unmapped_index: 'Unmapped index', default_mismatch: 'Column default mismatch', + unreachable: 'Not checked — remote unreachable', }; export function ValidationPanel({ datasource }: ValidationPanelProps) { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx index 7afa48a7aa..20baf33f0d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx @@ -22,7 +22,7 @@ * Dropping `description?` from a local `interface` proves nothing on its own, * for two independent reasons measured on this card: * - * 1. The node the inspector edits is typed `FlowNodeLike` (the exported shape + * 1. The node the inspector edits is typed `InspectorFlowNode` (the exported shape * `locateFlowNode` returns), NOT the inspector's own module-local * declaration — so narrowing only the local copy changes no read. * 2. Both shapes carry a deliberately load-bearing `[k: string]: unknown` @@ -35,7 +35,7 @@ * * The two assertions below survive both traps: * - * - **Compile time**: the DECLARED members of `FlowNodeLike` — index signature + * - **Compile time**: the DECLARED members of `InspectorFlowNode` — index signature * stripped — must be a subset of the spec's own `FlowNode` keys. That closes * the whole class rather than the one key: any future member added to the * read type that the contract refuses turns this red. @@ -55,7 +55,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import * as Automation from '@objectstack/spec/automation'; import type { FlowNode as SpecFlowNode } from '@objectstack/spec/automation'; -import type { FlowNodeLike } from './flow-nested-selection'; +import type { InspectorFlowNode } from './flow-nested-selection'; // Same stubs the sibling suite uses: the engine config-schema hook is empty so // the hardcoded field groups render, and the field catalog resolves without a @@ -129,7 +129,7 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? /** * The DECLARED members of a type — its index signature removed. * - * `keyof FlowNodeLike` is `string | number` while the index signature is there, + * `keyof InspectorFlowNode` is `string | number` while the index signature is there, * which is why the naive key comparison cannot see this defect at all. */ type Declared = { @@ -138,14 +138,14 @@ type Declared = { describe('the node read type declares no key FlowNodeSchema refuses (#6287)', () => { it('is pinned at compile time', () => { - type DeclaredNodeKeys = keyof Declared; + type DeclaredNodeKeys = keyof Declared; type SpecNodeKeys = keyof SpecFlowNode; // Guard against a degenerate probe: were either side `any`, or the // index-signature strip to leave nothing behind, every assertion below // would pass while measuring nothing. type _SpecNotAny = Assert, false>>; - type _LocalNotAny = Assert, false>>; + type _LocalNotAny = Assert, false>>; type _StripLeftKeys = Assert, false>>; type _SpecHasKeys = Assert>; // …and that the strip really removed the index signature: `description` diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx index 975b4673af..946c34ad6d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx @@ -55,7 +55,7 @@ import { useActionConfigSchemas } from '../previews/useFlowNodePalette.js'; import { FlowNodeConfigField } from './FlowNodeConfigField.js'; import { useFlowScope } from './useFlowScope.js'; import { nodeOutputRefs, type ScopeRef } from './flow-scope.js'; -import { NESTED_NODE_KIND, parseNestedNodeId, locateFlowNode, type FlowNodeLike } from './flow-nested-selection.js'; +import { NESTED_NODE_KIND, parseNestedNodeId, locateFlowNode, type InspectorFlowNode } from './flow-nested-selection.js'; import type { FlowDesignerEdge } from '../previews/flow-canvas-layout.js'; import { ScreenPreview } from '../previews/ScreenPreview.js'; @@ -69,7 +69,7 @@ import { ScreenPreview } from '../previews/ScreenPreview.js'; * - the node copy declared `description?: string`, a key `FlowNodeSchema` * refuses by name (`.strict()`, objectstack#4001) — and the copy was not even * the type the panel reads through, since `locateFlowNode` returns - * `FlowNodeLike`. Narrowing the copy alone would have changed nothing. + * `InspectorFlowNode`. Narrowing the copy alone would have changed nothing. * - the edge copy still spelled `condition?: unknown`, months after * `FlowEdgeInspector`'s twin was narrowed to the spec's `ExpressionInput` * because the loose spelling described an envelope the server rejects — the @@ -84,7 +84,7 @@ import { ScreenPreview } from '../previews/ScreenPreview.js'; * spec, so this panel now inherits that pin instead of needing its own copy of * it. */ -type FlowNode = FlowNodeLike; +type FlowNode = InspectorFlowNode; type FlowEdge = FlowDesignerEdge; /** diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts index 9b19f4112a..b2fef4fa23 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts @@ -121,8 +121,27 @@ export function regionLabelOf(regionKey: string, container?: { config?: unknown * field for it (objectui#6287). `FlowNodeInspector.specKeys.test.tsx` pins the * declared members (index signature stripped) as a subset of the spec's own * node keys, so the next addition of that kind fails to compile. + * + * ## Why the name is `InspectorFlowNode` and not `FlowNodeLike` + * + * `@objectstack/spec/system` began exporting its OWN `FlowNodeLike` in spec + * 17.3.0 (the minimal node shape `translateFlow` consumes), so the local name + * became a shadow of a live spec export — read by the next agent as the spec's + * own definition, which is how objectstack#2901 was filed with a backwards + * premise. `check-spec-symbol-derivation.mjs` caught it at the pin bump + * (objectui#7122) and offers four remedies; the difference here is REAL, so + * this is remedy 3, a declared local dialect. + * + * The difference, measured rather than asserted: the spec's `FlowNodeLike` + * declares `id?: string` OPTIONAL, this one declares `id: string` REQUIRED. + * Assignability runs one way only — this type is usable where the spec's is + * expected, the spec's is NOT usable where this one is. Importing the spec + * export in place of this declaration would therefore be a silent WIDENING + * that drops the `id` guarantee `NodeLocation` and every `locateFlowNode` + * caller are built on. The tripwire lives in `@object-ui/types`' + * `page-nav-misc-spec-parity.test.ts`, with the other renamed dialects. */ -export interface FlowNodeLike { +export interface InspectorFlowNode { id: string; type?: string; label?: string; @@ -136,7 +155,7 @@ export interface FlowNodeLike { */ export interface NodeLocation { /** The resolved node (a member of draft.nodes, or of a region sub-graph). */ - node: FlowNodeLike; + node: InspectorFlowNode; /** True when the node lives inside a container region (not draft.nodes). */ nested: boolean; /** @@ -145,7 +164,7 @@ export interface NodeLocation { */ scopeAnchorId: string; /** The enclosing container node — only when nested. */ - container?: FlowNodeLike; + container?: InspectorFlowNode; /** Human region label for the inspector breadcrumb — only when nested. */ regionLabel?: string; /** @@ -157,21 +176,21 @@ export interface NodeLocation { write: (next: Record | null) => Record | null; } -function asNodeArray(v: unknown): FlowNodeLike[] { - return Array.isArray(v) ? (v as FlowNodeLike[]) : []; +function asNodeArray(v: unknown): InspectorFlowNode[] { + return Array.isArray(v) ? (v as InspectorFlowNode[]) : []; } -function configOf(node: FlowNodeLike): Record { +function configOf(node: InspectorFlowNode): Record { const c = node.config; return c && typeof c === 'object' && !Array.isArray(c) ? (c as Record) : {}; } /** A region object (`{ nodes, edges, name? }`) with a usable `nodes` array, or null. */ -function asRegion(v: unknown): (Record & { nodes: FlowNodeLike[] }) | null { +function asRegion(v: unknown): (Record & { nodes: InspectorFlowNode[] }) | null { if (!v || typeof v !== 'object' || Array.isArray(v)) return null; const r = v as Record; if (!Array.isArray(r.nodes)) return null; - return r as Record & { nodes: FlowNodeLike[] }; + return r as Record & { nodes: InspectorFlowNode[] }; } /** Resolve a region object out of a container's config by its structured path. */ @@ -189,9 +208,9 @@ function regionFromConfig(cfg: Record, rp: RegionConfigPath) { * (`region.edges`, `branch.name`). */ function writeNestedNode( - nodes: FlowNodeLike[], + nodes: InspectorFlowNode[], containerIdx: number, - container: FlowNodeLike, + container: InspectorFlowNode, rp: RegionConfigPath, nodeIdx: number, next: Record | null, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts index 6581cb7417..8e6b6682df 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts @@ -73,7 +73,19 @@ export interface FlowScope { trigger?: TriggerScope; } -interface FlowNodeLike { +/** + * The scope walker's read shape for a node it has NOT yet validated — every + * member `unknown` on purpose, because this runs over raw stored metadata and + * narrows each value at its use site. + * + * Named `ScopeFlowNode` rather than `FlowNodeLike` because spec 17.3.0 began + * exporting a `FlowNodeLike` of its own (objectui#7122): a local declaration + * under a live spec export's name reads as the spec's definition to the next + * agent. This one is a genuine dialect, not a copy — the spec's members are + * typed (`id?: string`), these are deliberately untyped, which is the whole + * point of a pre-validation probe. Tripwire: `page-nav-misc-spec-parity.test.ts`. + */ +interface ScopeFlowNode { id?: unknown; type?: unknown; label?: unknown; @@ -154,7 +166,7 @@ export function flowAncestors(nodeId: string, edges: FlowEdgeLike[]): Set, nodeId: string | undefined): FlowScope { - const nodes = asArray(draft.nodes).map(asRecord) as FlowNodeLike[]; + const nodes = asArray(draft.nodes).map(asRecord) as ScopeFlowNode[]; const edges = asArray(draft.edges) as FlowEdgeLike[]; const refs: ScopeRef[] = []; diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 6203ad0309..784e85c7c1 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -114,16 +114,70 @@ describe('record:details sections ↔ spec section-entry coverage (#3819)', () = expect(specSectionKeys).toContain('name'); }); - it('exposes an editor for every key the spec declares on a section', () => { + /** + * Section keys the spec declares that this designer does NOT yet expose an + * editor for — every one of them arriving with `@objectstack/spec` 17.3.0, + * which grew the section entry from four member keys to twelve (measured: + * gained set exactly these eight, lost set empty). + * + * ⛔ This is a DEFERRAL, not a dismissal, and it is deliberately a hand-kept + * list rather than a loosened assertion. Eight new inspector controls is a + * feature, and the bump that revealed the gap is not the place to build it + * (maintainer ruling on objectui#7122, 2026-09-05, ruled item 5: "推迟,单开 + * feature 卡 —— 8 个新控件是功能,不是 bump 的尾巴"). Six of the eight are + * already HONOURED by the renderer through `DetailSection` (`icon`, + * `description`, `collapsible`, `defaultCollapsed`, `showBorder`, + * `headerColor`), so the gap is genuinely the control and not the capability; + * `group` is unimplemented here, and `hideEmpty` is retired on purpose + * (objectui#7129). All eight are documented on the `sections` input's + * description, which the sibling `recordDetailsInputs.spec-parity.test.ts` + * enforces — so they are discoverable in source mode today. + * + * The assertion below keeps its full force for everything else: a NINTH key + * landing upstream still fails here, and so does any entry of this list that + * stops being a spec key (a stale deferral) or that quietly gains a control + * without being removed from the list. + */ + const DEFERRED_SECTION_CONTROLS = [ + 'group', + 'hideEmpty', + 'collapsible', + 'showBorder', + 'defaultCollapsed', + 'icon', + 'description', + 'headerColor', + ]; + + it('exposes an editor for every key the spec declares on a section, bar the deferred eight', () => { expect(sectionsField?.kind).toBe('array'); const authored = (sectionsField?.itemFields ?? []).map((f) => f.name); - const missing = specSectionKeys.filter((k) => !authored.includes(k)); + const missing = specSectionKeys.filter( + (k) => !authored.includes(k) && !DEFERRED_SECTION_CONTROLS.includes(k), + ); // If this fails: the spec declares a section key the block designer gives - // authors no way to write. Add the itemField — a key that only source-mode - // editing can reach is a key Studio-built pages structurally cannot carry. + // authors no way to write, and that is not one of the eight consciously + // deferred above. Add the itemField — a key that only source-mode editing + // can reach is a key Studio-built pages structurally cannot carry. expect(missing, 'section keys with no designer control').toEqual([]); }); + it('the deferral list is neither stale nor a cover for a control that now exists', () => { + // The two ways the exemption above could rot, both closed here rather than + // left to a reader's diligence. Without this the list would be a permanent + // hole: a key removed upstream, or one that later gained a control, would + // sit in it forever and quietly shrink what the coverage assertion checks. + const authored = (sectionsField?.itemFields ?? []).map((f) => f.name); + expect( + DEFERRED_SECTION_CONTROLS.filter((k) => !specSectionKeys.includes(k)), + 'deferred key the spec no longer declares — drop it from the list', + ).toEqual([]); + expect( + DEFERRED_SECTION_CONTROLS.filter((k) => authored.includes(k)), + 'deferred key that now HAS a designer control — drop it from the list', + ).toEqual([]); + }); + it('the `name` editor is a text box carrying the snake_case convention', () => { const nameField = sectionsField?.itemFields.find((f) => f.name === 'name'); expect(nameField, 'record:details sections must expose the i18n anchor `name`').toBeDefined(); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx index b15bb58cb5..f70e50512c 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx @@ -174,7 +174,12 @@ describe('canvas chrome for a renderable-but-unoffered spelling (#5837)', () => // Counter-probe. `element:text_input` is unoffered for its own reason and is // in no alias group; a fix keyed on "is excluded" would hand it a friendly // icon it has not earned. - for (const type of ['element:text_input', 'element:record_picker', 'element:form', 'ai:chat_window']) { + // `element:form` was a fourth probe here until `@objectstack/spec` 17.3.0 + // retired it from `PageComponentType` and objectui dropped its now-stale + // palette exclusion with it (objectui#7122). Three probes still exercise + // the same property on three different exclusion reasons, so the + // counter-probe keeps its force. + for (const type of ['element:text_input', 'element:record_picker', 'ai:chat_window']) { expect(PALETTE_EXCLUSIONS[type], `${type} is not an exclusion any more`).toBeTruthy(); expect(resolveBlockDisplayMeta(type), `${type} borrowed display meta`).toBeUndefined(); expect(resolveBlockTone(type), `${type} borrowed a tone`).toBe(BLOCK_CATEGORY_TONE.misc); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts index d8496eae9e..67df6ff5e0 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts @@ -82,7 +82,12 @@ * vocabulary against `PlaceholderRenderer` under `namespace: * 'protocol-placeholder'`, which would make this file answer "has a renderer" * for types that have only the dashed "Component Placeholder" scaffold — - * `user:profile` is one (measured). This repo's own language is clear that the + * `user:profile` was the measured example, until `@objectstack/spec` 17.3.0 + * retired the type from `PageComponentType` and objectui#7122 dropped its + * placeholder registration with it. The MECHANISM is unchanged and so is the + * refusal to opt in; only the illustration went, and it is left named here + * because it is the reading the argument was built on. This repo's own + * language is clear that the * scaffold is not a renderer: `views/app-launcher-renderer.tsx` describes the * state before it existed as "nothing rendered it, so a page that authored it * drew a dashed box", with placeholders registered the whole time. Opting in diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index 3f6216e3f0..4bc87b87fc 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -149,16 +149,33 @@ export const PALETTE_EXCLUSIONS: Record = { // Shell singletons — chrome the app shell owns, not page content. 'app:launcher': 'shell singleton — lives in the app shell chrome', 'global:notifications': 'shell singleton — lives in the app shell header', - 'user:profile': 'shell singleton — lives in the app shell header', - // No renderer, by decision — and these two are the ones that MEASURE that way. - // Nothing registers `form` under `namespace: 'element'` (the form renderers are - // `ui:form` in `components/renderers/form/form.tsx` and `view:form` in - // `plugin-form/src/index.tsx`; the object-bound alternative named below, - // `object-form`, is registered in that same file), and no `ai:` namespace - // registration exists anywhere — `components/renderers/placeholders.tsx` keeps - // `ai:chat_window` out on purpose so a referencing schema fails loudly. + // + // ⛔ `user:profile` and `element:form` are NOT missing entries — they are + // RETIRED UPSTREAM. `@objectstack/spec` 17.3.0 dropped both from + // `PageComponentType` (measured: the enum went 34 → 32 options, lost set + // exactly `['user:profile', 'element:form']`, gained set empty), and this + // ledger's contract is that every entry names a REAL spec type — pinned by + // `__tests__/block-config.test.ts`'s "every exclusion names a real spec type + // and carries a reason". An exclusion for a type that no longer exists is a + // decision about nothing. + // + // The reconciliation was made on all sites at once rather than here alone + // (objectui#7122): `user:profile` also came out of `PROTOCOL_COMPONENTS` in + // `@object-ui/components`' `renderers/placeholders.tsx`, and out of the + // regenerated `@object-ui/cli` `known-schema-types.ts` that derives from it. + // Deleting only this entry was refused twice, and rightly: it would silence + // the one loud signal while objectui went on knowing a type the spec had + // retired. Nothing user-reachable was removed — neither type had a renderer. + // `user:profile` had only the dashed "Component Placeholder" scaffold (the + // sibling `exclusion-reason-truthfulness.test.ts` measures exactly that and + // records that this repo does not count the scaffold as a renderer), the + // shell's own profile affordance is a React slot and never this block type, + // and nothing anywhere registered `element:form`. + // + // No renderer, by decision. No `ai:` namespace registration exists anywhere — + // `components/renderers/placeholders.tsx` keeps `ai:chat_window` out on + // purpose so a referencing schema fails loudly. 'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical', - 'element:form': 'no renderer — use the object-bound `object-form` block', // Renders fine — excluded because it is not PAGE CONTENT, not because it is // unrenderable. Both types have a registered renderer under `namespace: // 'element'` (`components/renderers/basic/text-input.tsx:161`, diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts index 13a0b6287f..4c41276970 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts @@ -93,7 +93,7 @@ export interface FlowDesignerNode { * * ⛔ Making it required here catches nothing. Measured on `origin/main` in * #6287: `tsc` exit 0, ZERO errors, because every node reaches the reader - * types through `as FlowNodeLike[]` / `as FlowDesignerNode[]` casts out of + * types through `as InspectorFlowNode[]` / `as FlowDesignerNode[]` casts out of * `Record`, and **a cast bypasses a required member**. It is * also mildly harmful — `node.label ?? ''` and `node.label || node.id` are * guards the OPTIONAL type forces, while a required `label` would let diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx index 58a3f73c86..b57adbb61b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx @@ -16,7 +16,7 @@ * ⛔ The reader-side type is NOT the enforcement point, and this is measured, * not asserted. objectui#6287 made `label` required on the reader type and got * `tsc` exit 0 with **zero errors**: every node reaches that type through - * `as FlowNodeLike[]` casts out of `Record`, and **a cast + * `as InspectorFlowNode[]` casts out of `Record`, and **a cast * bypasses a required member**. It is also mildly harmful — `node.label ?? ''` * and `node.label || node.id` are guards the OPTIONAL type currently forces, * while a required `label` would let `node.label.trim()` compile against a @@ -279,7 +279,7 @@ function collectSites(file: string, src: string): Site[] { const sites: Site[] = []; // (a) a literal declared AS a flow node - for (const m of src.matchAll(/:\s*(?:FlowDesignerNode|FlowNodeLike|FlowNode)\s*=\s*\{/g)) { + for (const m of src.matchAll(/:\s*(?:FlowDesignerNode|InspectorFlowNode|FlowNodeLike|FlowNode)\s*=\s*\{/g)) { const open = src.indexOf('{', m.index!); const close = matchBracket(src, open, '{', '}'); if (close < 0) continue; diff --git a/packages/auth/README.md b/packages/auth/README.md index d1258d09f7..28596599aa 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -325,7 +325,7 @@ not a gap. Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens. -This feature aligns with the `PreviewModeConfig` from `@objectstack/spec/kernel` ([spec PR #676](https://github.com/objectstack-ai/spec/pull/676)). +This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846), which removed it together with the `RuntimeMode` value `'preview'`; the spec this package resolves no longer exports it. The `previewMode` prop below is unaffected — it is the host's to supply, and always was. ### Usage diff --git a/packages/auth/src/__tests__/auth-spec-parity.test.ts b/packages/auth/src/__tests__/auth-spec-parity.test.ts index d6f791bfdf..e6d005a0a3 100644 --- a/packages/auth/src/__tests__/auth-spec-parity.test.ts +++ b/packages/auth/src/__tests__/auth-spec-parity.test.ts @@ -274,43 +274,26 @@ describe('the AuthProvider exemption rests on the spec symbol being an id enum', }); /** - * `PreviewModeConfig` — the doc-provenance ratchet (objectui#6748). + * `PreviewModeConfig` — the doc-provenance ratchet (objectui#6748) FIRED and is + * retired here, which is the disposition its own docblock prescribed. * - * `packages/auth/README.md:328` tells readers that this package's preview-mode - * prop "aligns with the `PreviewModeConfig` from `@objectstack/spec/kernel`". - * That sentence is TRUE on the spec this package resolves today — 17.2.0 still - * exports `PreviewModeConfig`, `PreviewModeConfigParsed` and - * `PreviewModeConfigSchema` from its `./kernel` barrel — which is why this - * assertion ships green rather than skipped. + * The ratchet guarded one sentence: `packages/auth/README.md` claimed this + * package's preview-mode prop "aligns with the `PreviewModeConfig` from + * `@objectstack/spec/kernel`". Its instruction on going red was exact — + * "correct `packages/auth/README.md` … and then delete this guard, which has no + * reason to outlive the sentence it protects". * - * Upstream has already retired the symbol in SOURCE: objectstack#11846, landed - * as objectstack PR #12718 on 2026-08-28, removed the whole `PreviewModeConfig` - * block together with the `RuntimeMode` value `'preview'`, and registered it as - * `kernel/PreviewModeConfig` in `RETIRED_DEFS_BY_MAJOR[18]`. So it leaves the - * PUBLISHED set at spec major 18 — not during 17.x. That is why the README line - * was deliberately NOT rewritten when this was found: editing it now would make - * it wrong in the opposite direction for the rest of 17.x, telling readers there - * is no upstream anchor while the import still resolves. + * It went red on the `@objectstack/spec` 17.3.0 bump (objectui#7122): the + * symbol left the PUBLISHED set there. ⚠️ Worth recording rather than + * smoothing over — the docblock expected that to happen at major 18, because + * objectstack#11846 registered the retirement in `RETIRED_DEFS_BY_MAJOR[18]`. + * It arrived in a MINOR instead, one of four public type exports 17.3.0 + * removed without a major-version signal. * - * Nothing else in this repo would notice the moment that flips. This is the - * signal. When this package bumps to a spec without the symbol, THIS TEST GOES - * RED, and the fix is to correct `packages/auth/README.md:328` — drop the - * alignment claim, or repoint it at whatever succeeds it — and then delete this - * guard, which has no reason to outlive the sentence it protects. - * - * The capability itself is NOT in question. `AuthProvider`'s `previewMode` prop - * is host-supplied and stays (objectui#6654's ruling leaves it intact); only the - * provenance sentence is at stake here. + * The README sentence is corrected and the guard is gone. The capability is + * untouched: `AuthProvider`'s `previewMode` prop is host-supplied and stays + * (objectui#6654). No absence pin replaces this one — the symbol was never + * declared in this package, so there is no local name for the spec to collide + * with; the names this repo DOES own are pinned in + * `page-nav-misc-spec-parity.test.ts`. */ -describe('the README preview-mode provenance claim still has an upstream anchor', () => { - it('the spec still exports `PreviewModeConfig`', () => { - expect( - SPEC_NAMES.has('PreviewModeConfig'), - '@objectstack/spec no longer exports `PreviewModeConfig`, so ' + - '`packages/auth/README.md:328` now points readers at a symbol that does not ' + - 'exist — it was retired for major 18 (objectstack#11846, PR #12718). Correct ' + - 'that line, then delete this guard. Do NOT remove the `previewMode` prop: the ' + - 'capability is host-supplied and unaffected (objectui#6654, objectui#6748).', - ).toBe(true); - }); -}); diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 14e8d373ba..a3d3c41895 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -415,7 +415,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'protocol-placeholder:record:highlights', 'protocol-placeholder:record:path', 'protocol-placeholder:record:related_list', - 'protocol-placeholder:user:profile', 'protocol-placeholder:view:calendar', 'protocol-placeholder:view:drawer', 'protocol-placeholder:view:gallery', @@ -648,7 +647,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ul', 'url', 'user', - 'user:profile', 'utility', 'vector', 'view', diff --git a/packages/components/src/renderers/placeholders.tsx b/packages/components/src/renderers/placeholders.tsx index e93c072960..98ca24bb0b 100644 --- a/packages/components/src/renderers/placeholders.tsx +++ b/packages/components/src/renderers/placeholders.tsx @@ -84,7 +84,19 @@ const PROTOCOL_COMPONENTS = [ 'record:details', 'record:highlights', 'record:related_list', 'record:activity', 'record:chatter', 'record:path', 'app:launcher', 'nav:menu', 'nav:breadcrumb', - 'global:search', 'global:notifications', 'user:profile', + 'global:search', 'global:notifications', + // 'user:profile' intentionally omitted — `@objectstack/spec` 17.3.0 RETIRED + // it from `PageComponentType` (measured: the enum went 34 → 32 options, lost + // set exactly `['user:profile', 'element:form']`, gained set empty), so it is + // no longer a page block any author can legitimately write. It never had a + // renderer here either, only the dashed scaffold below, and the shell's own + // profile affordance is a React slot rather than this block type — so nothing + // user-reachable went with it. Same reasoning as `ai:chat_window` further + // down: a page schema still naming it now produces the loud "Unknown + // component type" panel instead of a silent grey box, which is what sends the + // fix to the source. Retired across all three sites at once (objectui#7122): + // here, the Studio palette ledger (`app-shell` `previews/block-types.ts`), + // and the regenerated `@object-ui/cli` `known-schema-types.ts`. // 13. Dashboard Widgets 'widget:metric', 'widget:bar', 'widget:line', 'widget:pie', 'widget:funnel', diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index c61c239ad8..77dcab75aa 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -251,8 +251,26 @@ export const SPEC_ACTION_KEYS = [ 'onSuccess', 'openIn', 'opensInNewTab', + // Added to `ActionSchema` in @objectstack/spec 17.3.0 (objectui#7122), the + // declarative single-record field write that mirrors a list view's + // `bulkActionDefs`: `operation: 'update'` applies `patch` (merged UNDER the + // collected `params`) to the current record on the data plane AS THE CALLER. + // + // Listed here for the same reason `description` above is, and with the same + // limits: this array's contract is "every property the spec's `ActionSchema` + // declares", so listing them RESTATES the spec rather than adopting a + // feature. `ActionDef` does NOT declare either key and is not changed, no + // runner branch reads them, and no action surface forwards them — that is a + // deliberate omission under the maintainer's 2026-09-05 ruling on this bump + // ("record both as justified omissions now; forward only the key a runtime + // actually reads, once its semantics are read from upstream — no speculative + // forwarding"). What these two entries buy is the one thing the inventory is + // consulted for: an action carrying them is no longer reported as having + // unknown keys, which is a dev-mode console warning and nothing else. + 'operation', 'order', 'params', + 'patch', 'recordIdField', 'recordIdParam', 'refreshAfter', diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 78acecd040..f16cceb540 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -5130,7 +5130,27 @@ export class ObjectStackAdapter implements DataSource { // an array the sink cannot lower was refused above rather than dropped. if (analyticsWhere !== undefined) payload.where = analyticsWhere; - const data = await this.client.analytics.query(payload); + const contractResult = await this.client.analytics.query(payload); + + // `client.analytics.query` resolved to `Promise` at + // `@objectstack/client` 17.2.0 and resolves to `Promise` + // at 17.3.0, so the pre-envelope branches below stopped type-checking the + // moment the family moved. The client's own docblock states the runtime + // change that produced the narrower type: "BREAKING since #13079 - read + // `result.rows`, not `result.data.rows`; the method used to resolve to the + // whole envelope." + // + // Those branches are READ THROUGH a widened alias here rather than + // deleted, and the distinction is deliberate: deleting them is a runtime + // compatibility decision about servers older than #13079, NOT a type + // repair, and it belongs to whoever owns that decision. This alias + // restores exactly the compile-time latitude 17.2.0's `Promise` gave + // the same expression and changes no runtime byte of it. When the + // compatibility question is ruled, the branches go and the alias goes + // with them - it exists only to keep a decision from being made by a + // build error. + const data = contractResult as AnalyticsResult & + Partial>; const rawRows: any[] = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows diff --git a/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx index 10e83e0e53..8746468f92 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx @@ -51,7 +51,11 @@ const OBJECT_BODY = { label: 'Widget', fields: { name: { type: 'text', label: 'Name', required: true }, - owner_id: { type: 'lookup', label: 'Owner', indexed: true, helpText: 'Record owner.' }, + // `reference` is not this file's subject: it is here because a `lookup` + // without a target is un-storable at `@objectstack/spec` 17.3.0 and the + // designer now refuses one before the PUT (objectui#7122). Without it every + // save below would raise on the target instead of exercising `indexed`. + owner_id: { type: 'lookup', label: 'Owner', reference: 'account', indexed: true, helpText: 'Record owner.' }, code: { type: 'text', label: 'Code', indexed: false }, }, indexes: [{ name: 'by_owner', fields: ['owner_id'] }], diff --git a/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx index b6ff8f10bc..fb0250abee 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx @@ -50,7 +50,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { FieldSchema } from '@objectstack/spec/data'; import { MetadataClient } from '@object-ui/data-objectstack'; import type { DesignerFieldDefinition } from '@object-ui/types'; @@ -65,6 +65,17 @@ import type { DesignerFieldDefinition } from '@object-ui/types'; * left behind. `carryOver` spreads the previous server def verbatim, so * without a tombstone the key rides straight back out to the route that * rejects it — and the object stays blocked forever. + * + * ⚠️ `legacy_id` carries the canonical `reference` ALONGSIDE the misspelling, + * which it did not need to before `@objectstack/spec` 17.3.0. 17.3.0 makes a + * target-less `lookup` un-storable, and objectui#7122 fixed the product half + * by refusing such a field client-side before the PUT — so a fixture whose + * `legacy_id` had ONLY the retired spelling would now make every save in this + * file raise, and every assertion below would be measuring the guard instead + * of the strip. Both keys present is also the more faithful legacy document: + * a server that stored `referenceTo` and a later client that wrote + * `reference`. The strip assertion keeps its full force either way — the + * retired key is refused BY NAME, so its presence alone is the 422. */ const OBJECT_BODY = { name: 'probe_widget', @@ -72,7 +83,7 @@ const OBJECT_BODY = { fields: { name: { type: 'text', label: 'Name', required: true }, owner_id: { type: 'lookup', label: 'Owner', reference: 'account', inlineHelpText: 'Record owner.' }, - legacy_id: { type: 'lookup', label: 'Legacy', referenceTo: 'contact' }, + legacy_id: { type: 'lookup', label: 'Legacy', reference: 'contact', referenceTo: 'contact' }, }, }; @@ -256,16 +267,16 @@ describe('objectui#6041 · WRITE — the save carries `reference`, never `refere expect(FieldSchema.safeParse(fields.legacy_id).success).toBe(true); }); - it('a HALF-FILLED draft — type `lookup`, target left empty — still saves, exactly as before', async () => { - // The behavioural edge this card had to measure. The spec's prose calls - // `reference` "Required for relationship types", but that requirement is - // NOT enforced by the zod parse at 17.2.0: `{ type: 'lookup', label: 'L' }` - // parses green at field level AND through `ObjectSchema`. `undefined` is - // dropped by `JSON.stringify` under either spelling, so the wire bytes are - // byte-identical before and after this fix. + it('a HALF-FILLED draft — type `lookup`, target left empty — is REFUSED, with no PUT and a visible reason', async () => { + // ⭐ INVERTED at `@objectstack/spec` 17.3.0 (objectui#7122, ruled item 4). // - // ⚠ This case would still pass on a revert, and says so deliberately: it - // exists to prove the rename did NOT newly block a draft. + // It used to read "still saves, exactly as before": the spec's prose called + // `reference` "Required for relationship types" while the 17.2.0 zod parse + // did not enforce it, so `{ type: 'lookup', label: 'L' }` parsed green and + // the designer persisted target-less drafts. 17.3.0 enforces it, and a PUT + // of such a draft returns 422 `INVALID_METADATA` for the WHOLE object — + // blocking every later save of it, not just this field. So the product was + // fixed rather than the pin: the draft is refused in the client. await renderPage(); const next: DesignerFieldDefinition[] = [ ...designerProps!.fields, @@ -274,10 +285,37 @@ describe('objectui#6041 · WRITE — the save carries `reference`, never `refere await act(async () => { designerProps!.onFieldsChange!(next); }); + + // The whole point: nothing reached the wire. `onFieldsChange` is + // fire-and-forget, so a guard that threw anywhere but inside the page's + // save `try` would surface as an unhandled rejection and show the author + // nothing — which is the silent failure this refusal exists to end. + await waitFor(() => + expect(screen.getByTestId('metadata-fields-page-error').textContent).toMatch( + /needs a `reference`/, + ), + ); + expect(puts).toHaveLength(0); + // …and the author is told WHICH field, because an object save can carry + // dozens and a message that only says "a lookup" is not actionable. + expect(screen.getByTestId('metadata-fields-page-error').textContent).toContain('half_id'); + }); + + it('the same draft saves once its target is picked — the control for the refusal above', async () => { + // Without this, the refusal is satisfied by a guard that blocks every + // `lookup`, which would break authoring rather than protect it. + await renderPage(); + const next: DesignerFieldDefinition[] = [ + ...designerProps!.fields, + { id: 'fld_half', name: 'half_id', label: 'Half', type: 'lookup', referenceTo: 'contact' }, + ]; + await act(async () => { + designerProps!.onFieldsChange!(next); + }); await waitFor(() => expect(puts).toHaveLength(1)); const fields = savedFields(); - expect('reference' in fields.half_id).toBe(false); + expect(fields.half_id.reference).toBe('contact'); expect('referenceTo' in fields.half_id).toBe(false); expect(FieldSchema.safeParse(fields.half_id).success).toBe(true); }); diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index f6aac3b9a0..636d836bbc 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -253,6 +253,54 @@ function fromDesignerField( * the one that matters: it raises BEFORE the request, so a refused list issues * no PUT at all. */ +/** + * Field types whose `reference` (the target object a relationship links to) + * `@objectstack/spec` requires to be present and non-empty — the sibling of + * `MetadataService`'s list, kept here for the same reason this file keeps its + * own `toFieldsMap` and `carryOver`: the two writers convert different input + * types on different paths and neither owns the other's. + * + * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for + * every one of `FieldSchema`'s 49 declared types: exactly `lookup` and + * `master_detail` are refused for a missing target, and no other type is + * refused at all on that minimal document. + */ +const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; + +/** + * Refuse a relationship field whose target is missing — BEFORE the PUT. + * + * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` + * and `master_detail` (a `custom` refinement at path `reference`). Against a + * matched backend, PUTting a half-filled relationship draft returns `422 + * INVALID_METADATA` for the WHOLE object document — so the damage is not + * confined to the incomplete field: every later save of that object fails the + * same way until the draft is completed or removed. At 17.2.0 the requirement + * was prose only and this page persisted such drafts freely. + * + * The maintainer's reconciliation for that change (objectui#7122, 2026-09-05, + * ruled item 4) is to keep the incomplete draft in the client and never PUT it. + * This raises inside the caller's save `try`, so the message lands in the + * page's existing error surface — the same banner a nameless or duplicated + * field already produces, and no new UI affordance. + */ +function assertRelationshipTargetPresent( + field: { type?: string; reference?: unknown }, + fieldName: string, + writer: string, +): void { + if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; + const reference = field?.reference; + if (typeof reference === 'string' && reference.trim() !== '') return; + throw new Error( + `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + + '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + + 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + + '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + + 'this field. Pick the target object, or change the field to a non-relationship type.', + ); +} + function toFieldsMap( next: DesignerFieldDefinition[], prevFields: Record, @@ -288,7 +336,13 @@ function toFieldsMap( const prev = Object.prototype.hasOwnProperty.call(prevFields, name) ? prevFields[name] : undefined; - entries.push([name, fromDesignerField(designed, prev)]); + const emitted = fromDesignerField(designed, prev); + // Checked on the EMITTED entry, not on `designed`: `fromDesignerField` + // merges the carried-over server entry underneath the designer's values, so + // the target may legitimately arrive from `prev` on a field the author did + // not touch. Reading `designed.referenceTo` alone would refuse those. + assertRelationshipTargetPresent(emitted, name, '[MetadataFieldsPage]'); + entries.push([name, emitted]); }); return Object.fromEntries(entries); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 97d62a19c2..2771cb39f5 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -446,7 +446,7 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // above already said `rejects`.) inputs: [ { name: 'columns', type: 'enum', label: 'Columns', enum: ['1', '2', '3', '4'], defaultValue: '2', description: 'Number of columns for field layout (1-4)' }, - { name: 'sections', type: 'array', label: 'Sections', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields.' }, + { name: 'sections', type: 'array', label: 'Sections', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, six of which this renderer already honours through DetailSection: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette). Two are declared upstream and NOT read here: `group`, which objectui does not implement on a detail section, and `hideEmpty`, deliberately retired in objectui#7129 (maintainer 2026-09-01) in favour of DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle — authoring it does nothing on this renderer. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature.' }, { name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' }, // `hideFields` is DECLARED, not merely honoured (objectui#3808). The spec // declares it (objectstack#5611) and `RecordDetailsRenderer` has read it diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx index aaa36479b2..af9fb28a69 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx @@ -16,7 +16,7 @@ * * | party | said | * |-------------------------------------------|----------------------------| - * | `@objectstack/spec` `RecordDetailsProps` | ⛔ REFUSES the key | + * | `@objectstack/spec` `RecordDetailsProps` | ⛔ REFUSED it (17.2.0; see the 2026-09-05 note below) | * | `@object-ui/types` `DetailViewSection` | ✅ declared it | * | `./zod/views.zod.ts` `DetailViewSectionSchema` | ⛔ absent | * | `RecordDetailsRenderer` | ✅ honoured it | @@ -29,6 +29,24 @@ * refusing, keep the mirror absent. `DetailSection`'s auto-hide heuristic * (4 fields / 25% empty; 3 / 20% on mobile) is now the WHOLE contract. * + * ## ⚠️ 2026-09-05 — the spec moved back, and this file now records a DIVERGENCE + * + * `@objectstack/spec` 17.3.0 RE-DECLARES `hideEmpty` on the `record:details` + * section entry (measured: the entry went 4 → 12 member keys, `hideEmpty` among + * the eight gained, lost set empty). One clause of the ruling — "keep the spec + * refusing" — therefore describes nothing any more, through no act of this + * repo. + * + * objectui's own three parties are UNCHANGED and still agree: the type does not + * declare it, the mirror omits it, the renderer does not read it. 1/4 below is + * pointed at the measured upstream truth so the divergence is a stated fact + * rather than a red test; every other assertion is untouched. + * + * ⇒ Whether objectui re-adopts the key is a MAINTAINER decision (it reverses + * the ruling and re-adds a deleted control) and is reported on objectui#7122, + * NOT taken here. If it is re-adopted, this file is the checklist: three + * parties to move, not one. + * * ## Why one file * * Alignment is a claim about FOUR sources at once, and each of them is green on @@ -114,23 +132,61 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('DetailViewSection.hideEmpty is retired — all four parties agree (#7129)', () => { - it('1/4 — `@objectstack/spec` REFUSES the key on a `record:details` section', () => { +describe('DetailViewSection.hideEmpty is retired in objectui — and the spec re-declared it at 17.3.0 (#7129)', () => { + it('1/4 — ⚠️ `@objectstack/spec` 17.3.0 DECLARES the key again: the fourth party moved', () => { + // ⭐ READ THIS BEFORE CHANGING ANYTHING ELSE IN THIS FILE. + // + // This assertion is inverted from what it said at 17.2.0, and the inversion + // is NOT objectui following the spec back. It records that the ruling's + // fourth party changed its answer underneath the ruling. + // + // The 2026-09-01 ruling (总监批 #28) converged four disagreeing contracts on + // the spec's answer, in these words: "retire the declaration and the read, + // keep the spec refusing, keep the mirror absent". `@objectstack/spec` + // 17.3.0 then re-declared `hideEmpty` on the `record:details` section entry + // — measured, as one of eight keys the entry gained (4 → 12 members, lost + // set empty). So the clause "keep the spec refusing" is no longer a + // description of anything, through no act of this repo. + // + // ⛔ What has NOT changed, and what this file still pins in full: objectui's + // three parties still agree the key is retired. 2/4 (the mirror omits it), + // 3/4 (the type does not declare it) and 4/4 (nothing reads it, end to end) + // are untouched below. Authoring `hideEmpty` on this renderer still does + // nothing, which is the behaviour the ruling ordered. + // + // ⇒ Whether objectui should now re-adopt the key is a MAINTAINER decision — + // it would reverse a five-day-old ruling and re-add a control the ruling + // deleted — and it is reported on objectui#7122 rather than taken here. The + // assertion is pointed at the measured truth so that the divergence is a + // stated, pinned fact instead of a red test somebody eventually deletes. const parsed = RecordDetailsProps.safeParse({ sections: [{ label: 'Contact', fields: ['phone'], hideEmpty: true }], }); - // Envelope, not a bare failure: the code, and the key it names. - expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); - const refused = parsed.error?.issues.flatMap( + expect(parsed.success).toBe(true); + // Value reachability, not just key presence: a declared-but-unusable key + // would leave the divergence smaller than this comment claims. + expect( + (parsed.data as { sections?: { hideEmpty?: boolean }[] })?.sections?.[0]?.hideEmpty, + ).toBe(true); + + // CONTROL, and it is what stops this from reading as "the spec went soft": + // the section object is still STRICT, so an undeclared key is still refused + // by name. `hideEmpty` parses because it was DECLARED, not because + // unrecognized keys stopped being refused. + const undeclared = RecordDetailsProps.safeParse({ + sections: [{ label: 'Contact', fields: ['phone'], __objectui_7129_probe__: true }], + }); + expect(undeclared.success).toBe(false); + expect(undeclared.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + const refused = undeclared.error?.issues.flatMap( (i) => (i as unknown as { keys?: string[] }).keys ?? [], ); - expect(refused).toContain('hideEmpty'); + expect(refused).toContain('__objectui_7129_probe__'); // CONTROL: a declared section key parses AND its value survives, so the - // refusal above is about `hideEmpty` and not about a section object the - // probe built wrong. + // reading above is about the section object the probe built and not about + // a probe that built one wrong. const control = RecordDetailsProps.safeParse({ sections: [{ label: 'Contact', fields: ['phone'], columns: 2 }], }); diff --git a/packages/plugin-form/src/submitRedirect.test.ts b/packages/plugin-form/src/submitRedirect.test.ts index 70ed1eb743..8326ca3c2b 100644 --- a/packages/plugin-form/src/submitRedirect.test.ts +++ b/packages/plugin-form/src/submitRedirect.test.ts @@ -131,8 +131,15 @@ describe('the shape verdict is the contract’s, for every family', () => { // submitter reads on screen is the one the authoring door would have said. // An empty or generic message would be a silent drop wearing an error's // clothes — which is defect 2 with extra steps. + // + // The citation is asserted in the spelling the refusal actually carries. + // `@objectstack/spec` 17.3.0 stripped the `#NNNN` issue numbers from these + // messages while keeping the prescriptive half intact, so `#7496` stopped + // appearing and the ruling is now referenced by DATE. Re-pointed rather + // than dropped: the property this line exists for is that the sentence + // carries its governing ruling, not that it spells it as an issue number. expect(verdict.refusal).toMatch(/`(submitBehavior\.)?url`/); - expect(verdict.refusal).toContain('#7496'); + expect(verdict.refusal).toContain('ruled 2026-08-11'); expect(verdict.refusal.length).toBeGreaterThan(40); }); diff --git a/packages/types/src/__tests__/export-options-spec-parity.test.ts b/packages/types/src/__tests__/export-options-spec-parity.test.ts index cb03b5dfe1..bb4dd2806e 100644 --- a/packages/types/src/__tests__/export-options-spec-parity.test.ts +++ b/packages/types/src/__tests__/export-options-spec-parity.test.ts @@ -175,7 +175,12 @@ describe('exportOptions ↔ installed @objectstack/spec (objectui#4535)', () => // reduced to "Invalid input". const messages = (refused.error?.issues ?? []).map((i) => i.message).join('\n'); expect(messages).toMatch(/pdf/); - expect(messages).toMatch(/8010|1301/); + // Was `/8010|1301/` — the issue numbers. 17.3.0 stripped those citations + // and kept the prescriptive half, so the assertion moves to the half this + // test's own comment above calls the point: the actionable repair (which + // values survive) and the migration command that lists the edits. + expect(messages).toMatch(/'csv', 'xlsx' and 'json'/); + expect(messages).toMatch(/os migrate meta/); }); it('lifts a bare format array at PARSE — which is why the renderer still needs its own tolerance', () => { diff --git a/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts index 9bf2d39e82..d714f93b37 100644 --- a/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts +++ b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts @@ -33,7 +33,12 @@ * live runtime read. Growing `ObjectSchemaClientExtensions` fails the * key-set assertion, forcing the promote-upstream / justify-here * decision consciously (same forcing function as - * `object-view-spec-parity.test.ts`). + * `object-view-spec-parity.test.ts`). At `@objectstack/spec` 17.3.0 that + * surface is EMPTY: `editMode`, its only member, was PROMOTED UPSTREAM — + * which is route one of this very guard, arriving on its own. The + * assertions below moved from "the spec must not declare it" to "the spec + * declares it and the delta no longer restates it", which is the same + * invariant (exactly one declaration of the key) read from the other side. * * When one of these fails, do NOT edit this file first. Decide whether the * key belongs upstream in `@objectstack/spec` (promote it — the #5362 route @@ -59,21 +64,37 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? true : false; type Assert = T; +type Extends = A extends B ? true : false; // 1. Structural derivation — the alias IS spec type + client delta. type _derivation = Assert< Equal >; -// 4. The client delta stays exactly the measured set. `editMode` is read by -// `@object-ui/app-shell` (`utils/recordFormNavigation.ts`, routed by -// `AppContent`'s central `handleEdit` dispatcher). +// 4. The client delta stays exactly the measured set — and at 17.3.0 that set +// is EMPTY. `editMode` was the one member; the spec adopted it (measured: +// `ObjectSchema`'s accept set 42 → 43 keys, gained set exactly `['editMode']`, +// lost set empty, declared as the same `'page' | 'modal'` union), so the key is +// carried by `ServiceObject` now and restating it here would be the two- +// declarations fork this guard exists to prevent. The app-shell read +// (`utils/recordFormNavigation.ts`, routed by `AppContent`'s central +// `handleEdit` dispatcher) is unchanged and still typed — through the +// derivation instead of through the delta. type _extensionSurface = Assert< - Equal + Equal +>; + +// …and `editMode` really is reachable on the derived type, not merely absent +// from the delta. This is the half a `keyof … = never` assertion cannot state: +// an empty delta would read identically if the spec had NOT adopted the key, +// and the member would then be silently unwritable. +type _editModeStillTyped = Assert< + Extends<{ name: string; fields: Record; editMode: 'page' }, ObjectSchemaMetadata> >; // 2. The three keys #5362 measured as read-but-undeclared are admitted, and -// a typed document can carry the client extension alongside them. +// a typed document can carry `editMode` alongside them — carried by the spec's +// own `ServiceObject` since 17.3.0, no longer by a local delta member. const derivedAdmitsTheThreeKeys: ObjectSchemaMetadata = { name: 'task', fields: { @@ -149,27 +170,34 @@ describe('ObjectSchemaMetadata spec derivation (#5362)', () => { expect(keys).toContain('nameField'); }); - it('does not declare the client extension — editMode stays an objectui-side member', () => { - // If this starts failing, the spec has adopted `editMode`: retire it from - // `ObjectSchemaClientExtensions` and let the derivation carry it. - expect(keys).not.toContain('editMode'); + it('DECLARES editMode — the spec adopted it at 17.3.0 and the client delta retired its copy', () => { + // The prescription the previous version of this test carried ("If this + // starts failing, the spec has adopted `editMode`: retire it from + // `ObjectSchemaClientExtensions` and let the derivation carry it") has been + // executed. This is its other half: the key must be present HERE, or the + // retirement removed the declaration without anything replacing it. + expect(keys).toContain('editMode'); }); - it('accepts the spec-shaped document through the spec parse (value reachability, not just key presence)', () => { - const { editMode: _clientOnly, ...specShaped } = derivedAdmitsTheThreeKeys; - const parsed = SpecObjectSchema.safeParse(specShaped); + it('accepts the whole document — editMode included — through the spec parse', () => { + // Key presence is not value reachability: `editMode` could be declared and + // still refuse `'page'`. The fixture is passed WHOLE, with no key stripped + // out, which is the assertion that would have failed at 17.2.0. + const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys); expect(parsed.success).toBe(true); + expect(parsed.data?.editMode).toBe('page'); }); - it('rejects the client extension at the spec parse — editMode is client-side ONLY', () => { - // The spec parse is strict on unrecognized keys (objectstack#4001), so a - // published document carrying `editMode` is rejected loudly, not dropped. - // This pins what "client extension" means: the member exists on the CLIENT - // type for documents served by client-side data sources, and it cannot - // ride a spec-validated publish path. If the spec ever adopts `editMode`, - // this test and the absence test above both flip — retire the extension - // member and let the derivation carry the key. - const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys); + it('still refuses an INVENTED client key by name — the strictness that made editMode a delta has not gone soft', () => { + // The control for the two assertions above, and the reason they are not + // vacuous. `ObjectSchema` is strict on unrecognized keys (objectstack#4001); + // if it had instead been loosened to a passthrough, `editMode` would parse + // green for a reason that has nothing to do with adoption and this file + // would report a settlement that did not happen. + const parsed = SpecObjectSchema.safeParse({ + ...derivedAdmitsTheThreeKeys, + editModeX: 'page', + }); expect(parsed.success).toBe(false); if (parsed.success) return; const unrecognized = parsed.error.issues.find( @@ -178,7 +206,7 @@ describe('ObjectSchemaMetadata spec derivation (#5362)', () => { expect(unrecognized).toBeDefined(); expect( (unrecognized as { keys?: string[] } | undefined)?.keys, - ).toEqual(['editMode']); + ).toEqual(['editModeX']); }); it('keeps the compile-time fixtures alive', () => { diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index 2f24edc3cf..9afcec8924 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -614,6 +614,12 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 ['FileMetadata', 'UploadedFileMetadata'], ['PageRegion', 'PageNodeRegion'], ['PageRegionSchema', 'PageNodeRegionSchema'], + // `FlowNodeLike` became a spec export in 17.3.0 (objectui#7122), so + // `@object-ui/app-shell`'s two same-named local declarations became + // shadows and were renamed to `InspectorFlowNode` (the inspector's read + // type, `id` REQUIRED where the spec's is optional) and `ScopeFlowNode` + // (the scope walker's pre-validation probe, every member `unknown`). + ['FlowNodeLike', 'InspectorFlowNode'], // `['ResponsiveConfig', 'MobileResponsiveConfig']` left this list when the // local dialect was RETIRED (objectui#7519) — see the header. // `WidgetManifest` / `WidgetSource` moved OUT of this list on the @@ -716,6 +722,42 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 }, ); + /** + * THE TRIPWIRE'S FOURTH FIRING, on the `@objectstack/spec` 17.3.0 bump + * (objectui#7122). 17.3.0 published objectstack#11027's retirement of the + * whole `ui/responsive` vocabulary, which the objectui#7580 ruling had + * already localized here on the merged-but-unreleased upstream change. + * + * For the length of that one pin interval the local declarations and live + * spec exports shared a name, so both carried a SELF-EXPIRING ALLOW entry in + * `scripts/check-spec-symbol-derivation.mjs` whose own text set the + * expiry: "⛔ Delete this entry on the pin bump — ratchet 3 will force it — + * and move the name to the absence pin in page-nav-misc-spec-parity.test.ts". + * The bump did exactly that; these rows are where the names landed. + * + * `BreakpointName` is `@object-ui/types`'; `BreakpointColumnMap` is + * `@object-ui/layout`'s. Both are kept because `responsive-grid` is a + * REGISTERED SDUI component whose authorable `columns` reaches + * `resolveColumnClasses` on the render path — the tombstone's own stated + * return condition, met on the renderer side. The exports are live in this + * workspace, so a spec re-publish is a live collision, not a latent one. + */ + it.each([['BreakpointName'], ['BreakpointColumnMap']])( + 'the spec no longer owns `%s`, re-homed under the objectui#7580 ruling', + (localized) => { + expect( + names, + `spec owns '${localized}' again — the objectstack#11027 ui/responsive ` + + `retirement has been undone upstream while this workspace exports that exact ` + + `name (@object-ui/types' responsive union / @object-ui/layout's column map, ` + + `objectui#7580). This is a live collision: re-triage against objectstack#4115 ` + + `— derive from the spec again, or arbitrate the name. ⛔ Do not answer it by ` + + `re-adding an ALLOW entry: the deleted ones were an INTERVAL waiver, not a ` + + `deliberate-divergence waiver.`, + ).not.toContain(localized); + }, + ); + /** * `OfflineConfig` is the one the spec vacated that objectui did NOT reclaim, * and the reason is worth pinning rather than remembering: **the spec was @@ -760,6 +802,8 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 // `MobileResponsiveConfig` retired with its type (objectui#7519). ['RuntimeWidgetManifest', 'SDUI component manifest, not the field-widget plugin'], ['RuntimeWidgetSource', 'objectui module/inline/registry loader union'], + ['InspectorFlowNode', "app-shell's flow-inspector read type, `id` REQUIRED"], + ['ScopeFlowNode', "app-shell's pre-validation scope probe, members `unknown`"], ])('the spec does not own `%s` (%s)', (name) => { expect(names).not.toContain(name); }); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 3710dce9db..d4fac3ac50 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -457,7 +457,11 @@ describe('DashboardWidgetSchema derives from the spec', () => { expect(issue, '`responsive` must be reported by name').toBeDefined(); // The tombstone names its own retirement, not #5010's — if this ever reads // `#5010` the key was folded into the wrong retirement upstream. - expect(issue?.message).toContain('#4876, ADR-0049 D2'); + // Was `'#4876, ADR-0049 D2'`. 17.3.0 stripped the `#NNNN` half of that + // citation and kept the ADR reference and the prescription, so the + // assertion keeps the durable half plus the repair the author acts on. + expect(issue?.message).toContain('ADR-0049 D2'); + expect(issue?.message).toContain('Delete the key.'); // And it must point at the surviving home for breakpoint behaviour rather // than just saying "removed". expect(issue?.message).toContain('page.components[].responsive'); 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 index 11a1a4bf6e..e7c39b9d01 100644 --- a/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts +++ b/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts @@ -1,34 +1,61 @@ // 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). + * Where the authoring boundary actually runs for the select-option and + * multiline-editor keys — pinned so the comments that describe it cannot rot + * (objectui#7014, re-pointed for `@objectstack/spec` 17.3.0). * - * 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: + * ## What this file was, and what moved + * + * objectui#7014 measured three doc comments in `packages/types/src/field-types.ts` + * that claimed the installed `@objectstack/spec` DECLARED the keys beside them: * * "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). + * Against `@objectstack/spec@17.2.0` those claims were false — the spec had no + * such keys and refused them BY NAME — and a false canonical claim is a planted + * premise for the next agent, not stale documentation. So this file pinned the + * BOUNDARY rather than removing anything. + * + * ⭐ At `@objectstack/spec` **17.3.0** the boundary MOVED, and it moved to where + * those original comments said it was. The maintainer's 2026-08-25 ruling on + * objectui#6140 / objectui#6153 (Option A) declared both keys, and 17.3.0 + * implements it: `SelectOptionSchema.description` and a `rows` gated to the four + * multiline editor types are now authorable. Re-pointing these assertions + * EXECUTES that ruling; it does not make a new one. + * + * ## The boundary moved, it did not disappear — and that is the point * - * 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. + * Two of the three keys #7014 measured are now declared. The third is not, and + * neither are the keys around them. Everything below is written as a PAIR, so + * this file keeps failing loudly in whichever direction the contract next moves: * - * 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". + * - `description` and `rows` are DECLARED, each with a value-level assertion + * so "the key is admitted" is not confused with "anything may be written + * into it" (a key that were merely tolerated would pass the first and fail + * the second). + * - `icon` and `disabled` remain OUTSIDE the option vocabulary, refused BY + * NAME, and they are the control that proves the schema still refuses + * anything at all rather than having gone permissive. + * - `rows` is TYPE-GATED: declared on `textarea` / `markdown` / `html` / + * `richtext` and refused on the field types that do not take it. "Declared" + * does not mean "declared everywhere", and the refusal there arrives as a + * cross-field refinement rather than `unrecognized_keys`. + * - The four inert rich-text keys (`toolbar` / `preview` / `minHeight` / + * `maxHeight`) are STILL undeclared. The ruling's expansion stops at `rows` + * (objectui#7635 records this explicitly: ⛔ do not widen), so they are + * pinned as refused — that door has to be seen to stay shut. + * + * ⚠️ The prose in `packages/types/src/field-types.ts` and + * `packages/types/src/select-option.ts` still describes the 17.2.0 boundary and + * is now false in the other direction. Correcting it is objectui#7635's + * declared surface (the comment/prose sites), not this file's — recorded here + * so the two halves are not repaired twice or, worse, once. + * + * Every assertion pairs its verdict with a CONTROL that changes exactly one + * thing, so a red here reads "the key's status changed" and never "the fixture + * drifted". */ import { describe, it, expect } from 'vitest'; @@ -48,41 +75,77 @@ const refusedByName = (result: { success: boolean; error?: { issues: readonly an (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']); +/** Pull any issue whose `path` ends at `key`, whatever its code. */ +const issueAtPath = (result: { success: boolean; error?: { issues: readonly any[] } }, key: string) => + result.success + ? undefined + : result.error!.issues.find((i) => (i.path ?? []).at(-1) === key); + +describe('spec SelectOptionSchema — `description` is inside the vocabulary, `icon`/`disabled` are not', () => { + it('declares exactly the six keys 17.3.0 leaves it with', () => { + // If this list changes again, the comments in field-types.ts and + // select-option.ts must be re-pointed with it rather than left stale. + expect(SPEC_OPTION_KEYS).toEqual([ + 'color', + 'default', + 'description', + '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`, () => { + it('ADMITS `description` — the key ruled authorable on 2026-08-25', () => { + const res = SpecSelectOptionSchema.safeParse({ ...validOption, description: 'Blocks the release' }); + expect(res.success).toBe(true); + // …and it is admitted as a DECLARATION, not as tolerance: a wrongly-typed + // value is refused AT the key. A merely-ignored key would pass this too, + // which is what makes the pair a reading. + const wrongType = SpecSelectOptionSchema.safeParse({ ...validOption, description: 42 }); + expect(wrongType.success).toBe(false); + expect(issueAtPath(wrongType, 'description')?.code).toBe('invalid_type'); + }); + + for (const key of ['icon', 'disabled'] as const) { + it(`still 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(); + // Control, per fixture: the key is the only difference. + expect(SpecSelectOptionSchema.safeParse(validOption).success).toBe(true); }); } }); -describe('FieldSchema routes options through that strict schema', () => { +describe('FieldSchema routes options through that 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`', () => { + it('accepts the WHOLE field when an option carries `description`', () => { const res = FieldSchema.safeParse(field([{ ...validOption, description: 'help' }])); + expect(res.success).toBe(true); + }); + + it('still fails the WHOLE field when an option carries `icon`, naming it at its path', () => { + const res = FieldSchema.safeParse(field([{ ...validOption, icon: 'flame' }])); expect(res.success).toBe(false); - expect(refusedByName(res, 'description')).toBeDefined(); + const named = refusedByName(res, 'icon'); + expect(named, "expected unrecognized_keys naming 'icon'").toBeDefined(); + // The refusal is reported AT the option, not at the field — this is what + // "routes options through that schema" means, and it is the half that would + // silently disappear if options were ever parsed loosely. + expect((named as { path?: unknown[] }).path).toEqual(['options', 0]); }); }); -describe('FieldSchema refuses `rows` by name on every multiline editor type', () => { +describe('FieldSchema declares `rows` on the four multiline editor types', () => { const base = (type: string) => ({ name: 'body', type, label: 'Body' }); for (const type of ['textarea', 'markdown', 'html', 'richtext'] as const) { @@ -90,10 +153,48 @@ describe('FieldSchema refuses `rows` by name on every multiline editor type', () expect(FieldSchema.safeParse(base(type)).success).toBe(true); }); - it(`\`${type}\` with \`rows\` is refused BY NAME`, () => { + it(`\`${type}\` ADMITS \`rows\`, and enforces it as a positive integer`, () => { + expect(FieldSchema.safeParse({ ...base(type), rows: 4 }).success).toBe(true); + // The value half: declared does not mean unchecked. + const zero = FieldSchema.safeParse({ ...base(type), rows: 0 }); + expect(zero.success).toBe(false); + expect(issueAtPath(zero, 'rows')?.code).toBe('too_small'); + const stringy = FieldSchema.safeParse({ ...base(type), rows: '4' }); + expect(stringy.success).toBe(false); + expect(issueAtPath(stringy, 'rows')?.code).toBe('invalid_type'); + }); + + for (const key of ['toolbar', 'preview', 'minHeight', 'maxHeight'] as const) { + it(`\`${type}\` still refuses the inert rich-text key \`${key}\` BY NAME`, () => { + const value = key === 'toolbar' ? ['bold'] : key === 'preview' ? true : 200; + const res = FieldSchema.safeParse({ ...base(type), [key]: value }); + expect(res.success).toBe(false); + expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}' on ${type}`).toBeDefined(); + // Control, per fixture: the key is the only difference. + expect(FieldSchema.safeParse(base(type)).success).toBe(true); + }); + } + } +}); + +describe('`rows` is TYPE-GATED — declared is not the same as declared everywhere', () => { + const base = (type: string) => ({ name: 'body', type, label: 'Body' }); + + for (const type of ['text', 'select'] as const) { + it(`control: \`${type}\` without \`rows\` is accepted`, () => { + expect(FieldSchema.safeParse(base(type)).success).toBe(true); + }); + + it(`\`${type}\` refuses \`rows\` — and as a cross-field refinement, not \`unrecognized_keys\``, () => { 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(); + const at = issueAtPath(res, 'rows'); + expect(at, `expected an issue at 'rows' on ${type}`).toBeDefined(); + // The distinction matters: `rows` IS a declared key of the field + // vocabulary, so the schema does not report it as unrecognized. It is the + // TYPE that does not take it, which arrives as a refinement. + expect(at?.code).toBe('custom'); + expect(refusedByName(res, 'rows')).toBeUndefined(); }); } }); diff --git a/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts index 844e883879..63a824290d 100644 --- a/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts +++ b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts @@ -39,6 +39,30 @@ * as an OPTIONAL key, so every document that face accepted before it is still * accepted. It is asserted below rather than left implicit. * + * ## ⭐ What `@objectstack/spec` 17.3.0 changed here, and why nothing was edited + * + * 17.3.0 adopted `SelectOptionSchema.description` (maintainer ruling + * 2026-08-25 on objectui#6140 / objectui#6153, Option A). `SelectOptionBase` + * derives the spec's keys BY REFERENCE, so the key arrived on BOTH faces with + * no edit to `../select-option` at all — which is exactly the property the + * derivation exists to have, and it is worth saying out loud that the pins + * below moved while the type did not. + * + * Two consequences are written into the assertions rather than left implicit: + * + * 1. The FORM face gained a member it never declared, so its + * pre-convergence equality became a SET DIFFERENCE — the same idiom the + * object-metadata face has used for `default` since the convergence. A + * plain `Equal` there would now have to be repaired every time the spec + * grows, which is the hand-copy failure this file exists to prevent. + * 2. The two faces no longer differ on `description` — it is inherited on + * both — so the "differ on exactly `value`" assertion states that + * directly instead of excusing `description` from the comparison. + * + * The runtime half moved the same way: `description` is asserted ACCEPTED, and + * `disabled` / `icon` carry the refusal half so a schema that had gone + * permissive cannot pass this file. + * * ## The control the spec fixtures need * * A select option's `value` is a lowercase machine identifier with a minimum @@ -96,9 +120,26 @@ interface PRE_CONVERGENCE_SelectOptionMetadata { visibleWhen?: ExpressionWire; } -/** The SDUI form face is member-for-member what it was. */ -export type assertionFormFaceUnchanged = - Expect< Equal< SelectOption, PRE_CONVERGENCE_SelectOption > >; +/** + * The SDUI form face kept every member it had, at the same type — stated by + * removing the one key the spec has since added and comparing what is left. + * `PRE_CONVERGENCE_SelectOption` above is a HISTORICAL record and is never + * edited to match; it is the fixed end of this comparison. + */ +export type assertionFormFaceKeptEveryMember = + Expect< Equal< Omit< SelectOption, 'description' >, PRE_CONVERGENCE_SelectOption > >; + +/** + * …and `description` is the ONLY key it gained since. Written as a set + * difference so a second key cannot ride along silently: add one and this + * stops being `'description'`. + */ +export type assertionFormFaceGainedOnlyDescription = + Expect< Equal< Exclude< keyof SelectOption, keyof PRE_CONVERGENCE_SelectOption >, 'description' > >; + +/** …and it arrived OPTIONAL, so no form that type-checked before now fails. */ +export type assertionDescriptionIsOptional = + Expect< Equal< SelectOption['description'], string | undefined > >; /** * The object-metadata face kept every member it had, at the same type — the @@ -119,9 +160,16 @@ export type assertionMetadataFaceGainedOnlyDefault = export type assertionDefaultIsOptional = Expect< Equal< SelectOptionMetadata['default'], boolean | undefined > >; -/** The two faces differ on exactly one inherited member, and it is `value`. */ +/** + * The two faces differ on exactly one inherited member, and it is `value`. + * + * `description` used to be excused from this comparison because only the + * object-metadata face declared it; 17.3.0 put it in the derivation, so both + * faces carry it and the comparison states the difference without an + * exemption. Re-adding one here would hide the next divergence. + */ export type assertionOnlyValueDiffers = - Expect< Equal< Omit< SelectOption, 'value' >, Omit< SelectOptionMetadata, 'value' | 'description' > > >; + Expect< Equal< Omit< SelectOption, 'value' >, Omit< SelectOptionMetadata, 'value' > > >; export type assertionFormValueIsWide = Expect< Equal< SelectOption['value'], string | number | boolean > >; export type assertionMetadataValueIsSpecIdentifier = @@ -138,6 +186,7 @@ export type assertionMetadataValueIsSpecIdentifier = const SPEC_KEYS_ON_BASE: readonly (keyof SelectOptionBase)[] = [ 'color', 'default', + 'description', 'label', 'value', 'visibleWhen', @@ -184,6 +233,7 @@ export const acceptsEveryDeclaredKeyOnFormFace = (): SelectOption => ({ color: '#ef4444', default: false, visibleWhen: "'admin' in current_user.positions", + description: 'Blocks the release', disabled: true, icon: 'hash', }); @@ -240,7 +290,6 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { for (const [key, value] of [ ['disabled', true], ['icon', 'flame'], - ['description', 'Blocks the release'], ] as const) { it(`the spec refuses \`${key}\` BY NAME, with the same option minus the key accepted`, () => { const res = SpecSelectOptionSchema.safeParse({ ...CONTROL, [key]: value }); @@ -251,7 +300,21 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { }); } - it('a fully-populated read-model option is refused as a whole, naming all three', () => { + it('`description` is NOT one of them — 17.3.0 moved it inside the vocabulary', () => { + // The counterpart of the two refusals above, and the reason this file's + // dialect list is two keys and not three: `description` was an + // objectui-only key until the 2026-08-25 ruling declared it, so it is + // asserted ACCEPTED here rather than quietly dropped from the loop. + const res = SpecSelectOptionSchema.safeParse({ ...CONTROL, description: 'Blocks the release' }); + expect(res.success).toBe(true); + expect(refusedByName(res, 'description')).toBeUndefined(); + // Control: the schema has not gone permissive — it still refuses by name. + expect( + refusedByName(SpecSelectOptionSchema.safeParse({ ...CONTROL, icon: 'flame' }), 'icon'), + ).toBeDefined(); + }); + + it('a fully-populated read-model option is refused as a whole, naming the two still outside', () => { const readModel: SelectOptionMetadata = { label: 'High priority', value: 'high_priority', @@ -263,12 +326,16 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { }; const res = SpecSelectOptionSchema.safeParse(readModel); expect(res.success).toBe(false); - for (const key of ['description', 'disabled', 'icon']) { + for (const key of ['disabled', 'icon']) { expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); } + // …and `description` is NOT among them. Stated positively, because the + // whole-document refusal would still be red with `description` refused too, + // and this file would then be pinning a boundary the contract has left. + expect(refusedByName(res, 'description')).toBeUndefined(); // Control: the same document with only the spec keys parses. - const { description: _d, disabled: _di, icon: _i, ...specOnly } = readModel; + const { disabled: _di, icon: _i, ...specOnly } = readModel; expect(SpecSelectOptionSchema.safeParse(specOnly).success).toBe(true); }); }); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index ea009bceea..185d0024be 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -895,36 +895,42 @@ import type { ServiceObject } from '@objectstack/spec/data'; /** * Client-side members the objectui runtime reads on the object document but * `@objectstack/spec` does not declare. Every member here must cite a live - * runtime read — this interface is the measured client DELTA on top of the - * spec document, not a place to restate spec keys (restating them would - * recreate the hand-written fork objectui#5362 retired). The member list is - * pinned by `__tests__/object-schema-metadata-spec-derivation.test.ts`, so - * growing it is a conscious decision: promote the key upstream to the spec, - * or add it here with the runtime read that justifies it. - */ -export interface ObjectSchemaClientExtensions { - /** - * Default UI mode for record create/edit interactions. - * - * - `'modal'` (default) — open the record form inside a `ModalForm` dialog - * overlaid on top of the current view. Suitable for short forms and - * quick edits. - * - `'page'` — navigate to a dedicated full-screen route - * (`/{objectName}/new` for create, `/{objectName}/record/:id/edit` for - * edit). URLs are deep-linkable, refresh-safe, and integrate with the - * browser back button. Recommended for objects with many fields, - * `tabbed` / `wizard` form layouts, or scenarios that benefit from - * shareable links to the create/edit form. - * - * The host application reads this flag in its central `handleEdit` - * dispatcher (see `@object-ui/app-shell` `AppContent` and - * `utils/recordFormNavigation.ts`) — switching the value requires no code - * changes. - * - * @default 'modal' - */ - editMode?: 'modal' | 'page'; -} + * runtime read — this type is the measured client DELTA on top of the spec + * document, not a place to restate spec keys (restating them would recreate + * the hand-written fork objectui#5362 retired). The member list is pinned by + * `__tests__/object-schema-metadata-spec-derivation.test.ts`, so growing it is + * a conscious decision: promote the key upstream to the spec, or add it here + * with the runtime read that justifies it. + * + * ## The delta is EMPTY today, and that is the pinned state + * + * `editMode` was the one member. `@objectstack/spec` 17.3.0 ADOPTED it — + * measured on the installed build, `ObjectSchema`'s accept set went 42 → 43 + * keys with gained set exactly `['editMode']` and an empty lost set, declared + * as `editMode?: 'page' | 'modal'`, the same union this file carried. So the + * key is no longer a client delta, and restating it here would be exactly the + * fork this type exists to prevent: two declarations of one key, with the + * local one shadowing the spec's for every reader. + * + * The retirement is what the pin's own docblock PRESCRIBED for this event + * ("If the spec ever adopts `editMode`, this test and the absence test above + * both flip — retire the extension member and let the derivation carry the + * key"), so it is executing a standing instruction, not a new decision. + * + * ⚠️ Nothing about `editMode` is removed from the product: it stays authorable + * and stays typed on {@link ObjectSchemaMetadata}, now carried by + * `ServiceObject`. `app-shell`'s `utils/recordFormNavigation.ts` read is + * unchanged. What DID change is that a published, spec-validated object + * document may now carry it — at 17.2.0 the spec refused the key by name. + * + * The type is kept (rather than deleted) because it is exported from + * `@object-ui/types` and because the derivation `spec type + client delta` is + * the shape this package pins; an empty delta states "objectui adds nothing + * here today", which is a fact worth keeping addressable. `Record` rather than `{}`: `keyof` answers `never`, and it does not trip + * `@typescript-eslint/no-empty-object-type`. + */ +export type ObjectSchemaClientExtensions = Record; /** * Object schema definition — the object document a data source's diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df8fbcd77e..d9e2ae6952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -68,7 +68,7 @@ importers: version: 4.1.10(vitest@4.1.10) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) axe-core: specifier: ^4.13.0 version: 4.13.0 @@ -279,13 +279,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -318,7 +318,7 @@ importers: version: 4.1.10(vitest@4.1.10) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) happy-dom: specifier: ^20.11.2 version: 20.11.2 @@ -327,7 +327,7 @@ importers: version: 1.31.0(react@19.2.8) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 react: specifier: 19.2.8 version: 19.2.8 @@ -420,7 +420,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -463,7 +463,7 @@ importers: version: 19.2.4(@types/react@19.2.18) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -521,10 +521,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -778,13 +778,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -878,7 +878,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) better-auth: specifier: ^1.6.28 version: 1.6.28(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -918,7 +918,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) chalk: specifier: ^6.0.0 version: 6.0.0 @@ -955,7 +955,7 @@ importers: version: 26.2.0 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -974,7 +974,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1010,7 +1010,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1164,10 +1164,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 shadcn: specifier: ^4.18.0 version: 4.18.0(typescript@6.0.3) @@ -1194,10 +1194,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1241,7 +1241,7 @@ importers: version: 2.4.9 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1259,17 +1259,17 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* version: link:../test-support tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1299,7 +1299,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1342,7 +1342,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -1406,7 +1406,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1453,7 +1453,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1634,7 +1634,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1676,7 +1676,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1804,7 +1804,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1913,7 +1913,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.1.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2038,7 +2038,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2099,7 +2099,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2160,7 +2160,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2197,7 +2197,7 @@ importers: version: 2.15.0(@types/node@26.2.0)(typescript@6.0.3) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2276,7 +2276,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2331,7 +2331,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2370,7 +2370,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2526,7 +2526,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2571,7 +2571,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2629,7 +2629,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2693,7 +2693,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2758,7 +2758,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2835,7 +2835,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2850,10 +2850,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2874,13 +2874,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2892,7 +2892,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -2924,7 +2924,7 @@ importers: version: 3.9.2 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -4252,26 +4252,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.2.0': - resolution: {integrity: sha512-bVRrWyqpn71U6HxQ2IuOuI5sg6b/xp0ZGaKms41+y1a/22bv1Q9cdTd3nY71jGvKmVZ/TJMEd8+D1poAPQQrKQ==} + '@objectstack/client@17.3.0': + resolution: {integrity: sha512-EoxDSZPxdqiInaEQ2eZlRAL0LES73sT2xVUqbUW84pXcB2f+s2uztOZmdkE/IDS65oBrMe8NnJ++q5RrwgI+wg==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.2.0': - resolution: {integrity: sha512-82I4eL/qcuFKOBhXWEGjJPAS9mOfwGVd9jeRbhRGuUZTm282sCuVoR/QC1JkDVxsFE0kSAtE4ej0XsYxP/spZQ==} + '@objectstack/core@17.3.0': + resolution: {integrity: sha512-MgH+HYLEl7dCQJ9XW4Wi/mBX32+WMUSf5X65DplUvHvfOeLQHnDg/GoXjnNVFBI+82A0tgkBPpvsvn7haduY8A==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.2.0': - resolution: {integrity: sha512-5p6gn92AwNj0e1tfT6mCjBkIUOPjjPr4C6K7GtOAfL20MRIILKgVtf94rZBpWr8+WBGRND050RpssgtN8CqQ/A==} + '@objectstack/formula@17.3.0': + resolution: {integrity: sha512-zLR5DdFshJOvNMX9G4CPBPbWeVrWa/b1GxcMFhyNldtJCb92YlGsgd7vw8OWUkoxE+k9AnbfbxbDaxSxY2Ecgw==} - '@objectstack/lint@17.2.0': - resolution: {integrity: sha512-PbyvKuiDdIQ61nVTy2y+19wl3bFJ3uTQk8u0l+OpNJsUYR1YxXCMnayC/h7h66kTPLs4IgjVTOedc3hV0CiSkg==} + '@objectstack/lint@17.3.0': + resolution: {integrity: sha512-pRFjIZ+sQmlPT2N24aM9szwHK50P3AjiwmHEFi6DUHJGtDq/Tt4HtZ+89nF7WP0MfT1AK8nlNadxsDrHPF8UCg==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.2.0': - resolution: {integrity: sha512-ntkTsawiwCVx1wubBdLqfuM1KslPy+PwRTevX2YqBC567KVsRMyn2RbmcDXTAVLUDQNOl2yab7oI7vXZO82mag==} + '@objectstack/sdui-parser@17.3.0': + resolution: {integrity: sha512-LbzxlLBoDBd0yxoIYMhYuXJ+j3iEsqvvBtrB0JWWda8Iy6mhPqbpyjj11Y5bZ2A0yGf20z0b/vXLDi1ISNIwpA==} - '@objectstack/spec@17.2.0': - resolution: {integrity: sha512-nQzyR9+9JQEtLzFastQbj0ETLLbOm4sSY+sNUEX2k0QgZThCsMB1rKPv100c6GQZYg1BaLfz5xc8tv9oH3cKqg==} + '@objectstack/spec@17.3.0': + resolution: {integrity: sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4279,6 +4279,10 @@ packages: ai: optional: true + '@objectstack/types@17.3.0': + resolution: {integrity: sha512-G5c2+V0gzR5co5XcZ0Zs4uTOp42421Yj6fWEWVV6kEpKfIzaDYUzv1HfmwdOfLklrbtNiF3loBg6jB00BVge/g==} + engines: {node: '>=22.0.0'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -7661,9 +7665,6 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} - fast-uri@3.1.7: resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} @@ -9299,11 +9300,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9685,8 +9681,8 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} potpack@2.1.0: @@ -12705,32 +12701,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/client@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/core': 17.2.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/core': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/core@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/types': 17.3.0(ai@7.0.65(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/lint@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.2.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/sdui-parser': 17.2.0 - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/formula': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/sdui-parser': 17.3.0 + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12738,15 +12735,21 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.2.0': {} + '@objectstack/sdui-parser@17.3.0': {} - '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: pg-connection-string: 2.14.0 zod: 4.4.3 optionalDependencies: ai: 7.0.65(zod@4.4.3) + '@objectstack/types@17.3.0(ai@7.0.65(zod@4.4.3))': + dependencies: + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + transitivePeerDependencies: + - ai + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -14001,7 +14004,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.26 + postcss: 8.5.28 tailwindcss: 4.3.3 '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': @@ -14852,7 +14855,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -14957,13 +14960,13 @@ snapshots: atomically@1.7.0: {} - autoprefixer@10.5.4(postcss@8.5.26): + autoprefixer@10.5.4(postcss@8.5.28): dependencies: browserslist: 4.28.6 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.26 + postcss: 8.5.28 postcss-value-parser: 4.2.0 axe-core@4.13.0: {} @@ -16242,10 +16245,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.6: {} - - fast-uri@3.1.7: - optional: true + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.2: dependencies: @@ -18128,8 +18128,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.17: {} - nanoid@3.3.18: {} nanoid@6.0.1: {} @@ -18463,12 +18461,12 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.26 + postcss: 8.5.28 tsx: 4.23.12 yaml: 2.9.0 @@ -18490,9 +18488,9 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.26: + postcss@8.5.28: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -19308,7 +19306,7 @@ snapshots: kleur: 4.1.5 open: 11.0.1 ora: 8.2.0 - postcss: 8.5.26 + postcss: 8.5.28 postcss-selector-parser: 7.1.5 prompts: 2.4.2 recast: 0.23.21 @@ -19799,7 +19797,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -19810,7 +19808,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -19821,7 +19819,7 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.58.2(@types/node@26.2.0) '@swc/core': 1.15.33(@swc/helpers@0.5.23) - postcss: 8.5.26 + postcss: 8.5.28 typescript: 6.0.3 transitivePeerDependencies: - jiti @@ -20155,7 +20153,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: @@ -20170,7 +20168,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: @@ -20185,7 +20183,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 9caf395e4f..660d29cc73 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -670,46 +670,18 @@ const ALLOW = { "case this map exists to make someone write a reason for.", issue: 4115, }, - // ── Re-homed layout vocabulary, objectui#7580 (ruling 2026-09-04, option A) ── - // Both entries below are SELF-EXPIRING, and deliberately so. They are the same - // shape as the three theme document types noted at the end of this map: a - // maintainer ruling localized a vocabulary whose upstream retirement is MERGED - // but not yet RELEASED, so for the length of one pin interval the local - // declaration and a live spec export share a name. Ratchet 3 fails an ALLOW - // entry that excuses nothing, so the pin bump that lands objectstack#11027 - // cannot leave either entry behind — it must delete them and pin the vacancy - // where it can execute, exactly as objectui#5668 did for the theme trio. - // - // ⛔ Neither is a deliberate-divergence waiver, and neither may be renewed on - // that reading: the local declarations are the retired spec members VERBATIM. - // The reason they are here is the interval, not a difference. - "@object-ui/types:BreakpointName": { - reason: - "Re-homed from `@objectstack/spec/ui` by the objectui#7580 ruling, NOT a fork: " + - "objectstack#11027 retired the whole `ui/responsive` vocabulary upstream " + - "(tombstone + `RETIRED_DEFS_BY_MAJOR[18]`), and this repo keeps the union because " + - "`responsive-grid` is a REGISTERED SDUI component whose authorable `columns` reaches " + - "`resolveColumnClasses` on the render path — the tombstone's own stated return " + - "condition, met on the renderer side. The collision is an INTERVAL, not a divergence: " + - "the pin is still 17.2.0, which pre-dates the retirement. Members are the retired " + - "enum's verbatim (`xs`…`2xl`), width-pinned by " + - "packages/types/src/__tests__/spec-derived-unions.test.ts. ⛔ Delete this entry on the " + - "pin bump — ratchet 3 will force it — and move the name to the absence pin in " + - "page-nav-misc-spec-parity.test.ts, the disposition objectui#5716/#5668 used for the " + - "theme trio.", - issue: 7580, - }, - "@object-ui/layout:BreakpointColumnMap": { - reason: - "The `BreakpointName` entry above, one package over and for the same interval: the " + - "authorable `columns` input of the registered `responsive-grid` component. Declared " + - "verbatim from the retired `BreakpointColumnMapSchema` — six optional column counts " + - "keyed `xs`…`2xl`, and no index signature, because that schema was `$strict`. Its " + - "twin `BreakpointOrderMap` was NOT re-homed (ruling item 3): it had no read point in " + - "the package and existed only because the retired `ResponsiveConfigSchema` paired the " + - "two. ⛔ Delete this entry on the pin bump; ratchet 3 will force it.", - issue: 7580, - }, + // ── Re-homed layout vocabulary, objectui#7580 — ENTRIES RETIRED, spec 17.3.0 ── + // `@object-ui/types:BreakpointName` and `@object-ui/layout:BreakpointColumnMap` + // lived here for exactly the interval their own text described: a maintainer + // ruling localized the `ui/responsive` vocabulary while objectstack#11027's + // upstream retirement was MERGED but not yet RELEASED, so the local + // declarations and live spec exports shared a name. 17.3.0 published the + // retirement, the collisions ended, ratchet 3 failed both entries as excusing + // nothing, and the pin bump deleted them — the disposition the entries + // themselves prescribed. The vacancy is pinned where it can execute: + // page-nav-misc-spec-parity.test.ts asserts both names ABSENT from the spec + // export set, so an upstream re-publish is a loud collision rather than an + // exemption a future fork inherits under the same name. // The three theme document types (`Theme`, `ThemeMode`, `ColorPalette`, // objectui#5716 ruling, option A — localize) carried ALLOW entries here from // the localization until the `@objectstack/spec` 17.2.0 refresh