Skip to content

Commit e027b3e

Browse files
os-zhuangclaude
andauthored
refactor(spec)!: retire global_nav from ACTION_LOCATIONS — a location the product never rendered, and the designer previewed anyway (#6888) (#7322)
* wip: retire global_nav from ACTION_LOCATIONS (#6888) * chore(spec): regenerate spec-changes, upgrade guide and reference docs (#6888) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f188ed6 commit e027b3e

17 files changed

Lines changed: 419 additions & 37 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `global_nav` from `ACTION_LOCATIONS` — a location the product never rendered, and the designer previewed anyway (#6888, ADR-0049)
6+
7+
`ACTION_LOCATIONS` is the canonical vocabulary for where an action surfaces in a
8+
running app, and `global_nav` — "global navigation/command-palette level
9+
actions" — has been in it since the vocabulary was written. **No running-app
10+
surface ever rendered it.** The console's ⌘K palette
11+
(`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items,
12+
objects, dashboards, pages, reports, recent items, record search and theme; its
13+
`actions` group is hard-coded chrome; and the file references neither
14+
`global_nav` nor any action-metadata source at all. Of the five references to
15+
the value in the whole UI repo at the vendored SHA, four were the Studio
16+
designer and the fifth a doc comment.
17+
18+
What lifts this above ordinary inert-declaration cleanup is the direction of the
19+
lie. `metadata-admin/previews/ActionPreview.tsx` drew the author a mock
20+
`⌘K · Command palette` frame, so the **authoring tool promised a surface the
21+
product does not have**. An author declares the location, watches it "render" in
22+
the designer, ships it, and it reaches no user — the ADR-0078
23+
declares/renders/does-nothing shape, arriving through the location vocabulary
24+
rather than through a missing key. For an AI author reading the corpus (ADR-0033)
25+
that preview is evidence the capability exists, which is exactly how dead
26+
metadata multiplies.
27+
28+
Retired rather than implemented (maintainer ruling, 2026-08-09): no user has
29+
asked for command-palette actions, and the only two declarers were our own
30+
showcase corpus. Wiring the palette would have been capability expansion with no
31+
pull. If real appetite appears it re-enters through the front door,
32+
implementation first.
33+
34+
FROM → TO:
35+
36+
| Was | Now |
37+
|:--|:--|
38+
| `locations: ['global_nav', 'record_header']` | `locations: ['record_header']` — drop the value, keep the served locations |
39+
| `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` |
40+
| `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 |
41+
42+
The retirement kit:
43+
44+
- This is an enum **VALUE** retirement, so there is no `retiredKey()` tombstone:
45+
the enum's own error map carries the prescription, keyed on the received value
46+
so only the spelling that used to be legal is told it "was removed" (the
47+
`crypto.hash` / `HookBodyCapability` precedent, #4391, and `array_agg` /
48+
`AggregationFunction`, #6188). A mis-spelling still gets zod's list of the
49+
legal locations. For the same reason nothing lands in `RETIRED_KEYS_BY_MAJOR`
50+
and the four surface ratchets are byte-identical — no def and no authorable
51+
key changed.
52+
- **ADR-0087 D2 conversion + D3 chain step**
53+
(`action-global-nav-location-removed`): `os migrate meta --from 16` strips the
54+
value from `action.locations`, one notice per rewritten action.
55+
- **The key is kept when the array empties**`locations: []`, never
56+
`delete locations`. On this surface the two are different declarations, not
57+
two spellings of one: the empty array is the documented headless shape
58+
("Headless actions: declare it, then hide it"), while an absent key means
59+
nobody placed the action — which is what `packages/lint`'s
60+
`action-no-placement` warns about, in those words ("an author who said
61+
'nowhere, deliberately' (`[]`) and one who never said anything at all").
62+
Dropping the key would convert a deliberate placement into a lint finding and
63+
discard the author's own statement of intent. This is the
64+
`hook-body-crypto-hash-removed` shape ("the `capabilities` key itself stays —
65+
an empty grant set is legal"), not the
66+
`dataset-measure-array-string-agg-removed` shape, which drops its item only
67+
because the stripped remainder would fail the dataset's own refinement.
68+
- The QA platform checklist's `records-forms.action-location-matrix` loses its
69+
`global_nav` variant. That variant was **unrunnable**, not merely obsolete:
70+
its step "new_task from the palette" could never pass. `enumSource.expect`
71+
moves 7 → 6, which is the ratchet that would otherwise have caught this drift.
72+
- The two showcase declarers become headless. `showcase_portfolio_snapshot` is
73+
object-less by design and its docblock already said `global_nav` was chosen
74+
"for the same reason" it has no `objectName` — so `[]` is the declaration it
75+
always meant.
76+
77+
**Behaviour that changes:** none at runtime. An action declaring `global_nav`
78+
rendered nowhere before this change and renders nowhere after it; what changes is
79+
that the declaration is now refused at parse, with the reason, instead of being
80+
accepted and silently ignored.
81+
82+
<!-- adr-0087: registered action-global-nav-location-removed -->

content/docs/protocol/objectui/actions.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ The `locations` array declares where an action surfaces. The canonical enum (`AC
221221
| `record_more` | Overflow ("More" / ⋯) menu on a record |
222222
| `record_related` | Actions on a related-list section inside a record |
223223
| `record_section` | Actions inside a body section/tab of a record |
224-
| `global_nav` | Global navigation / command-palette actions |
225224

226225
```yaml
227226
name: export_customers

content/docs/references/ui/action.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const result = ActionSchema.parse(data);
6464
| **label** | `string \| Record<string, string>` || Display label |
6565
| **objectName** | `string` | optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). |
6666
| **icon** | `string` | optional | Icon name |
67-
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section' \| 'global_nav'>[]` | optional | Locations where this action is visible |
67+
| **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Locations where this action is visible |
6868
| **component** | `Enum<'action:button' \| 'action:icon' \| 'action:menu' \| 'action:group'>` | optional | Visual component override |
6969
| **type** | `Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>` | optional | Action functionality type |
7070
| **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);
149149
* `record_more`
150150
* `record_related`
151151
* `record_section`
152-
* `global_nav`
153152

154153

155154
---

content/docs/ui/actions.mdx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ is collected, then the body runs with those values as its `input`.
189189
| `record_more` | Record page overflow ("…") menu |
190190
| `record_related` | Related-list sections |
191191
| `record_section` | Named action bars on record pages |
192-
| `global_nav` | App-level navigation |
193192

194193
Surfaces can also reference actions **by name**:
195194

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ The same descriptor loses a key in this step, and the pairing is the point (#674
236236

237237
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.
238238

239+
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.
240+
239241
### Mechanical (applied for you)
240242

241243
| Conversion | Surface | Change | Load window |
@@ -295,6 +297,7 @@ The plugin manifest loses its whole `loading` block in this step (#4914, ADR-004
295297
| `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 |
296298
| `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 |
297299
| `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 |
300+
| `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 |
298301

299302
### Semantic (delegated to you, with acceptance criteria)
300303

0 commit comments

Comments
 (0)