diff --git a/.changeset/approvals-nav-points-at-inbox.md b/.changeset/approvals-nav-points-at-inbox.md
new file mode 100644
index 0000000000..082ad852fd
--- /dev/null
+++ b/.changeset/approvals-nav-points-at-inbox.md
@@ -0,0 +1,31 @@
+---
+'@objectstack/platform-objects': minor
+'@objectstack/plugin-approvals': minor
+---
+
+Point the account app's **Approvals** navigation entry at the Approvals Inbox component, and contribute an **Approvals Inbox** entry to Setup (#7234).
+
+The entry point has not moved — the account menu still shows **Approvals** with the same
+label and icon in every locale. Its destination has. It used to open the raw
+`sys_approval_request` grid, which is an admin/diagnostic view of the engine's own table
+and cannot show an approver a single decision button: every action on that object is gated
+on `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block is
+attached only by the approvals REST path, never by the generic data API the object route
+reads. The result was a correct-looking list of rows nobody could act on. The entry is now
+`{ type: 'component', componentRef: 'approvals:inbox' }`, so it opens the full inbox —
+decision actions, business vocabulary, node progress and the request drawer.
+
+- **Account app**: `nav_account_approvals` becomes a component entry gated by
+ `requiresService: 'approvals'`, so it disappears where `plugin-approvals` is not
+ installed (the previous `requiresObject` gate does not apply to a component entry).
+- **Setup**: `plugin-approvals` contributes a new **Approvals Inbox** entry at the top of
+ **Setup → Approvals**, above the three raw tables, which stay exactly as they were —
+ admin-gated by `manage_platform_settings` and now unambiguously the diagnostic surface.
+ Labels ship in all four locales (zh-CN 审批中心).
+- `sys_approval_request` is no longer surfaced raw to end users anywhere.
+- **Docs**: the approver's queue is documented as the Approvals Inbox, with a snippet for
+ mounting it in any business app — one navigation entry naming the component-registry key
+ `approvals:inbox`, never a console path.
+
+Reaching the inbox end to end in the browser additionally requires the console pin bump,
+tracked separately.
diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx
index a09a379eb6..6f4dcfe681 100644
--- a/content/docs/automation/approvals.mdx
+++ b/content/docs/automation/approvals.mdx
@@ -314,9 +314,24 @@ and nothing can move it, so the run parks forever.
### The approver finds it in their queue
-Requests land in **Setup → Approvals → Requests**, whose `my_pending` view
-filters to `status = pending` and `pending_approvers` containing the current
-user. Programmatically:
+The approver's surface is the **Approvals Inbox** — in the stock console, the
+**Approvals** entry in the account menu. It lists what is waiting on the current
+user (`status = pending`, `pending_approvers` containing them) and it is the only
+surface that carries the **decision actions**: approve, reject, return, reassign,
+request info, delegate, remind, override.
+
+
+**Why the raw `sys_approval_request` table is not that surface.** Every decision
+action on the object is gated on `record.viewer.can_act ||
+record.viewer.can_override`, and the `viewer` block is computed and attached only
+by the approvals REST path (`/api/v1/approvals/*`) — never by the generic data
+API a plain object view reads. Browse the table directly and you get a correct,
+completely inert list: the rows are there, the buttons are not. The table stays
+available under **Setup → Approvals → Requests** for admins and diagnostics,
+which is what it is good for.
+
+
+Programmatically the same queue is:
```bash
curl -b cookies.txt \
@@ -353,6 +368,45 @@ service and drive `remind()`. Reminder, escalation, return, and reassignment
*do* publish notification topics — the initial request doesn't.
+### Mount the approvals inbox in your app
+
+Approvals are cross-cutting: people approve expenses inside the expenses app,
+not by leaving for a platform app. Add one navigation entry to any app you ship
+and that app gets the full inbox:
+
+```ts
+// my-app.app.ts
+navigation: [
+ // …
+ {
+ id: 'nav_approvals',
+ type: 'component',
+ label: 'Approvals',
+ componentRef: 'approvals:inbox',
+ icon: 'check-circle',
+ // Hide the entry where the approvals plugin is not installed. Enforced
+ // server-side: a gated-off entry never reaches the browser.
+ requiresService: 'approvals',
+ },
+],
+```
+
+`componentRef` names a **component-registry key**, not a URL. The console owns
+the route it resolves to, so `approvals:inbox` keeps working if that route ever
+changes — never hand-write a console path into app metadata.
+
+The inbox is per-user, not per-app: it shows the signed-in user everything
+waiting on them across every object, because "what is waiting on me" is a
+property of the person and not of the app they happen to have open. Filtering it
+down to one app's objects is not an option today.
+
+
+The platform's own **Account** app mounts it exactly this way, and
+**Setup → Approvals → Approvals Inbox** mounts the same component for admins
+above the raw `sys_approval_request` / `sys_approval_action` /
+`sys_approval_delegation` tables.
+
+
### The decision
```bash
diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx
index 16eadb95b4..03f582286e 100644
--- a/content/docs/ui/setup-app.mdx
+++ b/content/docs/ui/setup-app.mdx
@@ -52,7 +52,7 @@ anchors are:
| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) |
| **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` |
| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
-| **Approvals** (`group_approvals`) | `plugin-approvals` |
+| **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` |
| **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` |
| **Integrations** (`group_integrations`) | `plugin-webhooks` |
diff --git a/packages/platform-objects/src/apps/account-approvals-nav.test.ts b/packages/platform-objects/src/apps/account-approvals-nav.test.ts
new file mode 100644
index 0000000000..93c9618281
--- /dev/null
+++ b/packages/platform-objects/src/apps/account-approvals-nav.test.ts
@@ -0,0 +1,121 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The account app's Approvals entry points at the Approvals Inbox COMPONENT,
+// not at the raw `sys_approval_request` grid (#7234, entry C of #7213).
+//
+// Why this needs a pin rather than a code comment: the two shapes are one
+// keyword apart and both parse. `type: 'object'` renders a perfectly healthy
+// list of the engine's own table — which is exactly the defect. Every one of
+// `sys_approval_request`'s eight decision actions is gated on
+// `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block
+// is attached ONLY by the approvals REST path (`approval-service.ts`,
+// `attachViewers()`), never by the generic data API the object route uses. So
+// the object destination is a read-only wall of rows an approver cannot act on,
+// and it LOOKS fine. A regression back to it would show up in no other test.
+//
+// The gate half is verified where it is implemented, not re-implemented here:
+// `packages/rest/src/meta-app-area-nav-gate.test.ts` pins that `filterNav`
+// strips a `type: 'component'` item whose `requiresService` names an absent
+// service — variant-agnostic, which is what makes `requiresService` usable on a
+// component item at all.
+import { describe, it, expect } from 'vitest';
+import { AppSchema } from '@objectstack/spec/ui';
+
+import { ACCOUNT_APP } from './account.app.js';
+
+type NavItem = {
+ id?: string;
+ type?: string;
+ label?: string;
+ objectName?: string;
+ viewName?: string;
+ componentRef?: string;
+ requiresObject?: string;
+ requiresService?: string;
+ children?: NavItem[];
+};
+
+/** Every nav item in the account app, depth-first. */
+const allNavItems = (): NavItem[] => {
+ const out: NavItem[] = [];
+ const walk = (items: NavItem[] = []) => {
+ for (const item of items) {
+ if (!item) continue;
+ out.push(item);
+ if (Array.isArray(item.children)) walk(item.children);
+ }
+ };
+ walk((ACCOUNT_APP.navigation ?? []) as NavItem[]);
+ return out;
+};
+
+const approvalsEntry = (): NavItem => {
+ const item = allNavItems().find((i) => i.id === 'nav_account_approvals');
+ expect(item, 'the account app lost its nav_account_approvals entry').toBeDefined();
+ return item as NavItem;
+};
+
+describe("the account app's Approvals entry targets the inbox component (#7234)", () => {
+ it('routes to the `approvals:inbox` registry key', () => {
+ expect(approvalsEntry()).toMatchObject({
+ type: 'component',
+ componentRef: 'approvals:inbox',
+ });
+ });
+
+ it('addresses a registry KEY, never a console path', () => {
+ // objectui#2763's boundary: nav metadata names a component-registry key and
+ // the console owns the URL it resolves to (`approvals:inbox` →
+ // `/apps/{app}/component/approvals/inbox`, objectui#4071). A `/`-shaped ref
+ // here would be this package reaching across that line and would silently
+ // survive every schema check — `componentRef` is a free string, and
+ // `validate-nav-target-refs` deliberately does not resolve component refs
+ // (an unregistered ref is a NON-rule there: the registry is not visible to
+ // the linter).
+ const ref = approvalsEntry().componentRef ?? '';
+ expect(ref).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/);
+ expect(ref).not.toContain('/');
+ });
+
+ it('no longer routes to the raw sys_approval_request table', () => {
+ const entry = approvalsEntry();
+ expect(entry.objectName).toBeUndefined();
+ expect(entry.viewName).toBeUndefined();
+ // `requiresObject` is the object-route gate; a component item has no object
+ // route to gate, so leaving it behind would be a dead key that reads as one.
+ expect(entry.requiresObject).toBeUndefined();
+ });
+
+ it('gates on the approvals SERVICE, which is what backs the inbox', () => {
+ // `plugin-approvals` does `ctx.registerService('approvals', …)` in start().
+ // Without a gate the entry would render a dead component route on any
+ // deployment that does not install the plugin — the "dead-quiet entry"
+ // failure. `requiresService` is stripped server-side by `filterAppForUser`
+ // (ADR-0057 D10), so a gated-off entry never reaches the browser.
+ expect(approvalsEntry().requiresService).toBe('approvals');
+ });
+
+ it('keeps its label and icon — the entry point does not move', () => {
+ // Only the DESTINATION changes. The label is load-bearing beyond this file:
+ // `plugin-approvals`' approval-status-vocabulary.test.ts ties the
+ // `my_pending` view label to this entry's per-locale label (#7232/#7271), so
+ // rewording it here turns that cross-package parity case red.
+ expect(approvalsEntry()).toMatchObject({ label: 'Approvals', icon: 'check-circle' });
+ });
+
+ it('surfaces sys_approval_request raw NOWHERE in the account app', () => {
+ // The acceptance criterion in its own words: the engine-owned table is an
+ // admin/diagnostic surface reachable only under Setup, behind
+ // `group_approvals`' `manage_platform_settings` gate. The account app
+ // deliberately declares no `requiredPermissions` (every authenticated user
+ // needs it for sessions / API keys), so anything it lists is listed for
+ // everyone — which is why this is asserted over the whole tree and not just
+ // over the one entry above.
+ const raw = allNavItems().filter((i) => i.objectName === 'sys_approval_request');
+ expect(raw.map((i) => i.id)).toEqual([]);
+ });
+
+ it('the whole app still satisfies AppSchema with the component item', () => {
+ expect(() => AppSchema.parse(ACCOUNT_APP)).not.toThrow();
+ });
+});
diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts
index 80c8448d38..ee2c38c27f 100644
--- a/packages/platform-objects/src/apps/account.app.ts
+++ b/packages/platform-objects/src/apps/account.app.ts
@@ -98,13 +98,35 @@ export const ACCOUNT_APP: App = {
requiresObject: 'sys_inbox_message',
},
{
+ // #7234 — the DESTINATION is the Approvals Inbox component, not the
+ // raw `sys_approval_request` grid. The engine-owned table has no
+ // decision actions for an end user: all eight are gated on
+ // `record.viewer.can_act || record.viewer.can_override`, and the
+ // `viewer` block is attached ONLY on the approvals REST path
+ // (`approval-service.ts`, `attachViewers()`), never on the generic
+ // data API. So the object route could only ever render a read-only
+ // list of rows an approver cannot act on. The inbox component calls
+ // the approvals REST path and therefore carries the decision
+ // actions, business vocabulary, node progress and drawer.
+ //
+ // The ref is the component-registry KEY, never a console path — that
+ // indirection is the whole contract (objectui#2763; the key is
+ // registered by objectui#4071).
id: 'nav_account_approvals',
- type: 'object',
+ type: 'component',
label: 'Approvals',
- objectName: 'sys_approval_request',
- viewName: 'my_pending',
+ componentRef: 'approvals:inbox',
icon: 'check-circle',
- requiresObject: 'sys_approval_request',
+ // `requiresObject` cannot gate a component item — it names the
+ // object the entry ROUTES to, and this one routes to a component.
+ // The capability that must be present is the approvals SERVICE
+ // (`ctx.registerService('approvals', …)` in plugin-approvals), which
+ // is also what backs the REST path the inbox reads. Same shape as
+ // `nav_account_memberships` below, and enforced server-side by
+ // `filterAppForUser`/`filterNav` (ADR-0057 D10) — without it the
+ // entry would render a dead component route wherever
+ // plugin-approvals is not installed.
+ requiresService: 'approvals',
},
{
id: 'nav_account_memberships',
diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts
index 8e2c56d6bb..727a749b6a 100644
--- a/packages/platform-objects/src/apps/translations/en.ts
+++ b/packages/platform-objects/src/apps/translations/en.ts
@@ -98,7 +98,9 @@ export const en: TranslationData = {
nav_api_keys: { label: 'API Keys' },
nav_connect_agent: { label: 'Connect an Agent' },
- // Approvals
+ // Approvals. `nav_approvals_inbox` is the working surface (#7234); the
+ // three below it are the engine's raw tables, kept as the admin view.
+ nav_approvals_inbox: { label: 'Approvals Inbox' },
nav_approval_requests: { label: 'Requests' },
nav_approval_actions: { label: 'Action History' },
nav_approval_delegations: { label: 'Delegations (OOO)' },
diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts
index bd4891204e..884f9429b7 100644
--- a/packages/platform-objects/src/apps/translations/es-ES.ts
+++ b/packages/platform-objects/src/apps/translations/es-ES.ts
@@ -68,6 +68,7 @@ export const esES: TranslationData = {
nav_api_keys: { label: 'Claves API' },
nav_connect_agent: { label: 'Conectar un agente' },
+ nav_approvals_inbox: { label: 'Centro de aprobaciones' },
nav_approval_requests: { label: 'Solicitudes' },
nav_approval_actions: { label: 'Historial de Acciones' },
nav_approval_delegations: { label: 'Delegaciones (ausencia)' },
diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts
index a894621e62..103fc727c0 100644
--- a/packages/platform-objects/src/apps/translations/ja-JP.ts
+++ b/packages/platform-objects/src/apps/translations/ja-JP.ts
@@ -68,6 +68,7 @@ export const jaJP: TranslationData = {
nav_api_keys: { label: 'API キー' },
nav_connect_agent: { label: 'エージェントを接続' },
+ nav_approvals_inbox: { label: '承認センター' },
nav_approval_requests: { label: 'リクエスト' },
nav_approval_actions: { label: 'アクション履歴' },
nav_approval_delegations: { label: '委任 (不在時)' },
diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts
index f44e758ad2..5a5f914c0d 100644
--- a/packages/platform-objects/src/apps/translations/zh-CN.ts
+++ b/packages/platform-objects/src/apps/translations/zh-CN.ts
@@ -71,6 +71,9 @@ export const zhCN: TranslationData = {
nav_api_keys: { label: 'API 密钥' },
nav_connect_agent: { label: '连接智能体' },
+ // 审批中心 = the Approvals Inbox component (#7234) — the surface with
+ // decision actions. The three below are the engine's raw tables.
+ nav_approvals_inbox: { label: '审批中心' },
nav_approval_requests: { label: '审批申请' },
nav_approval_actions: { label: '审批历史' },
// `审批委派` matches sys_approval_delegation's object label; `(外出)`
diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts
index 72c0f1c0e0..4d66ab9277 100644
--- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts
+++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts
@@ -82,12 +82,23 @@ export class ApprovalsServicePlugin implements Plugin {
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
// ships their menu too; when the plugin isn't installed the slot is empty.
+ //
+ // #7234 — order inside the slot is ARRAY ORDER: `applyNavContributions`
+ // does `group.children.push(...c.items)` per contribution (sorted by
+ // `priority`), so the inbox being first in this array is what puts it
+ // above the raw tables. Do not reorder without meaning to.
+ //
+ // The inbox entry is the working surface (decision actions, node
+ // progress, drawer); the three object entries below stay as the
+ // admin/diagnostic view of the engine's own tables — reachable only here,
+ // behind `group_approvals`' `manage_platform_settings` gate.
navigationContributions: [
{
app: 'setup',
group: 'group_approvals',
priority: 100,
items: [
+ { id: 'nav_approvals_inbox', type: 'component', label: 'Approvals Inbox', componentRef: 'approvals:inbox', icon: 'list-checks' },
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
{ id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
diff --git a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
index 1608e14b92..99e6e38fd2 100644
--- a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
+++ b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts
@@ -37,13 +37,46 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
expect(manifest.navigationContributions).toHaveLength(1);
const contribution = manifest.navigationContributions[0];
expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
- expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
+
+ // ORDER IS THE ASSERTION, not a by-product (#7234). `applyNavContributions`
+ // appends `c.items` into the group verbatim, so this array's order IS the
+ // rendered order — the inbox must come first. A set-shaped assertion (the
+ // `.sort()` this case used to open with) cannot see that, and putting the
+ // working surface below three raw tables is most of the defect #7213
+ // reported.
+ expect(contribution.items.map((i: any) => i.id)).toEqual([
+ 'nav_approvals_inbox',
+ 'nav_approval_requests',
+ 'nav_approval_actions',
+ 'nav_approval_delegations',
+ ]);
+
+ // The inbox entry addresses the component REGISTRY KEY, never a console
+ // path (objectui#2763) — the console resolves `approvals:inbox` to
+ // `component/approvals/inbox` on its side (objectui#4071).
+ const [inbox, ...rawTables] = contribution.items as any[];
+ expect(inbox).toMatchObject({
+ id: 'nav_approvals_inbox',
+ type: 'component',
+ componentRef: 'approvals:inbox',
+ });
+ // It carries no object gate: `requiresObject` names the object an entry
+ // routes to, and this one routes to a component. It needs none — a
+ // navigation CONTRIBUTION only exists while its plugin is installed, which
+ // is the same condition that makes the inbox's REST path answer.
+ expect(inbox.objectName).toBeUndefined();
+ expect(inbox.requiresObject).toBeUndefined();
+
+ // The raw engine tables stay, unchanged, as the admin/diagnostic view.
+ expect(rawTables.map((i: any) => i.objectName)).toEqual([
+ 'sys_approval_request',
'sys_approval_action',
'sys_approval_delegation',
- 'sys_approval_request',
]);
- // Each entry is gated so the slot stays empty when the plugin is absent.
- for (const item of contribution.items) {
+ // Each object entry is gated so the slot degrades cleanly when an object is
+ // not registered.
+ for (const item of rawTables) {
+ expect(item.type).toBe('object');
expect(item.requiresObject).toBe(item.objectName);
}
});