Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .changeset/action-global-nav-location-retired.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered action-global-nav-location-removed -->
1 change: 0 additions & 1 deletion content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const result = ActionSchema.parse(data);
| **label** | `string \| Record<string, string>` | ✅ | 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. |
Expand Down Expand Up @@ -149,7 +149,6 @@ const result = ActionSchema.parse(data);
* `record_more`
* `record_related`
* `record_section`
* `global_nav`


---
Expand Down
1 change: 0 additions & 1 deletion content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading