diff --git a/.changeset/action-order-primary-button.md b/.changeset/action-order-primary-button.md
new file mode 100644
index 0000000000..1bd5e4d26a
--- /dev/null
+++ b/.changeset/action-order-primary-button.md
@@ -0,0 +1,9 @@
+---
+"@objectstack/spec": minor
+---
+
+`Action`: add an explicit `order` field so authors and plugins can decide which action holds the record-header primary-button slot, instead of depending on fragile cross-file `defineStack({ actions })` registration order (#2670).
+
+`order` is an optional number, **lower = higher / more prominent**, defaulting to `0`. `mergeActionsIntoObjects()` now stable-sorts every action group — each object's `actions` and the top-level `actions` — by `order` at both `defineStack()` and `composeStacks()` time. In `record_header` the first visible action becomes the primary button, so a negative `order` promotes an action into the primary slot and a positive `order` demotes it toward the `⋯` overflow menu. This is the declarative lever a plugin such as plugin-approvals uses to make an `Approve`/`Reject` decision stably outrank app actions, rather than hiding the other actions to "make room".
+
+Fully backward compatible: the sort is stable and treats unset `order` as `0`, so action groups where nobody sets `order` keep their exact registration order (and array reference). The record-header renderer (objectui) may additionally prefer a `variant: 'primary'` action when two actions tie on `order`.
diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx
index fa289922eb..58be340926 100644
--- a/content/docs/protocol/objectui/actions.mdx
+++ b/content/docs/protocol/objectui/actions.mdx
@@ -169,6 +169,7 @@ interface Action {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
component?: 'action:button' | 'action:icon' | 'action:menu' | 'action:group';
locations?: ActionLocation[]; // Where the action surfaces
+ order?: number; // Sort order within a location group (lower = higher)
// Behavior
type?: 'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'; // default 'script'
@@ -229,6 +230,30 @@ target: /api/customers/export
locations: [list_toolbar]
```
+### Ordering & the Primary Button
+
+Within each location group, actions render in **`order`** sequence — **lower comes first / more prominent**. In `record_header` the first visible action becomes the **primary button** and the rest fall into the `⋯` overflow menu, so `order` is how you decide which action holds the primary slot.
+
+```yaml
+# Approve holds the primary button even though the app action registered first.
+name: approve_request
+label: Approve
+locations: [record_header]
+variant: primary
+order: -10 # negative → floats ahead of app actions (order defaults to 0)
+```
+
+Semantics:
+
+- **`order` defaults to `0`.** An action with a **negative** `order` is promoted toward the primary slot; a **positive** `order` is demoted toward the overflow menu.
+- **The sort is stable.** Actions that leave `order` unset (or tie on the same value) keep their original registration order, so adding `order` to one action never reshuffles the others. Setting `order` on nobody is a no-op — existing screens are unaffected.
+- **No more fragile registration order.** Previously "who becomes the primary button" depended on the cross-file order in which `defineStack({ actions })` merged its arrays. `order` makes it declarative: a plugin such as [plugin-approvals](/docs/plugins/approvals) can inject an `Approve`/`Reject` decision with a low `order` so it stably outranks app actions, instead of the app having to *hide* its other actions to make room.
+- When two actions tie on `order`, the record-header renderer MAY additionally prefer the one with `variant: 'primary'` when choosing the primary button.
+
+
+`order` sorts actions **within the same location**; it does not move an action between locations. Use `locations` to choose *where* an action appears and `order` to choose *its position there*.
+
+
### Visibility & Disabled Rules
`visible` and `disabled` are **CEL** predicates (not declarative `{ field, value }` objects). `disabled` may also be a plain boolean.
diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json
index fc646a91d3..37000ac3c8 100644
--- a/packages/spec/liveness/action.json
+++ b/packages/spec/liveness/action.json
@@ -56,6 +56,11 @@
"status": "live",
"note": "objectui button styling."
},
+ "order": {
+ "status": "live",
+ "evidence": "packages/spec/src/stack.zod.ts (mergeActionsIntoObjects stable-sorts each action group by order)",
+ "note": "Deterministic action ordering (#2670). Framework stable-sorts every action group by `order` at defineStack/composeStacks time; objectui record_header picks the first as the primary button. Lower = higher; unset = 0."
+ },
"confirmText": {
"status": "live",
"note": "objectui confirm dialog."
diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts
index f9fdf19030..06a8025982 100644
--- a/packages/spec/src/compose-stacks.test.ts
+++ b/packages/spec/src/compose-stacks.test.ts
@@ -502,3 +502,117 @@ describe('composeStacks + defineStack integration', () => {
expect(combined.actions).toHaveLength(1);
});
});
+
+// ─── Action `order` sorting (mergeActionsIntoObjects) ───────────────
+// Regression coverage for #2670: the record-header primary button is the first
+// visible `record_header` action, so `order` must control which action lands
+// first — instead of fragile cross-file registration order.
+
+describe('Action order sorting', () => {
+ it("sorts an object's inline actions by `order` (lower = earlier / primary slot)", () => {
+ // Registration order puts the app action first; a negative `order` must
+ // float Approve up into the primary-button slot.
+ const stack = makeStack({
+ objects: [
+ {
+ name: 'deal',
+ fields: { title: { type: 'text' } },
+ actions: [
+ { name: 'close_deal', label: 'Close', locations: ['record_header'] },
+ { name: 'print', label: 'Print', locations: ['record_header'], order: 5 },
+ { name: 'approve', label: 'Approve', locations: ['record_header'], order: -10 },
+ ],
+ },
+ ],
+ });
+
+ const names = stack.objects![0].actions!.map((a) => a.name);
+ // approve (-10) → close_deal (unset = 0) → print (5)
+ expect(names).toEqual(['approve', 'close_deal', 'print']);
+ // What objectui renders as the primary button is now the approval decision.
+ expect(names[0]).toBe('approve');
+ });
+
+ it('is STABLE — actions that tie on `order` (incl. all-unset) keep registration order', () => {
+ const stack = makeStack({
+ objects: [
+ {
+ name: 'deal',
+ fields: { title: { type: 'text' } },
+ actions: [
+ { name: 'a', label: 'A' },
+ { name: 'b', label: 'B' },
+ { name: 'c', label: 'C' },
+ ],
+ },
+ ],
+ });
+ expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('interleaves merged top-level actions with inline actions by `order`', () => {
+ const stack = makeStack({
+ objects: [
+ {
+ name: 'deal',
+ fields: { title: { type: 'text' } },
+ actions: [{ name: 'inline_mid', label: 'Mid', order: 0 }],
+ },
+ ],
+ actions: [
+ { name: 'top_first', label: 'First', objectName: 'deal', order: -5 },
+ { name: 'top_last', label: 'Last', objectName: 'deal', order: 5 },
+ ],
+ });
+ expect(stack.objects![0].actions!.map((a) => a.name)).toEqual([
+ 'top_first',
+ 'inline_mid',
+ 'top_last',
+ ]);
+ });
+
+ it('is backward compatible — no `order` anywhere leaves the merged order untouched', () => {
+ const stack = makeStack({
+ objects: [
+ {
+ name: 'deal',
+ fields: { title: { type: 'text' } },
+ actions: [{ name: 'inline', label: 'Inline' }],
+ },
+ ],
+ actions: [{ name: 'merged', label: 'Merged', objectName: 'deal' }],
+ });
+ // Inline first (pre-existing behaviour), then the merged top-level action.
+ expect(stack.objects![0].actions!.map((a) => a.name)).toEqual(['inline', 'merged']);
+ });
+
+ it('sorts the top-level (global) actions array by `order` too', () => {
+ const stack = makeStack({
+ actions: [
+ { name: 'g_b', label: 'B', order: 2 },
+ { name: 'g_a', label: 'A', order: 1 },
+ ],
+ });
+ expect(stack.actions!.map((a) => a.name)).toEqual(['g_a', 'g_b']);
+ });
+
+ it('respects `order` when composing stacks (funnels through mergeActionsIntoObjects)', () => {
+ const base = makeStack({
+ objects: [
+ {
+ name: 'deal',
+ fields: { title: { type: 'text' } },
+ actions: [{ name: 'app_action', label: 'App', locations: ['record_header'] }],
+ },
+ ],
+ });
+ // A plugin (e.g. plugin-approvals) contributes a high-priority decision.
+ const approvals = makeStack({
+ actions: [
+ { name: 'approve', label: 'Approve', objectName: 'deal', locations: ['record_header'], order: -100 },
+ ],
+ });
+ const combined = composeStacks([base, approvals]);
+ expect(combined.objects![0].actions!.map((a) => a.name)).toEqual(['approve', 'app_action']);
+ });
+});
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index 5f5fa6493c..56b1bb33aa 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -769,25 +769,60 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
}
/**
- * Merge top-level actions into their target objects based on `objectName`.
- *
- * Actions with `objectName` are appended to the corresponding object's `actions` array.
- * Actions without `objectName` (global actions) are left untouched.
- * The top-level `actions` array is preserved for global access (e.g., platform overview, search).
- *
- * This aligns with Salesforce/ServiceNow patterns where object metadata includes its actions,
- * so API responses like `/api/v1/meta/objects/:name` include actions without downstream merge.
- *
+ * Stable-sort an actions array by explicit `order` (lower = higher / earlier).
+ *
+ * - Actions that leave `order` unset are treated as `0`.
+ * - The sort is STABLE (`Array.prototype.sort` is stable since ES2019), so
+ * actions that tie on `order` — including the overwhelmingly common case where
+ * NOBODY sets `order` — keep their original registration order. This is what
+ * lets `order` promote a `record_header` action into the primary-button slot
+ * without disturbing everything else.
+ * - Returns the SAME array reference untouched when no action opts in, so callers
+ * pay zero allocation on the common path and can cheaply detect "unchanged".
+ *
+ * @internal
+ */
+function sortActionsByOrder(actions: T[]): T[] {
+ if (!actions.some((a) => a.order !== undefined)) return actions;
+ // Copy first so the stable sort never mutates the caller's array.
+ return actions.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
+}
+
+/**
+ * Merge top-level actions into their target objects based on `objectName`, then
+ * honour each action's explicit `order`.
+ *
+ * Actions with `objectName` are appended to the corresponding object's `actions`
+ * array. Actions without `objectName` (global actions) are left in place. The
+ * top-level `actions` array is preserved for global access (e.g., platform
+ * overview, search).
+ *
+ * After merging, every action group (each object's `actions` and the top-level
+ * `actions`) is stable-sorted by `order` via {@link sortActionsByOrder}. Because
+ * that sort is a no-op unless an author sets `order`, this is fully backward
+ * compatible — arrays with no `order` keep their exact registration order and
+ * reference. Renderers that pick a single primary action from `record_header`
+ * (objectui) therefore see approve/reject-style actions in their declared
+ * priority rather than in fragile cross-file registration order.
+ *
+ * This aligns with Salesforce/ServiceNow patterns where object metadata includes
+ * its actions, so API responses like `/api/v1/meta/objects/:name` include actions
+ * (already ordered) without downstream merge.
+ *
* @internal
*/
function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition {
- if (!config.actions || !config.objects || config.objects.length === 0) {
- return config;
+ // Honour `order` on the preserved top-level actions regardless of objects.
+ const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
+ const topChanged = sortedTop !== config.actions;
+
+ if (!config.objects || config.objects.length === 0) {
+ return topChanged ? { ...config, actions: sortedTop } : config;
}
- // Build map: objectName → actions[]
+ // Build map: objectName → actions[] (top-level actions targeting an object)
const actionsByObject = new Map>();
- for (const action of config.actions) {
+ for (const action of config.actions ?? []) {
if (action.objectName) {
const list = actionsByObject.get(action.objectName) ?? [];
list.push(action);
@@ -795,20 +830,27 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi
}
}
- if (actionsByObject.size === 0) return config;
-
- // Merge into objects (shallow copy — only the `actions` field is modified;
- // other fields are shared references, consistent with mergeObjects() and Zod output)
+ // Merge into objects and sort each object's final actions by `order` (shallow
+ // copy — only the `actions` field is modified; other fields stay shared
+ // references, consistent with mergeObjects() and Zod output).
+ let objectsChanged = false;
const newObjects = config.objects.map((obj) => {
const objActions = actionsByObject.get(obj.name);
- if (!objActions) return obj;
- return {
- ...obj,
- actions: [...(obj.actions ?? []), ...objActions],
- };
+ const base = obj.actions ?? [];
+ const merged = objActions ? [...base, ...objActions] : base;
+ const sorted = sortActionsByOrder(merged);
+ // Untouched: no top-level actions merged in AND the sort was a no-op.
+ if (!objActions && sorted === base) return obj;
+ objectsChanged = true;
+ return { ...obj, actions: sorted };
});
- return { ...config, objects: newObjects };
+ if (!objectsChanged && !topChanged) return config;
+ return {
+ ...config,
+ ...(objectsChanged ? { objects: newObjects } : {}),
+ ...(topChanged ? { actions: sortedTop } : {}),
+ };
}
/**
diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts
index e6596622f7..9bf4b9b651 100644
--- a/packages/spec/src/ui/action.test.ts
+++ b/packages/spec/src/ui/action.test.ts
@@ -821,6 +821,52 @@ describe('ActionSchema - variant', () => {
});
});
+describe('ActionSchema - order', () => {
+ it('should accept a numeric order (incl. negative and zero)', () => {
+ for (const order of [-100, -1, 0, 5, 999]) {
+ const result = ActionSchema.parse({
+ name: 'ordered_action',
+ label: 'Ordered',
+ target: 'noop',
+ order,
+ });
+ expect(result.order).toBe(order);
+ }
+ });
+
+ it('should accept action without order (optional)', () => {
+ const result = ActionSchema.parse({
+ name: 'no_order',
+ label: 'Action',
+ target: 'noop',
+ });
+ expect(result.order).toBeUndefined();
+ });
+
+ it('should reject a non-numeric order', () => {
+ expect(() => ActionSchema.parse({
+ name: 'bad_order',
+ label: 'Action',
+ target: 'noop',
+ order: 'first',
+ })).toThrow();
+ });
+
+ it('should combine order with variant and locations (record_header primary hint)', () => {
+ const result = ActionSchema.parse({
+ name: 'approve',
+ label: 'Approve',
+ target: 'approve_handler',
+ locations: ['record_header', 'record_more'],
+ variant: 'primary',
+ order: -10,
+ });
+ expect(result.order).toBe(-10);
+ expect(result.variant).toBe('primary');
+ expect(result.locations).toContain('record_header');
+ });
+});
+
// ============================================================================
// Protocol Improvement Tests: execute → target migration & target validation
// ============================================================================
diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts
index d840258e69..feed011722 100644
--- a/packages/spec/src/ui/action.zod.ts
+++ b/packages/spec/src/ui/action.zod.ts
@@ -327,6 +327,27 @@ export const ActionSchema = lazySchema(() => z.object({
/** Visual Style */
variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'),
+ /**
+ * Explicit sort order WITHIN a UI location group (lower = higher / more
+ * prominent). Controls where the action lands in each `locations` group
+ * instead of relying on cross-file `defineStack({ actions })` registration
+ * order — which is fragile and couples unrelated features.
+ *
+ * In `record_header` the first visible action becomes the primary button, so
+ * a low (or negative) `order` promotes an action into the primary slot and a
+ * high `order` demotes it toward the `⋯` overflow menu. This is the
+ * declarative lever a plugin (e.g. plugin-approvals) or app author uses to
+ * make a decision like Approve/Reject stably outrank app actions, rather than
+ * hiding the other actions to "make room".
+ *
+ * Honoured by a STABLE sort in `mergeActionsIntoObjects()` (see stack.zod):
+ * actions that leave `order` unset are treated as `0` and keep their original
+ * registration order, so setting `order` on nobody is a no-op — fully
+ * backward compatible. Renderers MAY additionally prefer a `variant:'primary'`
+ * action when two actions tie on `order` (see objectui record-header renderer).
+ */
+ order: z.number().optional().describe('Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order.'),
+
/** UX Behavior */
confirmText: I18nLabelSchema.optional().describe('Confirmation message before execution'),
successMessage: I18nLabelSchema.optional().describe('Success message to show after execution'),