diff --git a/.changeset/6527-retired-field-key-tombstone-registry.md b/.changeset/6527-retired-field-key-tombstone-registry.md new file mode 100644 index 0000000000..c0dd51cb63 --- /dev/null +++ b/.changeset/6527-retired-field-key-tombstone-registry.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +'@object-ui/app-shell': patch +'@object-ui/plugin-designer': patch +--- + +One tombstone registry for the designer seam's retired field keys +(objectui#6527). Three independently maintained `RETIRED_FIELD_KEYS` literals +— the metadata-admin read door (`object-fields-io.ts`), `MetadataService`'s +carry-over and `MetadataFieldsPage`'s carry-over — become derivations from a +single registry in `@object-ui/types` (`RETIRED_FIELD_KEY_TOMBSTONES` + +`retiredFieldKeysFor(site)`), naming each retired key, the card that retired +it, and its PER-SITE applicability. + +Per-site behaviour is unchanged — this is a consolidation, and each site's +effective strip set is pinned equal to its pre-consolidation literal. The two +deliberate asymmetries a naive union would have destroyed are now recorded as +data and pinned: + +- `formula` stays stripped by the two write-side carry-overs and is NOT + stripped by the read door — ruled on objectui#6526 (option B): the + `ObjectFieldInspector` migration path (objectui#6043) stands, and the + registry test makes that ruling mechanical. +- `sortOrder` stays a single-site strip at `MetadataService`'s carry-over, + now explicitly recorded as the registry's one DEFENSIVE entry (objectui#6045 + measured that no shipped writer ever populated a field-level one). diff --git a/packages/app-shell/src/services/MetadataService.ts b/packages/app-shell/src/services/MetadataService.ts index 0a52311700..e41a23fa26 100644 --- a/packages/app-shell/src/services/MetadataService.ts +++ b/packages/app-shell/src/services/MetadataService.ts @@ -19,6 +19,11 @@ import { stripReadDecorations } from '@objectstack/spec/kernel'; import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack'; import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types'; +// The retired-field-key tombstone registry lives at a dedicated internal +// subpath, not the main barrel — objectui#6527 option B (maintainer ruling, +// 2026-08-28): a barrel import eagerly evaluates every other barrel member, +// which widened an unrelated consumer's module graph under the prior shape. +import { retiredFieldKeysFor } from '@object-ui/types/internal/retired-field-keys'; // --------------------------------------------------------------------------- // Types @@ -256,38 +261,35 @@ function toFieldsMap(fields: FieldMetadataPayload[]): Record unrecognized_keys — "never a FieldSchema key" (objectui#4644) - * referenceTo => unrecognized_keys — "Did you mean `referenceTo` -> `reference`?" (objectui#6041) - * formula => unrecognized_keys — "Did you mean `formula` -> `expression`?" (objectui#6043) - * isSystem => unrecognized_keys — "Did you mean `isSystem` -> `system`?" (objectui#6044) - * sortOrder => unrecognized_keys (objectui#6045) + * Carrying such a key out again would be a hard `422 INVALID_METADATA` that + * blocks EVERY later save of that object — and with the controls gone, an + * author has no way to clear it from the UI. Stripping is what makes an + * edit-and-save round-trip of such an object come out parseable; nothing that + * the server would store is lost, because these are exactly the values it + * refuses. * - * Every one of them is a key SOME designer build emitted before its card - * retired it, so a document stored back then can still carry it inside a field. - * Carrying it out again would be a hard `422 INVALID_METADATA` that blocks - * EVERY later save of that object — and with the controls gone, an author has - * no way to clear it from the UI. Stripping is what makes an edit-and-save - * round-trip of such an object come out parseable; nothing that the server - * would store is lost, because these are exactly the values it refuses. - * - * The list is keyed to those tombstones and is NOT a blanket unknown-key purge: - * every other key the server sent still rides through, which is the whole point - * of the carry-over. Deliberately not derived from `FieldSchema`'s accept set - * either — measured on 17.2.0, a plugin-registered key (`x_plugin_thing`) is - * `unrecognized_keys` to the INSTALLED spec while the SERVER that sent it - * accepts it, so filtering by the client's schema would drop precisely the keys - * this card exists to preserve. - * - * ⚠ This is the repo's THIRD copy of a retired-field-key list, each scoped to - * one writer's own history (`plugin-designer`'s `MetadataFieldsPage` carries - * four, `app-shell`'s `previews/object-fields-io` carries `['indexed']`). - * Unifying them spans `MetadataFieldsPage.tsx`, which objectui#6489 owns on - * this same seam, so it is filed rather than folded in here. + * The strip is keyed to those tombstones and is NOT a blanket unknown-key + * purge: every other key the server sent still rides through, which is the + * whole point of the carry-over. Deliberately not derived from `FieldSchema`'s + * accept set either — measured on 17.2.0, a plugin-registered key + * (`x_plugin_thing`) is `unrecognized_keys` to the INSTALLED spec while the + * SERVER that sent it accepts it, so filtering by the client's schema would + * drop precisely the keys this card exists to preserve. */ -const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'formula', 'isSystem', 'sortOrder'] as const; +const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataServiceCarryOver'); /** * The previous SERVER entry for one field, minus {@link RETIRED_FIELD_KEYS} — diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts index 08167dd871..a685914511 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.retiredKeys.test.ts @@ -53,7 +53,9 @@ const SAMPLE: Record<(typeof RETIRED_FIELD_KEYS)[number], unknown> = { describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui#6519)', () => { it('names exactly the three keys this door strips', () => { - // The list is the tombstone, and its two ABSENCES are deliberate: + // The list is derived from the tombstone registry (objectui#6527: + // `RETIRED_FIELD_KEY_TOMBSTONES` in `@object-ui/types`, site + // `metadataAdminFieldsReadDoor`), and its two ABSENCES are deliberate: // `formula` — premise holds, strip refused: ObjectFieldInspector // migrates the legacy key through its linting CEL editor, // and stripping empties that editor (measured: the pin @@ -61,8 +63,10 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui# // `sortOrder` — premise fails: no writer on this tree ever populated a // field-level one, so no draft can carry it and a strip // would be dead code that reads like a measurement. - // Both have a case of their own below. See the tombstone before adding a - // fourth entry. + // Both have a case of their own below, and both are also pinned at the + // registry itself (`retired-field-key-tombstones.test.ts` — `formula`'s + // absence is the objectui#6526 option B ruling made mechanical). See the + // registry before adding a fourth entry. expect([...RETIRED_FIELD_KEYS]).toEqual(['indexed', 'referenceTo', 'isSystem']); }); @@ -137,8 +141,9 @@ describe('object-fields-io · retired FieldSchema keys (objectui#4644, objectui# // preserved when it refused to rename the key blindly. Stripping at this // door empties that editor and the authored source is gone on the next // save — measured: with `formula` in the list, that pin renders `""` and - // fails. Until a maintainer rules that the text may be dropped - // (objectui#6519), it rides through. + // fails. RULED, objectui#6526 option B (2026-08-27): the migration path + // stands and the key is NOT stripped here; the 422 diagnostic points the + // author at the formula editor instead (PR #6624). const out = roundTrip({ total: { type: 'formula', formula: 'price * quantity' }, }) as Record>; diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts index d37902d392..6fd3399e24 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts @@ -17,6 +17,14 @@ * {@link RETIRED_FIELD_KEYS} — see the note on that constant. */ +// The retired-field-key tombstone registry lives at a dedicated internal +// subpath, not the main barrel — objectui#6527 option B (maintainer ruling, +// 2026-08-28): a barrel import eagerly evaluates every other barrel member +// (including `spec-report.ts`'s read of `@objectstack/spec/ui`), which is +// what widened an unrelated test's partial mock into a suite failure under +// the prior shape. A subpath import never pulls the barrel in. +import { retiredFieldKeysFor } from '@object-ui/types/internal/retired-field-keys'; + import type { FieldTypeId } from './field-types.js'; export type Shape = 'array' | 'record'; @@ -24,98 +32,60 @@ export type Shape = 'array' | 'record'; /** * Field keys the ObjectStack spec REJECTS by name, stripped on read. * - * Measured against the installed `@objectstack/spec` 17.2.0, each one on an - * otherwise-green field (`{ type: 'text', label: 'L' }`), and through the whole - * object document `PUT /api/v1/meta/object/:name` validates: - * - * indexed => unrecognized_keys "never a FieldSchema key; a field-level - * index flag built no index (#2377). Declare the index in the - * object's `indexes[]`." (objectui#4644) - * referenceTo => unrecognized_keys "Did you mean `referenceTo` -> - * `reference`?" (objectui#6041) - * isSystem => unrecognized_keys "Did you mean `isSystem` -> `system`?" - * (objectui#6044) - * - * `ObjectSchema.safeParse` reports them at `["fields", ]`, which is the - * hard 422 (`INVALID_METADATA`) that blocks EVERY subsequent save of the object - * until the author finds and clears the key — and the controls that wrote them - * are retired, so there is no UI path left to clear it. - * - * Every key here is one a SHIPPED build wrote, so a stored object can still - * carry it inside a field. That premise was verified per key rather than - * assumed (objectui#6519): + * Derived from the tombstone registry (`RETIRED_FIELD_KEY_TOMBSTONES` in + * `@object-ui/types`, objectui#6527) — this door is the registry's + * `metadataAdminFieldsReadDoor` site. The registry names each retired key, the + * card that retired it, and which sites strip it; the per-key evidence lives + * there. What stays HERE is this door's own contract: * - * - `indexed` — the field inspector's `Indexed` checkbox wrote it until - * objectui#4644, measured blocking saves on console 17.0.0 GA. - * - `referenceTo` — emitted by BOTH designer writers until objectui#6041: - * `MetadataService.toFieldPayload` (`referenceTo: field.referenceTo`) and - * `MetadataFieldsPage.fromDesignerField` (`referenceTo: designed.referenceTo`). - * - `isSystem` — declared on `MetadataFieldsPage`'s `ServerFieldSchema` and - * read back (`isSystem: raw.isSystem`) until objectui#6044, i.e. served - * field entries were expected to carry it; it left again through a verbatim - * carry-over spread rather than any named emit site. + * Every key this door strips is one a SHIPPED build wrote (verified per key, + * objectui#6519), so a stored draft can still carry it inside a field — + * `ObjectSchema.safeParse` reports it at `["fields", ]`, the hard 422 + * (`INVALID_METADATA`) that blocks EVERY subsequent save of the object, with + * the writing controls retired and no UI path left to clear the key. Stripping + * on load — not a data migration — is what makes an edit-and-save round-trip + * of such a draft come out parseable. `readFields` is the single read door for + * `draft.fields` across the whole object designer (inspector, form designer, + * design surface, settings / validations / API panels), and `writeFields` + * writes each def back verbatim, so one strip here covers every writer. + * Nothing is lost on the way out: where the spec has a spelling for the + * concept it is a SEPARATE key (`reference`, `system`) that is NOT stripped + * and rides through untouched, which is what lets the designer read it back. * - * Neither key loses anything on the way out: where the spec has a spelling for - * the concept it is a SEPARATE key that is NOT stripped — `reference` and - * `system` are real `FieldSchema` keys and ride through untouched, which is - * what lets the designer read them back. + * ── The two registry keys this door deliberately does NOT strip ── * - * ── Two keys the spec also refuses and this door deliberately does NOT strip ── - * Read both before adding a fourth entry; each was measured, and they fail the - * strip for DIFFERENT reasons. - * - * `formula` (objectui#6043) — the premise holds and the strip still does not. - * The Field Designer's textarea wrote it, so stored objects carry it; but - * `ObjectFieldInspector` is this platform's sanctioned migration surface for - * exactly that key. The legacy value seeds its CEL editor - * (`readPredicate(def.expression ?? def.formula)`) and the first edit commits + * `formula` — RULED, objectui#6526 option B (2026-08-27): this door reads + * drafts a live editor also MIGRATES, which neither write-side carry-over + * does. `ObjectFieldInspector` seeds its linting CEL editor from + * `readPredicate(def.expression ?? def.formula)` and the first edit commits * the spec key and clears the alias (`patchDef({ expression: …, formula: - * undefined })`). Stripping here empties that editor: measured on - * objectui#6519, adding `formula` to this list turns - * `ObjectFieldInspector.test.tsx`'s pin *commits edits to `expression` and - * migrates the legacy `formula` key* RED (`Tests 1 failed | 42 passed`) with the - * editor rendering `""` instead of the authored source — after which the next - * save drops the text for good. objectui#6043 refused the blind rename in - * `plugin-designer` PRECISELY because this linting editor exists to migrate the - * value properly; a strip here would discard what that ruling preserved. So a - * `formula` draft stays blocked at the server until the author edits the - * formula, which is a worse-than-nothing trade only a maintainer should make — - * it is raised on objectui#6519, not taken here. - * - * `sortOrder` (objectui#6045) — the premise itself fails. `FieldSchema` refuses - * it by name too, and `MetadataService`'s `carryOver` does list it, but no - * writer on this tree ever populated a FIELD-level `sortOrder`: it was declared - * on the UI model and on the wire shape and left undefined, so `toFieldPayload` - * emitted `sortOrder: undefined` and `JSON.stringify` dropped it. objectui#6045 - * removed it as objectui#4687's shape (a declaration with zero readers and zero - * writers), not objectui#6041's rename. No shipped build stored one, so no - * draft this door reads can carry one, and stripping it would be dead code that - * reads like a measurement. (The object-level `sortOrder` on `ObjectDefinition` - * and the saved-view `sortOrder` in `ObjectView` are different concepts living - * outside `fields` entirely, so neither passes through this door.) Evidence of a - * stored field-level `sortOrder` would change this — add it then, not defensively. + * undefined })`) — the migration objectui#6043 preserved when it refused the + * blind rename. Stripping here empties that editor and the authored source is + * gone on the next save (measured on objectui#6519: with `formula` in this + * list, the inspector's pin *commits edits to `expression` and migrates the + * legacy `formula` key* goes RED with the editor rendering `""`). A `formula` + * draft instead stays blocked at the server until the author makes that one + * migrating edit, and the client-side 422 diagnostic names the field and + * points at the Formula (CEL) editor (PR #6624). * - * Stripping on load — not a data migration — is what makes an edit-and-save - * round-trip of such a draft come out parseable. `readFields` is the single - * read door for `draft.fields` across the whole object designer (inspector, - * form designer, design surface, settings / validations / API panels), and - * `writeFields` writes each def back verbatim, so one strip here covers every - * writer. + * `sortOrder` — the premise fails: no writer on this tree ever populated a + * field-level one (objectui#6045 — objectui#4687's zero-readers/zero-writers + * shape, not a rename), so no draft this door reads can carry it, and a strip + * would be dead code that reads like a measurement. This door's contract is + * "add it on evidence, not defensively"; `MetadataService`'s carry-over keeps + * it as that site's one recorded-DEFENSIVE entry instead. (The object-level + * `sortOrder` on `ObjectDefinition` and the saved-view `sortOrder` in + * `ObjectView` are different concepts living outside `fields` entirely, so + * neither passes through this door.) * * Same shape as `PermissionAdvancedFacets`' `RETIRED_RLS_KEYS` * (objectstack#7130). Keyed to the tombstones, never a blanket unknown-key * purge: every other key the designer does not render still survives. - * - * ⚠ This is one of THREE retired-key lists on this seam, each scoped to its own - * writer's history (`MetadataFieldsPage`'s `carryOver` carries four, - * `MetadataService`'s carries five). They are NOT nested, and the two paragraphs - * above are why: this door reads drafts a live editor also migrates, which the - * two write-side lists do not. Unifying them spans - * `plugin-designer/src/MetadataFieldsPage.tsx`, which objectui#6489 owns in - * flight, so objectui#6519 scoped itself to this file and left unification to a - * follow-up rather than collide on that file. + * `object-fields-io.retiredKeys.test.ts` pins the derived list and both + * absences from this door's side; the registry's own test pins the same facts + * at the source. */ -export const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'isSystem'] as const; +export const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataAdminFieldsReadDoor'); /** Drop {@link RETIRED_FIELD_KEYS} from one field definition. */ function stripRetiredFieldKeys(def: Record): Record { diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 37b909af12..f6aac3b9a0 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -33,6 +33,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DESIGNER_FIELD_TYPES } from '@object-ui/types'; import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; +// The retired-field-key tombstone registry lives at a dedicated internal +// subpath, not the main barrel — objectui#6527 option B (maintainer ruling, +// 2026-08-28): a barrel import eagerly evaluates every other barrel member, +// which widened an unrelated consumer's module graph under the prior shape. +import { retiredFieldKeysFor } from '@object-ui/types/internal/retired-field-keys'; import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack'; import { FieldDesigner } from './FieldDesigner'; @@ -125,62 +130,46 @@ function toDesignerField(name: string, raw: ServerFieldSchema): DesignerFieldDef } /** - * Field keys the ObjectStack spec REJECTS by name (objectui#4644). + * Field keys the ObjectStack spec REJECTS by name, dropped out of + * {@link carryOver}. * - * `indexed` was never a `FieldSchema` key — the field-level flag built no - * index (objectstack#2377 removed it) and, since objectstack#4001 closed the - * silent-drop shape, `FieldSchema.safeParse` refuses it outright. Object-level - * `indexes[]` is the real surface. + * Derived from the tombstone registry (`RETIRED_FIELD_KEY_TOMBSTONES` in + * `@object-ui/types`, objectui#6527) — this carry-over is the registry's + * `metadataFieldsPageCarryOver` site. The registry names each retired key, the + * card that retired it, and which sites strip it; the per-key evidence lives + * there. What stays HERE is what is specific to THIS writer's history: * - * The Advanced section of {@link FieldDesigner} used to offer it, so objects - * saved from this page can still carry the key — and `fromDesignerField` - * spreads `prev` verbatim to preserve unknown keys, which would carry it back - * out to `PUT /api/v1/meta/object/:name` as a hard 422 (`INVALID_METADATA`) - * that blocks every later save. Stripping it out of the carried-over keys is - * what makes an edit-and-save round-trip of such an object come out - * parseable; it is keyed to the tombstone, so every other unknown key the - * designer does not render still survives. - */ -/* - * objectui#6041 adds `referenceTo`. Renaming the emit site alone does not - * unblock an object a previous designer build already saved: that stored - * payload still carries `referenceTo`, `carryOver` spreads `prev` verbatim, - * and the key would round-trip straight back out to the same 422. Stripping it - * on the way out is what makes an edit-and-save of an ALREADY-BLOCKED object - * come out parseable. The target itself is not lost — `fromDesignerField` - * re-emits it under the spec spelling `reference` on the very next line. - * - * objectui#6044 adds `isSystem` for the same reason and with one difference - * worth stating: `fromDesignerField` never NAMES it, so the only way out is the - * verbatim `carryOver` spread — which makes this line, not any emit site, the - * whole write half of that card. The spec spelling `system` is not stripped: it - * is a real `FieldSchema` key, so a server-injected flag rides through - * untouched, which is exactly what lets `toDesignerField` read it back. - * - * objectui#6043 adds `formula`, and it is the one entry here that is NOT half - * of a rename — the difference matters, because it is the only reason this - * strip loses anything: + * Each key is one this page's own era wrote (`indexed` via the Advanced + * section of {@link FieldDesigner}, objectui#4644; `referenceTo` via + * `fromDesignerField`'s old emit line, objectui#6041; `isSystem` as a declared + * `ServerFieldSchema` member served back to us, objectui#6044; `formula` via + * the retired formula textarea, objectui#6043), and `fromDesignerField` + * spreads `prev` verbatim to preserve unknown keys — so a stored object from + * that era would carry the key straight back out to + * `PUT /api/v1/meta/object/:name` as a hard 422 (`INVALID_METADATA`) that + * blocks every later save, with no control left on screen to clear it. + * Stripping the carried-over keys is what makes an edit-and-save round-trip of + * such an object come out parseable; it is keyed to the tombstones, so every + * other unknown key the designer does not render still survives. * - * - For `referenceTo` and `isSystem`, `fromDesignerField` re-emits the value - * under the spec spelling on a later line, so stripping costs nothing. - * - For `formula` there is no re-emit, because the card REFUSED the rename. - * `FieldSchema` does not parse CEL at the key level (17.2.0 accepts - * `expression: '!!!not cel at all!!!'`), so migrating a stored `formula` - * into `expression` would launder a non-CEL string — typically the - * `price * quantity` the retired control's own placeholder taught — into a - * valid key name, where it parses green and then evaluates to null at - * runtime. That is the silent failure the card exists to avoid, so the key - * is dropped rather than renamed. + * Two of the four cost nothing: `fromDesignerField` re-emits the lookup target + * under the spec spelling `reference` on the very next line, and the system + * flag is read back from the spec spelling `system` (never re-emitted — the + * strip IS the whole write half of objectui#6044). `formula` is the one entry + * whose strip DROPS a value, and that is objectui#6043's deliberate trade: the + * server refuses to store it, a blind rename to `expression` would launder + * non-CEL text into a formula that parses green and evaluates to null, and + * with the textarea gone stripping is the only way out of the 422. The + * migration surface for the VALUE is metadata-admin's `ObjectFieldInspector` + * (ruled again on objectui#6526, option B), which is why the READ door's list + * does not include `formula` while this write door's does. `expression` itself + * is a real `FieldSchema` key and rides through `carryOver` untouched. * - * Dropping it is what makes an already-blocked object saveable again, and there - * is no gentler option: with the control gone, an author has no other way to - * clear the key, so leaving it would keep the object 422-blocked forever. The - * value being dropped is one the server already refuses to store, so nothing - * that ever persisted is lost. `expression` is NOT stripped — it is a real - * `FieldSchema` key, so a formula authored in metadata-admin rides through - * `carryOver` untouched. + * `sortOrder` is absent here on the same unmeasured premise it always had: no + * shipped writer on this tree ever populated a field-level one (objectui#6045) + * — see its tombstone for the one site that keeps a recorded-defensive strip. */ -const RETIRED_FIELD_KEYS = ['indexed', 'referenceTo', 'isSystem', 'formula'] as const; +const RETIRED_FIELD_KEYS = retiredFieldKeysFor('metadataFieldsPageCarryOver'); /** Carry over `prev`'s unknown keys, minus {@link RETIRED_FIELD_KEYS}. */ function carryOver(prev?: ServerFieldSchema): ServerFieldSchema { diff --git a/packages/types/package.json b/packages/types/package.json index e65f4efde7..b4d320b13c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -51,6 +51,10 @@ "./zod": { "types": "./dist/zod/index.zod.d.ts", "import": "./dist/zod/index.zod.js" + }, + "./internal/retired-field-keys": { + "types": "./dist/internal/retired-field-keys.d.ts", + "import": "./dist/internal/retired-field-keys.js" } }, "files": [ diff --git a/packages/types/src/__tests__/retired-field-key-tombstones.test.ts b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts new file mode 100644 index 0000000000..87f229c779 --- /dev/null +++ b/packages/types/src/__tests__/retired-field-key-tombstones.test.ts @@ -0,0 +1,310 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6527 — the retired-field-key tombstone registry's own pins. + * + * The registry replaced three independently maintained per-site literals + * (`object-fields-io.ts`, `MetadataService.ts`, `MetadataFieldsPage.tsx`) that + * had drifted. The sites derive their strip lists from it via + * `retiredFieldKeysFor`, so what needs pinning HERE is the registry's own + * contract — and above all the two decisions a naive union of the old lists + * would have silently made in the wrong direction: + * + * 1. `formula` must NOT be stripped at the read door — RULED, objectui#6526 + * option B (director seat, 2026-08-27): "keep the adjudicated migration + * path (objectui#6043) — do NOT strip `formula` at the read door." + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula` and the first edit migrates the value; + * stripping on read empties that editor and destroys authored source. + * 2. `sortOrder` has a recorded verdict, not a unioned entry: no shipped + * writer on this tree ever populated a field-level one (objectui#6045 — + * "the key never reached the wire"), so it stays a single-site DEFENSIVE + * strip and the registry says so in data. + * + * The per-site round-trip behaviour stays pinned where it always was, from the + * consuming side: `object-fields-io.retiredKeys.test.ts` (the read door, + * including `formula` and `sortOrder` carried through), + * `MetadataService.fieldKeyCarryOver.test.ts` and the + * `MetadataFieldsPage.retired*.test.tsx` / `specKey*.test.tsx` files (the + * write doors, on captured PUT bytes). Those suites passing unchanged against + * the derived lists is the consolidation's parity measurement. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; + +import { + RETIRED_FIELD_KEY_SITES, + RETIRED_FIELD_KEY_TOMBSTONES, + retiredFieldKeysFor, + type RetiredFieldKey, +} from '../internal/retired-field-keys.js'; + +// `package.json` is read the same way `package-exports-manifest.test.ts` +// reads it (createRequire + readFileSync), NOT by importing the bare +// specifier `@object-ui/types/internal/retired-field-keys` from inside this +// package's own `src/` — objectui#4801 / scripts/check-package-self-import.mjs: +// that specifier resolves through the package's OWN `exports` map to `dist/`, +// and neither `type-check` nor `test` has a build-order dependency on this +// package's own build (turbo's `^build` reaches only DEPENDENCIES), so on a +// cold CI cache the self-import fails with TS2307 -- green only on a machine +// that happened to have built `dist/` already. It also would not have proven +// what it looked like it proved: this repo's vitest config aliases the bare +// package name straight to `src/` (vitest.config.mts), so even a passing +// self-import here would be resolving through that alias, never through the +// real `exports` map a genuine external consumer uses. +const require = createRequire(import.meta.url); +const packageJsonPath = require.resolve('../../package.json'); +const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + exports?: Record | string>; +}; + +/** An otherwise-green field — the positive control every probe rides on. */ +const BASE_FIELD = { name: 'amount', type: 'number', label: 'Amount' } as const; + +/** + * A representative value per registry key, as the retiring card measured the + * key on the wire. `Record` makes adding a tombstone + * without a probe value a compile error. + */ +const SAMPLE: Record = { + indexed: true, + referenceTo: 'account', + formula: 'price * quantity', + isSystem: true, + sortOrder: 3, +}; + +/** + * A representative value per recorded `specEquivalent`, so the "the concept + * lives HERE today" claim is probed against the installed schema rather than + * trusted. + */ +const SPEC_EQUIVALENT_SAMPLE: Record = { + reference: 'account', + system: true, + expression: 'price * quantity', +}; + +describe('retired-field-key registry · membership criterion (the instrument)', () => { + it('accepts the base field — the control that makes the refusals a result', () => { + expect(FieldSchema.safeParse(BASE_FIELD).success).toBe(true); + }); + + it('every registry key is refused BY NAME by the installed FieldSchema', () => { + // The membership criterion: stripping is safe exactly because the server + // refuses to store these values. A key the schema ACCEPTS in this loop is + // a registry entry that would make the strips delete authored metadata. + for (const { key } of RETIRED_FIELD_KEY_TOMBSTONES) { + const r = FieldSchema.safeParse({ ...BASE_FIELD, [key]: SAMPLE[key] }); + expect({ key, success: r.success }).toEqual({ key, success: false }); + expect(r.success ? [] : r.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + }); + + it('every recorded specEquivalent is a key the installed FieldSchema ACCEPTS', () => { + // `specEquivalent` documents where the CONCEPT lives today. Recording a + // spelling the schema refuses would send the next resolver to a dead key. + for (const { key, specEquivalent } of RETIRED_FIELD_KEY_TOMBSTONES) { + if (specEquivalent === null) continue; + expect(specEquivalent in SPEC_EQUIVALENT_SAMPLE).toBe(true); + const r = FieldSchema.safeParse({ + ...BASE_FIELD, + [specEquivalent]: SPEC_EQUIVALENT_SAMPLE[specEquivalent], + }); + expect({ key, specEquivalent, success: r.success }).toEqual({ key, specEquivalent, success: true }); + } + }); +}); + +describe('retired-field-key registry · the ruled asymmetry (objectui#6526, option B)', () => { + it('⭐ `formula` is NOT applicable at the read door — the ruling made mechanical', () => { + // Ruled 2026-08-27 on objectui#6526 (option B): the read door reads drafts + // `ObjectFieldInspector`'s linting CEL editor also MIGRATES — it seeds + // from `def.expression ?? def.formula`, the first edit commits + // `expression` and clears the alias (objectui#6043), and the client-side + // 422 diagnostic points the author at that editor (PR #6624). Flipping + // this flag strips the draft before the editor sees it: the editor seeds + // `""` and the authored expression text is destroyed on the next save. + // This pin going red means someone is overturning a maintainer ruling — + // that needs a new ruling, not a "consistency" edit. + const formula = RETIRED_FIELD_KEY_TOMBSTONES.find((t) => t.key === 'formula'); + expect(formula?.sites).toEqual({ + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }); + expect(retiredFieldKeysFor('metadataAdminFieldsReadDoor')).not.toContain('formula'); + }); + + it('`sortOrder` carries its recorded verdict: defensive, and at ONE site only', () => { + // objectui#6045 measured that no shipped writer ever populated a + // field-level `sortOrder`, so the entry is insurance, not a measured fix — + // kept at `MetadataService`'s carry-over with the measurement recorded + // (objectui#6527) rather than silently unioned into the other sites. + // Red here means either the entry spread without evidence, or the verdict + // was erased without recording a new one. + const sortOrder = RETIRED_FIELD_KEY_TOMBSTONES.find((t) => t.key === 'sortOrder'); + expect(sortOrder?.defensive).toBe(true); + expect(sortOrder?.sites).toEqual({ + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: false, + }); + }); + + it('`sortOrder` is the ONLY defensive entry — every other strip is measured', () => { + // Adding a defensive entry is a recorded decision, not a default. If this + // list grows, the new entry's tombstone must carry its own verdict the way + // `sortOrder`'s does. + expect(RETIRED_FIELD_KEY_TOMBSTONES.filter((t) => t.defensive).map((t) => t.key)).toEqual([ + 'sortOrder', + ]); + }); +}); + +describe('retired-field-key registry · per-site parity with the pre-consolidation literals', () => { + // objectui#6527 is a consolidation, so behaviour parity per site is the + // claim. These are the three literals the sites carried before deriving from + // the registry (re-taken on the merged ref at dispatch time). The read door + // is also pinned from the consuming side in + // `object-fields-io.retiredKeys.test.ts`; the write doors are pinned on + // captured PUT bytes in their own suites. + + it('read door — exactly the objectui#6519 list, in the same order', () => { + expect([...retiredFieldKeysFor('metadataAdminFieldsReadDoor')]).toEqual([ + 'indexed', + 'referenceTo', + 'isSystem', + ]); + }); + + it("MetadataService carry-over — exactly objectui#6488's five, in the same order", () => { + expect([...retiredFieldKeysFor('metadataServiceCarryOver')]).toEqual([ + 'indexed', + 'referenceTo', + 'formula', + 'isSystem', + 'sortOrder', + ]); + }); + + it("MetadataFieldsPage carry-over — exactly its four; registry (retirement) order", () => { + // The pre-consolidation literal read ['indexed', 'referenceTo', + // 'isSystem', 'formula']; the derived list is the same SET in registry + // order (formula's card objectui#6043 predates isSystem's objectui#6044). + // The site's strip is a delete loop, so the set is the behaviour. + expect([...retiredFieldKeysFor('metadataFieldsPageCarryOver')]).toEqual([ + 'indexed', + 'referenceTo', + 'formula', + 'isSystem', + ]); + }); +}); + +describe('retired-field-key registry · hygiene', () => { + it('keys are unique', () => { + const keys = RETIRED_FIELD_KEY_TOMBSTONES.map((t) => t.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('every tombstone names its retiring card as `objectui#`', () => { + // The template-literal type enforces this at compile time for TS readers; + // this runtime pin holds the same line for JS readers (the parity gate is + // an .mjs script and the follow-up pins it on this registry). + for (const { key, retiredBy } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, ok: /^objectui#\d+$/.test(retiredBy) }).toEqual({ key, ok: true }); + } + }); + + it('every tombstone answers every site — no column is left implicit', () => { + for (const { key, sites } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, sites: Object.keys(sites).sort() }).toEqual({ + key, + sites: [...RETIRED_FIELD_KEY_SITES].sort(), + }); + } + }); + + it('every tombstone applies at at least one site — no dead entries', () => { + for (const { key, sites } of RETIRED_FIELD_KEY_TOMBSTONES) { + expect({ key, applied: Object.values(sites).some(Boolean) }).toEqual({ + key, + applied: true, + }); + } + }); + + it('package.json declares the internal subpath, pointed at this module\'s own dist output', () => { + // The three strip sites import `@object-ui/types/internal/retired-field-keys` + // directly; a registry whose wiring fell off that subpath would break them + // at build time, but this pin makes the wiring a stated fact rather than + // an accident. + // + // This reads the DECLARATION rather than round-tripping an import of the + // bare specifier from inside the package's own `src/` (objectui#4801 / + // scripts/check-package-self-import.mjs — see the import block above for + // why that form is both a cold-CI-cache TS2307 hazard AND, separately, + // not actually a wiring test: vitest's own alias config resolves the bare + // package name straight to `src/`, so a self-import here would never + // exercise the real `exports` map a genuine external consumer resolves + // through). + const entry = pkg.exports?.['./internal/retired-field-keys']; + expect(entry).toEqual({ + types: './dist/internal/retired-field-keys.d.ts', + import: './dist/internal/retired-field-keys.js', + }); + + // The declared `dist/` target is NOT checked for existence: `test` has no + // build-order dependency on this package's OWN build (the same gap + // check-package-self-import.mjs polices for `type-check`), so a fresh CI + // cache has no `dist/` yet and asserting it existed would reintroduce the + // exact hazard this pin replaces — see `package-exports-manifest.test.ts`'s + // header for the same reasoning applied to the root export. + // + // What IS available unconditionally is the SOURCE the declared target is + // generated from — and it is DERIVED FROM THE DECLARATION rather than + // restated as a second constant. That derivation is what keeps this half a + // WIRING assertion: two independent literals that happen to agree would + // still pass if the `exports` entry were re-pointed at a module that does + // not exist. `tsc` mirrors the package's `src` tree to `dist` one-to-one + // (rootDir: ./src, outDir: ./dist — packages/types/tsconfig.json), so + // inverting that mapping over the declared target proves the subpath + // resolves to a real module, with no build required first. + const declaredImport = (entry as Record).import; + const srcRelative = declaredImport.replace(/^\.\/dist\//, 'src/').replace(/\.js$/, '.ts'); + const srcPath = resolve(dirname(packageJsonPath), srcRelative); + expect({ srcRelative, exists: existsSync(srcPath) }).toEqual({ + srcRelative: 'src/internal/retired-field-keys.ts', + exists: true, + }); + }); + + it('the registry is NOT exported from the main package barrel', async () => { + // objectui#6527 option B (maintainer ruling, 2026-08-28): the registry was + // deliberately un-exported from `@object-ui/types`'s main barrel. Importing + // that barrel eagerly evaluates every other module it re-exports — + // including `spec-report.ts`'s read of `@objectstack/spec/ui` — which is + // what widened an unrelated consumer's partial spec mock into a failed + // suite under the prior (option A) shape. This pin guards the regression: + // re-adding the re-export to `index.ts` turns it red. + // Type-only exports (`RetiredFieldKeySite` etc.) erase at runtime and + // cannot be probed this way; the compile-time half of this guard is the + // three consuming sites' own subpath imports failing to typecheck if the + // subpath's types ever moved, which is not this pin's job to duplicate. + const barrel: Record = await import('../index.js'); + expect('RETIRED_FIELD_KEY_TOMBSTONES' in barrel).toBe(false); + expect('retiredFieldKeysFor' in barrel).toBe(false); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ab09c60291..37ed4b6fbd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -687,6 +687,16 @@ export { DESIGNER_FIELD_TYPES, } from './designer.js'; +// Retired-field-key tombstone registry (objectui#6527) is DELIBERATELY not +// re-exported from this main barrel — ruled objectui#6527 option B +// (maintainer, 2026-08-28). It lives at the dedicated internal subpath +// `@object-ui/types/internal/retired-field-keys` instead: importing this +// barrel eagerly evaluates every other module it re-exports (including +// `spec-report.ts`'s read of `@objectstack/spec/ui`), and a registry export +// added here widened that evaluation graph enough to break an unrelated +// consumer's partial spec mock. A subpath import never pulls this barrel in. +// See `packages/types/src/internal/retired-field-keys.ts` for the registry. + // ============================================================================ // API and Events - API Integration and Event Handling // ============================================================================ diff --git a/packages/types/src/internal/retired-field-keys.ts b/packages/types/src/internal/retired-field-keys.ts new file mode 100644 index 0000000000..f9951abc39 --- /dev/null +++ b/packages/types/src/internal/retired-field-keys.ts @@ -0,0 +1,284 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - Retired-field-key tombstone registry (objectui#6527) + * + * The single place naming each FIELD key a designer once wrote that the + * installed `FieldSchema` refuses BY NAME, the card that retired it, and its + * PER-SITE applicability on the designer seam. The three strip sites derive + * their lists from this registry via {@link retiredFieldKeysFor}; before + * objectui#6527 each site kept its own literal and the three had drifted. + * + * ## Why this is a registry and NOT one shared array + * + * The three strip sets are DELIBERATELY not nested, and unioning them would + * silently decide two adjudicated questions in the wrong direction: + * + * - `formula` is stripped by the two WRITE doors and must NOT be stripped by + * the READ door — ruled on objectui#6526 (option B, 2026-08-27): + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula` and the author's first edit commits + * `expression` and clears the alias, the migration objectui#6043 preserved. + * Stripping at the read door destroys that authored source text. + * - `sortOrder` applies at ONE site only and is the registry's one + * `defensive` entry — see its tombstone. + * + * So applicability is per site, recorded here as data, and the per-site pins in + * `packages/types/src/__tests__/retired-field-key-tombstones.test.ts` are what + * keep both of those decisions mechanical rather than conventional. + * + * ## Membership criterion + * + * Every key in this registry is refused by the installed `FieldSchema` with + * `unrecognized_keys` — measured, and pinned by the registry's own test. That + * is what makes stripping safe: the server refuses to store these values, so a + * strip can never lose anything that persisted. A key the schema ACCEPTS must + * never be added here; stripping an accepted key deletes authored metadata. + * + * The strips these tombstones feed are keyed to the registry and are NOT a + * blanket unknown-key purge (AGENTS.md #0.1): a plugin-registered key the + * INSTALLED spec does not know is `unrecognized_keys` to the client while the + * SERVER that sent it accepts it, so filtering by the schema's accept set would + * drop precisely the keys the carry-overs exist to preserve. + * + * `scripts/check-designer-field-key-parity.mjs` (the declared-shape parity + * gate) does not read this registry yet — pinning it as that gate's single + * source is the cross-lane follow-up named on objectui#6527. + */ + +/** + * The strip sites on the designer seam, in the order their columns read in the + * tombstones below. + * + * - `metadataAdminFieldsReadDoor` — READ: + * `app-shell/src/views/metadata-admin/previews/object-fields-io.ts` + * (`readFields`, the single read door for `draft.fields` across the whole + * object designer). Strips on load, so an edit-and-save round-trip of a + * poisoned draft comes out parseable. This door reads drafts a live editor + * also MIGRATES, which neither write door does — that is why its column is + * not the union of the other two. + * - `metadataServiceCarryOver` — WRITE: + * `app-shell/src/services/MetadataService.ts` (`carryOver`, objectui#6488). + * Bounds the per-field carry-over of `saveFields` / `saveObject` so a stored + * document's retired keys do not ride back out to + * `PUT /api/v1/meta/object/:name` as a hard 422. + * - `metadataFieldsPageCarryOver` — WRITE: + * `plugin-designer/src/MetadataFieldsPage.tsx` (`carryOver`). Same bound for + * the standalone Field Designer's own save path. + */ +export const RETIRED_FIELD_KEY_SITES = [ + 'metadataAdminFieldsReadDoor', + 'metadataServiceCarryOver', + 'metadataFieldsPageCarryOver', +] as const; + +/** One strip site on the designer seam. See {@link RETIRED_FIELD_KEY_SITES}. */ +export type RetiredFieldKeySite = (typeof RETIRED_FIELD_KEY_SITES)[number]; + +/** One retired field key's tombstone. See {@link RETIRED_FIELD_KEY_TOMBSTONES}. */ +export interface RetiredFieldKeyTombstone { + /** The refused spelling, exactly as `FieldSchema` names it in `unrecognized_keys`. */ + readonly key: string; + /** The card that retired the key — the home of the full evidence. */ + readonly retiredBy: `objectui#${number}`; + /** + * The accepted `FieldSchema` key that carries this CONCEPT today, or `null` + * when the spec has no field-level equivalent. Documentation for the reader, + * NEVER an instruction to migrate a value mechanically: objectui#6043 refused + * exactly that rename for `formula`, because `FieldSchema` validates the key + * and not the language, so a blind rename launders non-CEL text into a + * formula that parses green and evaluates to null. + */ + readonly specEquivalent: string | null; + /** + * `true` when NO measurement shows any stored document can carry the key — + * the strip is insurance rather than a measured fix, and the entry says so + * instead of reading like a measurement. Kept-with-a-note is a recorded + * verdict (objectui#6527); a defensive entry must never spread to more sites + * without evidence. + */ + readonly defensive: boolean; + /** Which sites strip this key. Every `false` is deliberate and documented. */ + readonly sites: Readonly>; +} + +/** + * The tombstones, in the order the cards retired the keys. + * + * Adding an entry: the key must be measured `unrecognized_keys` against the + * installed `FieldSchema` (the registry test enforces this), and each site + * column is its own decision — a site that cannot see the key (read door: + * "no draft can carry it"; a carry-over: "this writer never emitted it") gets + * `false` and a reason here, not a defensive `true`. + */ +export const RETIRED_FIELD_KEY_TOMBSTONES = [ + { + /* + * Never a FieldSchema key at all: the field-level flag built no index + * (objectstack#2377 removed it) and the object's `indexes[]` is the real + * surface — hence no `specEquivalent`, the concept moved levels rather + * than spellings. Shipped in console 17.0.0 GA as the field inspector's + * `Indexed` checkbox, so stored objects from that era carry it. + */ + key: 'indexed', + retiredBy: 'objectui#4644', + specEquivalent: null, + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * A rename: the spec spells the lookup target `reference` + * ("Did you mean `referenceTo` -> `reference`?"). The strip loses nothing — + * every write path re-emits the designer's target under `reference`, and + * the read door's writers never emit the retired spelling. + */ + key: 'referenceTo', + retiredBy: 'objectui#6041', + specEquivalent: 'reference', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * The one entry whose strip DROPS a value, and the one whose read-door + * column is a ruling rather than a symmetry: + * + * - WRITE doors strip it (`true` twice): the server refuses to store the + * value, and with the authoring control retired an author has no other + * way to clear it, so leaving it would keep the object 422-blocked + * forever. objectui#6043 REFUSED the rename to `expression` — see + * `specEquivalent` on the interface for why a blind rename is worse + * than the drop. + * - The READ door must NOT strip it (`false`) — RULED, objectui#6526 + * option B (2026-08-27, inherited by objectui#6527): + * `ObjectFieldInspector` seeds its linting CEL editor from + * `def.expression ?? def.formula`; the first edit commits `expression` + * and clears the alias (objectui#6043's migration), and the client-side + * 422 diagnostic names the field and points at that editor (PR #6624). + * Stripping here empties the editor and destroys the authored + * expression text — measured: the inspector's migration pin renders + * `""` and fails. Flipping this `false` is overturning a maintainer + * ruling, not tidying an inconsistency. + */ + key: 'formula', + retiredBy: 'objectui#6043', + specEquivalent: 'expression', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * A rename with an asymmetric write half: the spec spells it `system`, and + * `fromDesignerField` / `toFieldPayload` never NAME `isSystem`, so the + * carry-over strip is the entire write half of the retirement (the spec + * spelling rides through untouched, which is what lets the designers read + * the flag back). + */ + key: 'isSystem', + retiredBy: 'objectui#6044', + specEquivalent: 'system', + defensive: false, + sites: { + metadataAdminFieldsReadDoor: true, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: true, + }, + }, + { + /* + * The registry's one DEFENSIVE entry, and its one single-site column — + * both facts are pinned, and both were decided on the record rather than + * unioned in (objectui#6527, re-measured 2026-08-27 on this tree): + * + * objectui#6045 retired the key as objectui#4687's zero-readers / + * zero-writers shape, NOT as a rename: `DesignerFieldDefinition` declared + * it, `toFieldPayload` copied it, nothing ever populated it, and + * `JSON.stringify` dropped the `undefined` — "the key never reached the + * wire". No shipped writer on this tree ever emitted a field-level + * `sortOrder`, so no stored document written by these designers can carry + * one. The spec has no field-level ordering key at all: field order is + * declaration order in the object's `fields` record, and the near-spelling + * `sortable` is a boolean ("whether field is sortable in list views") — a + * different concept, not an equivalent. + * + * - `metadataServiceCarryOver: true` — objectui#6488 added the strip + * when it built the carry-over, as insurance against a document some + * OTHER client stored while an older server accepted the key. Kept, + * with the measurement recorded here instead of silently: dropping it + * would turn such a document (if one exists — unmeasured either way) + * into a 422 that blocks every save with no UI way out, for the price + * of one honest entry. + * - The other two sites never had it and gain nothing: the read door + * strips only what a draft can measurably carry (its own contract: + * "add it then, not defensively"), and `MetadataFieldsPage`'s + * carry-over has the same unmeasured premise it always had. Widening + * either would be the naive union this registry exists to prevent. + * + * Evidence of a stored field-level `sortOrder` flips this entry to a + * measured three-site strip; evidence that none can exist retires it + * entirely. Either way, update the tombstone and its pins together. + * + * Distinct keys, same spelling: the object-level `sortOrder` retired by + * objectui#6223 (an `ObjectSchema` matter, still declared on + * `ObjectDefinition` as the Object Manager's display order) and the + * saved-view `sortOrder` in `app-shell`'s `ObjectView` (per-view display + * order). Neither is this field-level key. + */ + key: 'sortOrder', + retiredBy: 'objectui#6045', + specEquivalent: null, + defensive: true, + sites: { + metadataAdminFieldsReadDoor: false, + metadataServiceCarryOver: true, + metadataFieldsPageCarryOver: false, + }, + }, +] as const satisfies readonly RetiredFieldKeyTombstone[]; + +/** The union of every retired field key named in the registry. */ +export type RetiredFieldKey = (typeof RETIRED_FIELD_KEY_TOMBSTONES)[number]['key']; + +/** + * The retired field keys that apply at `S` — the literal union, derived from + * the tombstone data so a site's list and its type cannot drift apart. + */ +export type RetiredFieldKeysAt = Extract< + (typeof RETIRED_FIELD_KEY_TOMBSTONES)[number], + { sites: { [K in S]: true } } +>['key']; + +/** + * The strip list for one site, in registry (retirement) order. + * + * This is the ONLY supported way for a strip site to obtain its list — a site + * that inlines a literal is a fourth copy, which is exactly the drift + * objectui#6527 closed. The cast is sound because the return type is derived + * from the same tombstone data the filter reads. + */ +export function retiredFieldKeysFor( + site: S, +): readonly RetiredFieldKeysAt[] { + return RETIRED_FIELD_KEY_TOMBSTONES.filter((t) => t.sites[site]).map( + (t) => t.key, + ) as RetiredFieldKeysAt[]; +}