From f78152341a41d558085cbfe564ad7519141c0b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 02:01:34 +0000 Subject: [PATCH 1/2] wip: retire global_nav from ACTION_LOCATIONS (#6888) --- .../action-global-nav-location-retired.md | 82 +++++++++++ content/docs/protocol/objectui/actions.mdx | 1 - content/docs/ui/actions.mdx | 1 - .../areas/records-forms.json | 25 ++-- examples/app-showcase/src/ui/actions/index.ts | 34 ++++- .../lint/src/validate-action-locations.ts | 2 +- packages/spec/liveness/action.json | 4 +- packages/spec/src/conversions/registry.ts | 131 ++++++++++++++++++ packages/spec/src/migrations/registry.ts | 27 +++- .../studio/action-location-retirement.test.ts | 12 +- packages/spec/src/studio/plugin.zod.ts | 5 +- packages/spec/src/ui/action.test.ts | 68 ++++++++- packages/spec/src/ui/action.zod.ts | 47 ++++++- skills/objectstack-ui/SKILL.md | 1 - 14 files changed, 405 insertions(+), 35 deletions(-) create mode 100644 .changeset/action-global-nav-location-retired.md diff --git a/.changeset/action-global-nav-location-retired.md b/.changeset/action-global-nav-location-retired.md new file mode 100644 index 0000000000..a845d64e41 --- /dev/null +++ b/.changeset/action-global-nav-location-retired.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: retire `global_nav` from `ACTION_LOCATIONS` — a location the product never rendered, and the designer previewed anyway (#6888, ADR-0049) + +`ACTION_LOCATIONS` is the canonical vocabulary for where an action surfaces in a +running app, and `global_nav` — "global navigation/command-palette level +actions" — has been in it since the vocabulary was written. **No running-app +surface ever rendered it.** The console's ⌘K palette +(`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, +objects, dashboards, pages, reports, recent items, record search and theme; its +`actions` group is hard-coded chrome; and the file references neither +`global_nav` nor any action-metadata source at all. Of the five references to +the value in the whole UI repo at the vendored SHA, four were the Studio +designer and the fifth a doc comment. + +What lifts this above ordinary inert-declaration cleanup is the direction of the +lie. `metadata-admin/previews/ActionPreview.tsx` drew the author a mock +`⌘K · Command palette` frame, so the **authoring tool promised a surface the +product does not have**. An author declares the location, watches it "render" in +the designer, ships it, and it reaches no user — the ADR-0078 +declares/renders/does-nothing shape, arriving through the location vocabulary +rather than through a missing key. For an AI author reading the corpus (ADR-0033) +that preview is evidence the capability exists, which is exactly how dead +metadata multiplies. + +Retired rather than implemented (maintainer ruling, 2026-08-09): no user has +asked for command-palette actions, and the only two declarers were our own +showcase corpus. Wiring the palette would have been capability expansion with no +pull. If real appetite appears it re-enters through the front door, +implementation first. + +FROM → TO: + +| Was | Now | +|:--|:--| +| `locations: ['global_nav', 'record_header']` | `locations: ['record_header']` — drop the value, keep the served locations | +| `locations: ['global_nav']` on an action with a UI home | place it where a renderer serves it: `list_toolbar`, `list_item`, `record_header`, `record_more`, `record_related`, `record_section` | +| `locations: ['global_nav']` on an action with no UI home (e.g. object-less, invoked over REST/MCP/AI) | `locations: []` — the documented **headless** declaration, which keeps the capability gate, param contract and audit trail | + +The retirement kit: + +- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone: + the enum's own error map carries the prescription, keyed on the received value + so only the spelling that used to be legal is told it "was removed" (the + `crypto.hash` / `HookBodyCapability` precedent, #4391, and `array_agg` / + `AggregationFunction`, #6188). A mis-spelling still gets zod's list of the + legal locations. For the same reason nothing lands in `RETIRED_KEYS_BY_MAJOR` + and the four surface ratchets are byte-identical — no def and no authorable + key changed. +- **ADR-0087 D2 conversion + D3 chain step** + (`action-global-nav-location-removed`): `os migrate meta --from 16` strips the + value from `action.locations`, one notice per rewritten action. +- **The key is kept when the array empties** — `locations: []`, never + `delete locations`. On this surface the two are different declarations, not + two spellings of one: the empty array is the documented headless shape + ("Headless actions: declare it, then hide it"), while an absent key means + nobody placed the action — which is what `packages/lint`'s + `action-no-placement` warns about, in those words ("an author who said + 'nowhere, deliberately' (`[]`) and one who never said anything at all"). + Dropping the key would convert a deliberate placement into a lint finding and + discard the author's own statement of intent. This is the + `hook-body-crypto-hash-removed` shape ("the `capabilities` key itself stays — + an empty grant set is legal"), not the + `dataset-measure-array-string-agg-removed` shape, which drops its item only + because the stripped remainder would fail the dataset's own refinement. +- The QA platform checklist's `records-forms.action-location-matrix` loses its + `global_nav` variant. That variant was **unrunnable**, not merely obsolete: + its step "new_task from the palette" could never pass. `enumSource.expect` + moves 7 → 6, which is the ratchet that would otherwise have caught this drift. +- The two showcase declarers become headless. `showcase_portfolio_snapshot` is + object-less by design and its docblock already said `global_nav` was chosen + "for the same reason" it has no `objectName` — so `[]` is the declaration it + always meant. + +**Behaviour that changes:** none at runtime. An action declaring `global_nav` +rendered nowhere before this change and renders nowhere after it; what changes is +that the declaration is now refused at parse, with the reason, instead of being +accepted and silently ignored. + + diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index c0abeaf4f0..2c42c1709f 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -221,7 +221,6 @@ The `locations` array declares where an action surfaces. The canonical enum (`AC | `record_more` | Overflow ("More" / ⋯) menu on a record | | `record_related` | Actions on a related-list section inside a record | | `record_section` | Actions inside a body section/tab of a record | -| `global_nav` | Global navigation / command-palette actions | ```yaml name: export_customers diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 9060705ef2..cdc601d5f2 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -189,7 +189,6 @@ is collected, then the body runs with those values as its `input`. | `record_more` | Record page overflow ("…") menu | | `record_related` | Related-list sections | | `record_section` | Named action bars on record pages | -| `global_nav` | App-level navigation | Surfaces can also reference actions **by name**: diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 4b42df13a1..b1a2a365d6 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -1359,10 +1359,10 @@ }, { "id": "records-forms.action-location-matrix", - "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section, global nav — and each dispatches for real", + "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section — and each dispatches for real", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -1371,7 +1371,7 @@ "fixtures": { "app": "showcase", "requires": [ - "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, script, visible '!record.done'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — deliberately kept OFF the toolbar, objectui#3142), showcase_new_task (global_nav, modal)", + "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, script, visible '!record.done'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — deliberately kept OFF the toolbar, objectui#3142)", "seeded tasks in both done and not-done states for the CEL visibility both-sides check" ] }, @@ -1382,14 +1382,13 @@ "record_more — showcase_open_docs / showcase_recalc_selection under the ⋯ overflow", "record_related — showcase_log_time on the related-list section", "record_section — showcase_mark_done / showcase_log_time in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)", - "global_nav — showcase_new_task in the command palette / global nav", "empty-locations semantics probe — a locations-less action lands on EVERY location including the toolbar (objectui action-bar.tsx documented behavior; the reason recalc_selection must declare record_more)" ], "steps": [ "boot the showcase isolated; sign in as admin; open the showcase_task list view", - "for each location variant, navigate to its surface (list header / row menu / a not-done task's detail title bar / its ⋯ menu / a related-list section / the Task Detail quick-actions bar / the global command palette), screenshot AFTER render settles, then read the rendered action buttons from the DOM", + "for each location variant, navigate to its surface (list header / row menu / a not-done task's detail title bar / its ⋯ menu / a related-list section / the Task Detail quick-actions bar), screenshot AFTER render settles, then read the rendered action buttons from the DOM", "record for every fixture action WHERE it rendered — building the full placement matrix (rendered locations vs declared locations)", - "dispatch one action per location with a ref-targeted click: bulk_reassign (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.edit), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST), new_task from the palette; capture each network trace", + "dispatch one action per location with a ref-targeted click: bulk_reassign (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.edit), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST); capture each network trace", "verify the state-changing dispatches server-side: mark_done flips the task's done flag (API re-read), recalc_selection's per-record branch recomputes the estimate", "CEL visibility both sides: locate a done task and a not-done task; read the row menu and record header of each for showcase_mark_done", "empty-locations probe: in a scratch/writable package author a copy of an api action with NO locations key; reload and record every surface it appears on (including the toolbar), then delete the probe" @@ -1402,7 +1401,7 @@ "evidence": "per-location screenshots + the placement matrix" }, { - "clause": "placement is EXCLUSIVE, not additive: the matrix has zero extra placements — record_more-only actions (open_docs, recalc_selection) never render in record_header or list_toolbar; global_nav-only new_task never renders on rows; the engine location-filters even explicitly-named actions (the record:quick_actions bar note in the fixture source)", + "clause": "placement is EXCLUSIVE, not additive: the matrix has zero extra placements — record_more-only actions (open_docs, recalc_selection) never render in record_header or list_toolbar; the engine location-filters even explicitly-named actions (the record:quick_actions bar note in the fixture source)", "oracle": "dom", "verify": "the placement matrix's extra-placement cells are all empty, checked against every captured surface", "evidence": "the same matrix, extra-placement columns" @@ -1411,7 +1410,7 @@ "clause": "each location's sampled action DISPATCHES for real — flow wizard opens and resumes, modal opens, script executes, form opens the declared edit form view, url navigates, api POSTs — and state-changing ones round-trip server-side (mark_done flips done; recalc updates the estimate)", "oracle": "network", "verify": "one captured dispatch per location + API re-reads for the two state changes", - "evidence": "the seven traces + the two re-reads" + "evidence": "the six traces + the two re-reads" }, { "clause": "row-level CEL visibility gates per record, both sides: showcase_mark_done ('visible: !record.done') renders on the not-done task's row/header and is ABSENT on the done task's — and the evaluation is fail-closed (a throwing expression hides, never shows)", @@ -1441,7 +1440,7 @@ "ref": "objectui: e2e/live/list-row-action-cel.spec.ts (row-level CEL visibility) + e2e/live/action-modal.spec.ts (dialog dispatch)" }, "source": [ - "packages/spec/src/ui/action.zod.ts:397 (ACTION_LOCATIONS — the canonical 7-value enum, single source of truth)", + "packages/spec/src/ui/action.zod.ts:565 (ACTION_LOCATIONS — the canonical 6-value enum, single source of truth; `global_nav` was the 7th until #6888 retired it — no product surface rendered it)", "examples/app-showcase/src/ui/actions/index.ts (per-location fixture fleet + the record:quick_actions filter note + the objectui#3142 empty-locations commentary)", "objectui: packages/.../action-bar.tsx (missing/empty locations → every location)", "cross-ref: bulk dispatch-count semantics live in records-forms.list-view-capabilities (bulk-actions variant); param dialogs in records-forms.action-param-widgets" @@ -1458,12 +1457,18 @@ "date": "2026-08-08", "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 3, + "date": "2026-08-10", + "change": "dropped the global_nav variant — the vocabulary lost that member (#6888). The variant was UNRUNNABLE as written, not merely obsolete: its step 'new_task from the palette' could never pass, because the console ⌘K palette builds its groups from nav items, objects, dashboards, pages, reports, recent items and record search and reads no action metadata at all. The fixture fleet loses showcase_new_task with it (now headless, locations: []); the dispatch step drops from seven traces to six; enumSource.expect 7 → 6", + "ref": "claude/issue-6888-retire-global-nav" } ], "enumSource": { "file": "packages/spec/src/ui/action.zod.ts", "export": "ACTION_LOCATIONS", - "expect": 7 + "expect": 6 } }, { diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index ab39ac9228..7108567b3c 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -16,7 +16,10 @@ const fieldZoo = 'showcase_field_zoo'; /** * Action matrix — covers every `ActionType` (script / url / flow / modal / * api / form) surfaced across a spread of `ActionLocation`s (toolbar, row, - * record header/more, related list, global nav). + * record header/more, related list, record section) — plus two HEADLESS + * actions (`locations: []`), which is a declaration in its own right, not an + * omission: callable over REST/MCP/AI with the capability gate and audit trail + * intact, claiming no UI surface. */ /** @@ -173,7 +176,20 @@ export const LogTimeAction = defineAction({ refreshAfter: true, }); -/** global nav command-palette action. */ +/** + * modal — headless (`locations: []`). + * + * This action used to declare `locations: ['global_nav']`, which #6888 retired: + * no product surface ever rendered that location, so the declaration placed the + * action nowhere. Its live entry point is elsewhere and always was — the + * Overview page's "Create Task" CTA (`../pages/index.ts`) carries an INLINE + * action of the same name, which is the path a user actually clicks. So the + * registered action keeps its declaration and states the truth about its UI + * surface (`[]` — "nowhere, deliberately") rather than naming a location + * nothing serves. No replacement placement was invented for it here: choosing + * one is a product decision about the showcase, not a consequence of the + * retirement (flagged on #6888). + */ export const NewTaskAction = defineAction({ name: 'showcase_new_task', label: 'New Task', @@ -181,7 +197,7 @@ export const NewTaskAction = defineAction({ objectName: task, type: 'modal', target: 'showcase_component_gallery', - locations: ['global_nav'], + locations: [], refreshAfter: true, }); @@ -340,9 +356,13 @@ export const ArchiveTaskAction = defineAction({ * live specimen to exercise. This is that specimen. * * The body is genuinely object-less: it counts across SEVERAL objects, so there - * is no single record or object the action could sensibly hang off. `location` - * is `global_nav` for the same reason — an object-less action has no row and no - * record header to render on. + * is no single record or object the action could sensibly hang off. It is + * HEADLESS for the same reason — an object-less action has no row and no record + * header to render on — which `locations: []` declares outright: callable over + * REST/MCP/AI, with its capability gate, param contract and audit trail intact, + * and claiming no UI surface. It used to say `locations: ['global_nav']`, which + * meant the same thing in intent but named a location no renderer served; + * #6888 retired that value and this is the declaration it always meant. */ export const PortfolioSnapshotAction = defineAction({ name: 'showcase_portfolio_snapshot', @@ -360,7 +380,7 @@ export const PortfolioSnapshotAction = defineAction({ capabilities: ['api.read'], }, successMessage: 'Portfolio snapshot taken.', - locations: ['global_nav'], + locations: [], refreshAfter: false, ai: { exposed: true, diff --git a/packages/lint/src/validate-action-locations.ts b/packages/lint/src/validate-action-locations.ts index 2e6ed2974a..280fa136f0 100644 --- a/packages/lint/src/validate-action-locations.ts +++ b/packages/lint/src/validate-action-locations.ts @@ -160,7 +160,7 @@ export function validateActionLocations(stack: AnyRec): ActionLocationsFinding[] 'so it renders on no surface — the button exists in metadata and nowhere in the UI.', hint: 'Add the surface it belongs on, e.g. `locations: [\'record_header\']` (or `list_item`, ' + - '`list_toolbar`, `record_more`, `record_section`, `record_related`, `global_nav`); or ' + + '`list_toolbar`, `record_more`, `record_section`, `record_related`); or ' + "place it from a list view's `bulkActions` / `bulkActionDefs` if it acts on a selection. " + 'If it is meant to be callable over REST / MCP / AI with no UI surface, say so explicitly ' + 'with `locations: []` — an empty array is the documented headless shape and is never flagged.', diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index c3e83ff3e7..2b93b2f191 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -21,7 +21,9 @@ }, "locations": { "status": "live", - "note": "objectui — where the action is surfaced." + "verifiedAt": "2026-08-10", + "evidence": "objectui `actionRendersAt` (packages/types/src/ui-action.ts) is the ONE placement predicate since objectui#3142 — `Array.isArray(declared) && declared.includes(location)` — consumed by action-bar.tsx and the record surfaces; `locations: []` therefore renders nowhere, which is the documented headless shape.", + "note": "The KEY is live; its VALUE SET was audited per-member in #6888 and `global_nav` was retired (no product surface read it — the console ⌘K palette composes its groups from nav items/objects/dashboards/pages/reports/recents/record search and reads no action metadata; the only renderer of the value was the Studio designer's mock preview frame). An enum-VALUE retirement leaves this row untouched in status: the key is still walked, still authorable and still enforced — see the conversion `action-global-nav-location-removed`." }, "component": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index bd966485fd..68be2f669b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6184,6 +6184,136 @@ const appHiddenToUnpublished: MetadataConversion = { }, }; +/** + * `global_nav` leaves `ACTION_LOCATIONS` (protocol 17, #6888 — ADR-0049 + * enforce-or-remove, maintainer ruling 2026-08-09). + * + * The location was declared from the day the vocabulary was written and no + * product surface ever served it. The console's ⌘K palette + * (`app-shell/src/chrome/CommandPalette.tsx`) composes its seven groups from + * nav items, objects, dashboards, pages, reports, recent items, record search + * and theme — its `actions` group is hard-coded chrome — and the file + * references neither `global_nav` nor any action-metadata source at all. Of the + * five references to the value in the whole UI repo at the vendored SHA, four + * were the Studio designer and the fifth a doc comment. + * + * What makes it worse than an ordinary inert declaration is the direction of + * the lie: `metadata-admin/previews/ActionPreview.tsx` drew the author a mock + * `⌘K · Command palette` frame, so the authoring tool PROMISED a rendering the + * product cannot do. An author declares the location, watches it "work" in the + * designer, ships it, and it renders nowhere — the ADR-0078 + * declares/renders/does-nothing shape, arriving through the location + * vocabulary rather than through a missing key. Retired rather than + * implemented: no user has asked for command-palette actions and the only two + * declarers were our own showcase corpus, so wiring the palette would have been + * capability expansion with no pull. If real appetite appears it re-enters + * through the front door, implementation first. + * + * Like `hook-body-crypto-hash-removed` and + * `dataset-measure-array-string-agg-removed` above, this is an enum-VALUE + * retirement: there is no `retiredKey()` tombstone to hang the prescription on, + * so the enum's own error map carries it (`GLOBAL_NAV_RETIRED`, + * `ui/action.zod.ts`), keyed on `issue.input` so only the spelling that used to + * be legal is told it "was removed". For the same reason nothing lands in + * `RETIRED_KEYS_BY_MAJOR` and the four surface ratchets are expected to be + * byte-identical — no def and no authorable KEY changed. + * + * ## The empty-array edge, decided deliberately + * + * The value is stripped from `locations` and **the key is kept even when the + * array empties** — `locations: []`, never `delete locations`. On this surface + * the two are different declarations, not two spellings of one: + * `content/docs/ui/actions.mdx` ("Headless actions: declare it, then hide it") + * documents `[]` as a first-class shape — the action stays callable over + * REST/MCP/AI and keeps its capability gate, param contract and audit trail — + * while an ABSENT key means the author never placed the action at all, which is + * exactly what `packages/lint`'s `action-no-placement` rule warns about. The + * rule states the distinction in those words: "an author who said 'nowhere, + * deliberately' (`[]`) and one who never said anything at all (key absent)". + * Dropping the key would therefore convert a deliberate placement into a lint + * finding and lose the author's own statement of intent. Stripping to `[]` is + * also the truthful reading of the retirement: the action's UI home is gone, + * and headless is what it now is. + * + * This differs from `dataset-measure-array-string-agg-removed`, which DROPS the + * measure, for a reason that is about validity rather than taste: a measure + * with neither `aggregate` nor `derived` fails the dataset's own `superRefine`, + * so stripping alone would emit an item that cannot parse. An action with + * `locations: []` parses, and is documented to. It follows + * `hook-body-crypto-hash-removed`'s "the `capabilities` key itself stays (an + * empty grant set is legal)" instead. + * + * `retiredFromLoadPath`: the enum rejects the value outright, so a live author + * is taught at parse rather than silently rewritten. The entry exists so stored + * 16.x/17-rc rows replay clean (`applyConversionsToStoredItem` — without it a + * pre-removal row flags `metadata_spec_invalid` forever, mislabelling + * chain-owned history as a current-contract violation) and so + * `os migrate meta --from 16` rewrites author sources. + */ +const actionGlobalNavLocationRemoved: MetadataConversion = { + id: 'action-global-nav-location-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'action.locations[]', + summary: + "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K " + + 'palette reads no action metadata, while the Studio designer previewed a command-palette ' + + 'frame for it. The value is stripped and the key kept, so an action left with no location ' + + 'becomes the documented headless shape `locations: []`)', + apply(stack, emit) { + return mapCollection(stack, 'actions', (action, path) => { + const locations = action.locations; + if (!Array.isArray(locations) || !locations.includes('global_nav')) return action; + emit({ from: 'global_nav', to: '(removed)', path: `${path}.locations` }); + return { ...action, locations: locations.filter((l) => l !== 'global_nav') }; + }); + }, + fixture: { + before: { + actions: [ + // The empty-array edge: `global_nav` was this action's ONLY location, + // so the strip empties the array. The key survives — see the docblock. + { + name: 'portfolio_snapshot', + label: 'Portfolio Snapshot', + type: 'script', + locations: ['global_nav'], + }, + // Surgical: the surviving locations stay, in order, beside the strip. + { + name: 'new_task', + label: 'New Task', + type: 'modal', + target: 'gallery', + locations: ['record_header', 'global_nav', 'list_toolbar'], + }, + // An action without the retired value passes through untouched. + { + name: 'mark_done', + label: 'Mark Done', + type: 'script', + locations: ['list_item'], + }, + ], + }, + after: { + actions: [ + { name: 'portfolio_snapshot', label: 'Portfolio Snapshot', type: 'script', locations: [] }, + { + name: 'new_task', + label: 'New Task', + type: 'modal', + target: 'gallery', + locations: ['record_header', 'list_toolbar'], + }, + { name: 'mark_done', label: 'Mark Done', type: 'script', locations: ['list_item'] }, + ], + }, + // One per rewritten action — `mark_done` is untouched. + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -6252,6 +6382,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { // The two vocabularies were disjoint precisely here — the app-UI values // must NOT leak into the Studio contribution enum: expect(() => studio.ActionContributionLocationSchema.parse('list_toolbar')).toThrow(); - expect(() => studio.ActionContributionLocationSchema.parse('global_nav')).toThrow(); + expect(() => studio.ActionContributionLocationSchema.parse('record_section')).toThrow(); - // ui side — untouched, and still the 7-value app-UI vocabulary. + // ui side — untouched by THIS rename, and still the app-UI vocabulary + // (`global_nav` left it later and separately, #6888). expect('ActionLocationSchema' in ui).toBe(true); expect('ACTION_LOCATIONS' in ui).toBe(true); - for (const loc of ['list_toolbar', 'record_header', 'global_nav']) { + for (const loc of ['list_toolbar', 'record_header', 'record_section']) { expect(() => ui.ActionLocationSchema.parse(loc)).not.toThrow(); } expect(() => ui.ActionLocationSchema.parse('toolbar')).toThrow(); diff --git a/packages/spec/src/studio/plugin.zod.ts b/packages/spec/src/studio/plugin.zod.ts index 61a3f084df..22dceaff4f 100644 --- a/packages/spec/src/studio/plugin.zod.ts +++ b/packages/spec/src/studio/plugin.zod.ts @@ -144,9 +144,10 @@ export type SidebarGroupContributionParsed = z.infer { 'record_header', 'record_more', 'record_related', - 'global_nav', + 'record_section', ] as const; const action: ActionType = { @@ -1432,7 +1432,6 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { 'record_more', 'record_related', 'record_section', - 'global_nav', ]); }); @@ -1448,6 +1447,71 @@ describe('ACTION_LOCATIONS — canonical source of truth', () => { expect(() => ActionLocationSchema.parse('')).toThrow(); }); + // ─── [#6888] `global_nav` retirement — the enum-VALUE tombstone ──────────── + // + // The value is gone from the vocabulary and there is no `retiredKey()` to + // hang the prescription on (a value is not a key), so the enum's own error + // map carries it, keyed on `issue.input`. These pin the two halves that make + // the retirement audible instead of silent: the value is REFUSED, and the + // refusal SAYS WHY and what to do instead. A bare `.toThrow()` would pass on + // zod's generic "invalid option" message and would not notice the error map + // being dropped, which is the whole mechanism here. + describe('[#6888] `global_nav` is retired', () => { + it('is no longer a member of the vocabulary', () => { + expect([...ACTION_LOCATIONS]).not.toContain('global_nav'); + // Anti-vacuity: the constant we just probed is the real one and still + // carries the surviving members, so `not.toContain` cannot pass by + // resolving an empty list. + expect(ACTION_LOCATIONS).toHaveLength(6); + expect([...ACTION_LOCATIONS]).toContain('record_section'); + }); + + it('refuses the value WITH the prescription — bare enum and whole action alike', () => { + // The prescription: names the value, says it was removed, points at the + // live alternatives (a served location, or the headless `locations: []`), + // and closes with the house `os migrate meta` sentence (#6856 route D). + const prescribes = (fn: () => unknown) => { + expect(fn).toThrow(/`global_nav` was removed from `ACTION_LOCATIONS`/s); + expect(fn).toThrow(/#6888/s); + expect(fn).toThrow(/locations: \[\]/s); + expect(fn).toThrow(/os migrate meta --from 16/s); + }; + + prescribes(() => ActionLocationSchema.parse('global_nav')); + // The path an author actually travels — the value inside a real action. + prescribes(() => + ActionSchema.parse({ + name: 'palette_action', + label: 'Palette Action', + type: 'script', + target: 'true', + locations: ['global_nav'], + }), + ); + }); + + it('tells ONLY the retired spelling that it "was removed"', () => { + // The error map is keyed on `issue.input` precisely so a typo is not + // misinformed that its value used to exist. `globalnav` never did. + expect(() => ActionLocationSchema.parse('globalnav')).toThrow(); + expect(() => ActionLocationSchema.parse('globalnav')).not.toThrow(/was removed/s); + expect(() => ActionLocationSchema.parse('global_navigation')).not.toThrow(/was removed/s); + }); + + it('accepts the migrated shape: an action with no UI surface says so with `[]`', () => { + // What the D2 conversion rewrites `locations: ['global_nav']` INTO — the + // documented headless declaration, still a legal action. + const action = ActionSchema.parse({ + name: 'portfolio_snapshot', + label: 'Portfolio Snapshot', + type: 'script', + target: 'true', + locations: [], + }); + expect(action.locations).toEqual([]); + }); + }); + it('ActionSchema accepts a `locations: ActionLocation[]` field', () => { const all: ActionLocation[] = [...ACTION_LOCATIONS]; const action = ActionSchema.parse({ diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 14836efc71..cc915d2e2c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -508,6 +508,26 @@ const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( * Note: The action name is the configuration ID. JavaScript function names can use camelCase, * but the metadata ID must be lowercase snake_case. */ +// Retired-VALUE prescription. Declared with `//` (never `/** */`) and ABOVE the +// enum's JSDoc deliberately: `build-docs.ts` takes a doc comment adjacent to the +// declaration as the reference entry's blurb, so a `/** */` here would displace +// the location table below. (House style set by the `crypto.hash` / +// `HookBodyCapability` and `array_agg` / `AggregationFunction` enum-value +// retirements — `data/hook-body.zod.ts`, `data/query.zod.ts`.) +const GLOBAL_NAV_RETIRED = + '`global_nav` was removed from `ACTION_LOCATIONS` in @objectstack/spec 17 (#6888, ADR-0049 ' + + 'enforce-or-remove) — no running-app surface ever rendered it. The console command palette ' + + '(`⌘K`) builds its groups from nav items, objects, dashboards, pages, reports, recent items ' + + 'and record search; it reads no action metadata at all, so an action declaring this location ' + + 'never reached a user. The only thing that DID draw it was the Studio designer, which ' + + 'previewed a command-palette frame for a surface the product does not have — an authoring ' + + 'tool teaching authors to write dead metadata (ADR-0078). Place the action on a location a ' + + 'renderer serves (`list_toolbar`, `list_item`, `record_header`, `record_more`, ' + + '`record_related`, `record_section`), or — for an action that deliberately has no UI home, ' + + 'such as an object-less one invoked over REST/MCP/AI — declare it headless with ' + + '`locations: []`, which keeps its capability gate, param contract and audit trail. ' + + 'Run `os migrate meta --from 16` to rewrite existing sources automatically.'; + /** * Action Location — where an action is allowed to surface in the UI. * @@ -524,7 +544,23 @@ const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( * - `record_related` — actions on a related list section inside a record. * - `record_section` — actions surfaced inside a body section/tab of a record * (e.g. a Security tab grouping change-password, 2FA, etc.). - * - `global_nav` — global navigation/command-palette level actions. + * + * `global_nav` was REMOVED in 17 (#6888, maintainer ruling 2026-08-09). It had + * been declared here since the vocabulary was written and no product surface + * ever served it: the console's ⌘K palette composes its groups from nav items, + * objects, dashboards, pages, reports, recent items and record search, and + * references no action metadata. Four of the five references to the value in + * the whole UI repo were the Studio designer — which drew the author a mock + * "⌘K · Command palette" frame, promising a rendering the product cannot do + * (the ADR-0078 declares-renders-does-nothing shape, arriving through the + * location vocabulary rather than through a missing key). Retired rather than + * implemented: no user has asked for command-palette actions and the only two + * declarers were our own showcase corpus, so wiring the palette would have been + * capability expansion with no pull. This is an enum VALUE, not a key, so there + * is no `retiredKey()` tombstone — the prescription lives on the enum's own + * error map above, keyed on `issue.input` so that only the spelling which used + * to be legal is told it "was removed". An object-less action's honest + * declaration is `locations: []` (headless), not a location nothing renders. */ export const ACTION_LOCATIONS = [ 'list_toolbar', @@ -533,10 +569,15 @@ export const ACTION_LOCATIONS = [ 'record_more', 'record_related', 'record_section', - 'global_nav', ] as const; -export const ActionLocationSchema = z.enum(ACTION_LOCATIONS); +export const ActionLocationSchema = z.enum(ACTION_LOCATIONS, { + // Only the spelling that USED to be legal gets the retirement message. + // Telling the author of `globalnav` that their value "was removed" would + // misinform, so everything else keeps zod's own enum error, which already + // lists the legal locations. (`array_agg` / `crypto.hash` precedent.) + error: (issue) => (issue.input === 'global_nav' ? GLOBAL_NAV_RETIRED : undefined), +}); export type ActionLocation = z.input; /** diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index d79503ed35..e1a412a41a 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -1819,7 +1819,6 @@ Register them under `defineStack({ actions: [...] })`. | `record_section` | Body section/tab of a record (e.g. a Security tab) | | `list_item` | Per-row action in list views | | `list_toolbar` | Bulk action on selected rows (`input.selectedIds`) | -| `global_nav` | Global navigation / command-palette level | ### Visibility, Disable & Feedback From 3ffb4f8bfa7810ffa16ee53e8a887629c656d3da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 02:29:50 +0000 Subject: [PATCH 2/2] chore(spec): regenerate spec-changes, upgrade guide and reference docs (#6888) --- content/docs/references/ui/action.mdx | 3 +-- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index f5efe38a08..29417d06f5 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -64,7 +64,7 @@ const result = ActionSchema.parse(data); | **label** | `string \| Record` | ✅ | Display label | | **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). | | **icon** | `string` | optional | Icon name | -| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section' \| 'global_nav'>[]` | optional | Locations where this action is visible | +| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible | | **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override | | **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type | | **target** | `string` | optional | URL, Script Name, Flow ID, or API Endpoint. Supports $`{param.X}` and $`{ctx.X}` interpolation. | @@ -149,7 +149,6 @@ const result = ActionSchema.parse(data); * `record_more` * `record_related` * `record_section` -* `global_nav` --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 0ff6061d3d..b538c24441 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -236,6 +236,8 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 The plugin manifest loses its whole `loading` block in this step (#4914, ADR-0049, maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block rather than a key, and answered REMOVE on measurement: every reference to `manifest.loading.*` in objectstack, cloud and objectui lived inside `packages/spec` itself, so a full loading policy parsed, entered the manifest, and configured nothing. The reason it outranked ordinary inert-key cleanup is that one of its members was `sandboxing`, declaring process / vm / iframe / web-worker isolation and a service ACL: an inert SECURITY control is worse than an absent one, because an author (very often an AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot reload was a two-source defect on top of that — the retired `PluginHotReloadSchema` was the dead one of two vocabularies, and the ruling converges on the live one, `HotReloadConfigSchema`, which `HotReloadManager` actually reads and which is KEPT unenforced as the starting point for a separate future decision. Like `isAsync`, its prescription is a semantic entry rather than a conversion: a manifest is not a stack collection, so `os migrate meta` has no seam at which to rewrite one. +The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, maintainer ruling 2026-08-09). It was declared from the day `ACTION_LOCATIONS` was written and no product surface ever served it: the console command palette composes its groups from nav items, objects, dashboards, pages, reports, recent items and record search, and reads no action metadata at all — so an action declaring this location never reached a user. What lifts it above ordinary inert-declaration cleanup is that the authoring tool PROMISED the surface: the Studio designer previewed a mock `⌘K · Command palette` frame for exactly this value, so an author (very often an AI, ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 shape arriving through a location vocabulary rather than through a missing key. It was retired rather than implemented because the demand evidence is empty: no user has asked for command-palette actions and the only two declarers were our own showcase corpus, so wiring the palette would have been capability expansion with no pull. This is an enum VALUE, not a key, so — as with `crypto.hash` and the two aggregate functions above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed". The conversion strips the value from `action.locations` and KEEPS the key even when the array empties, because on this surface `locations: []` and an absent `locations` are different declarations: the empty array is the documented headless shape (callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key means nobody placed the action — which is what `packages/lint`'s `action-no-placement` warns about. An object-less action, whose only reason for declaring `global_nav` was that it has no row and no record header to render on, is therefore migrated to the declaration it always meant. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -295,6 +297,7 @@ The plugin manifest loses its whole `loading` block in this step (#4914, ADR-004 | `page-structure-inert-keys-removed` | `page.component.page:header.icon / page.component.page:card.actions` | page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only) | retired — `migrate meta` only | | `record-details-layout-removed` | `page.component.record:details.layout` | record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing) | retired — `migrate meta` only | | `app-hidden-to-unpublished` | `app.hidden` | stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched) | retired — `migrate meta` only | +| `action-global-nav-location-removed` | `action.locations[]` | action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 9a0b5e4119..86245021e3 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -397,6 +397,12 @@ "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", "conversionId": "app-hidden-to-unpublished", "toMajor": 17 + }, + { + "surface": "action.locations[]", + "to": "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`)", + "conversionId": "action-global-nav-location-removed", + "toMajor": 17 } ], "migrated": [ @@ -1317,6 +1323,12 @@ "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", "conversionId": "app-hidden-to-unpublished", "toMajor": 17 + }, + { + "surface": "action.locations[]", + "to": "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`)", + "conversionId": "action-global-nav-location-removed", + "toMajor": 17 } ], "migrated": [