Skip to content

Commit f0022c4

Browse files
fix(plugin-approvals): Setup → Approvals → Requests opens the tenant-wide list, and a merged-app pin closes the caller-scoped first-view family (#21991)
Fixes #21984 Clause-②: no ## What changes An administrator who opens Setup → Approvals → Requests now lands on the "All" list of approval requests. Before this, `nav_approval_requests` named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view first (`pending_approvers contains {current_user_id}`). The console opens an object's first declared list view when a route names none, so the administrator saw only the requests pending on themselves. This applies the family's rule from triage `6012877503`: a caller-scoped list view is never an object's first, and every entry that wants one names it. - **`sys_approval_request` declares `all_requests` first.** `my_pending`, `submitted_by_me` and `completed` follow it in their previous order. No view is added, removed or edited. A short comment at `listViews` states why the position is the contract. - **`nav_approval_requests` names `viewName: 'all_requests'`.** That is the key the spec already declares on an object navigation item, so there is no new key. - **The four generated translation bundles follow the new order.** They were regenerated with `node scripts/check-i18n-bundles.mjs --write`. The change is a pure reorder of `_views` keys (+12 / −12 over four files), and no translated text changed. - **Unit pin:** `nav-contribution.test.ts` gains one case for this entry. - **Merged-app pin (new):** `packages/qa/dogfood/test/platform-app-object-entry-views.test.ts`, with 56 cases. Triage `6015714713` requires it; it is described below. It closes the family, because the `platform-objects` pins cannot see plugin entries. - **One changeset:** `@objectstack/plugin-approvals` at `patch`, carrying `Clause-②: no`. ⛔ No new key, no `packages/spec` edit, no `platform-objects` edit, no objectui change, no new `check:*` gate, and no governed surface. ## Supporting edits outside the declared file surface (so the pin can exist) - **`packages/qa/dogfood/vitest.config.ts`:** anchored source aliases for `@objectstack/setup`, `@objectstack/account` and `@objectstack/plugin-sharing` in the `isolated` project. - The pin imports all three as values. - Without an alias, each would resolve through `dist/` and would have to be added to dogfood's `check:test-source-alias` row, which is shrink-only and set-equal. An alias is that gate's prescribed fix. - The other contributors are already aliased (`plugin-approvals`, `service-datasource`, `cloud-connection`) or already in that row (`plugin-security`, `plugin-audit`, `plugin-webhooks`, `service-messaging`, `mcp`, `objectql`, `platform-objects`). - **`packages/qa/dogfood/package.json`** gains `@objectstack/setup` and `@objectstack/account` as devDependencies, and **`pnpm-lock.yaml`** changes by 6 lines. - With these, `turbo ls --affected` reaches this pin when either app shell changes. - Without them, the pin would read two packages outside its dependency graph. ## The dispatch's hypotheses, measured At `origin/main` `1c563af40e`. PR #21983 merged at 12:31Z, before this branch was cut. - **H1 holds.** `listViews` declared `my_pending` (`:62`), `submitted_by_me` (`:76`), `completed` (`:86`) and `all_requests` (`:99`). `nav_approval_requests` (`approvals-plugin.ts:147`) named no view. - **H2: no reader in this repository relies on `sys_approval_request`'s first view.** - **Account:** `nav_account_approvals` (`account.app.ts:114`–`:117`) routes to the `approvals:inbox` component. No Account entry names `sys_approval_request`; the merged pin enumerates all six Account object entries. - **Setup:** `nav_approval_requests` is the only entry naming the object. - **Code:** no code reads this object's first list-view key. `packages/cli/src/commands/lint.ts:168` (`firstListViewKey`) reads a first key only to place a label diagnostic. No view on this object sets `isDefault`. - **objectui at the `.objectui-sha` pin `0abd4f9f87`:** read, not edited. - `ApprovalsInboxPage.tsx` reads the approvals REST path (`services/approvalsApi`) and names `sys_approval_request` only for its declared actions (`:2494`, `:2541`). - `recordApprovalActions.ts:36` and `deadRecordReference.ts:66` name the object, not a view. - No objectui file names `my_pending`, `submitted_by_me` or `all_requests`. - The generic `views[0]` doors (the object breadcrumb and the object switcher) are fixed by the reorder itself. - **H3 holds: the reorder moved four generated files.** These are `plugin-approvals/src/translations/{en,es-ES,ja-JP,zh-CN}.objects.generated.ts`. `check-i18n-bundles` first read "plugins/plugin-approvals: 4 bundle(s) drifted", and after `--write` it exits 0. The `*.source-hashes.generated.ts` files did not move. - **H4 holds.** The merged pin is red on `main` for this entry only; see "Red on main" below. ## Stop conditions (from `6012877503`) - **Access difference: not met.** This was read from source; no real-door read was run. - The view filter is a presentation predicate added to the generic data query. Row-level security and sharing decide which rows a caller reads, whichever view is open. - `all_requests` was already a tab on this page, and the diff changes neither who can read nor which rows they get. - The Setup entry also sits behind `group_approvals`' `manage_platform_settings` gate. - **No unscoped view: not met.** `all_requests` carries no filter at all. ## The merged-app pin `platform-app-object-entry-views.test.ts` boots the composition the way `packages/cli/scripts/check-app-nav-i18n.mjs` does, which was read and not edited: - the same 11 contributors; - a fake context whose only real service is `manifest`; - each manifest handed to `ObjectQL.registerApp`; - the apps read back through `registry.getApp`, which applies the same `applyNavContributions` merge that `/api/v1/meta/app` serves. Its population is every `type: 'object'` entry of the merged apps, with nothing hand-listed: 26 Setup entries and 6 Account entries, naming 27 objects. - **(a), 25 cases:** every object an entry names declares a first list view without `{current_user_id}`. - **(b), 14 cases:** every entry whose object declares a caller-scoped view names a `viewName` that the object declares. On a Setup entry, that view must not be caller-scoped. - **Composition and non-vacuity, 17 cases:** - both apps are merged; - each contributor lands at least one id in each app it declares; - the population contains plugin entries (`nav_approval_requests`, `nav_record_shares`); - the eight objects this family reordered are judged by both rules; - no named object is unresolved; - the objects that declare only caller-scoped views are exactly `sys_inbox_message` and `sys_member`, and only Account entries name them, each with `mine`. - **Where an object's definition is read:** - first, the composition's own registry (`registry.getObject`); - otherwise, `@objectstack/platform-objects/identity`, which is the barrel `plugin-auth` registers its identity objects from (`authIdentityObjects`). `AuthPlugin` cannot boot without a secret, which is also why check-app-nav-i18n leaves it out. - At this head, 10 named objects come from the identity barrel and 17 from the composition. - **Reach:** - The roster mirrors check-app-nav-i18n's `CONTRIBUTORS` by hand, so a contributor added there and not here is not seen. Reading that script's text from this test would have needed a `CROSS_PACKAGE_TEST_INPUTS` declaration and a turbo.json edit, so that was not done. - `plugin-auth`'s `nav_sso_providers` is merged only when an external IdP is wired, so no boot here merges it. Its object `sys_sso_provider` declares no caller-scoped view. ## Red on main, green on the head - **Main (`1c563af40e`):** the exact base blobs of the two subject files were restored into the tree with `git restore --source`. The pin reads `plugin-approvals` through its source alias. - Pin: **2 failed | 54 passed (56)**. The failures are `(a) … › sys_approval_request` ("declares the caller-scoped list view "my_pending" first; it is opened by setup/nav_approval_requests") and `(b) … › setup/nav_approval_requests` ("names no viewName"). - Unit: **1 failed | 1 passed (2)**. - No other entry was red, because PR #21983 had already landed. The restore was proven by blob equality with HEAD and an empty `git diff HEAD`. - **Head (`10ff7b0044`):** pin **56 passed (56)**; unit **2 passed (2)**. ## Ablation (on committed `079304d6ec`, through `scripts/ablation-replace.mjs`, restores proven by blob) The direction of each leg was predicted before it ran: one red pin case and one red unit case per leg. | leg | mutation | pin | unit | |:--|:--|:--|:--| | A | literal swap of the adjacent `all_requests` / `my_pending` blocks (blob `17a36501e7fd → 014a8acaa16d`) | 1 failed / 55: `(a) › sys_approval_request` | 1 failed: "declares all_requests first" | | B | drop `viewName: 'all_requests'` (blob `70ec3ecd12af → 9fb934b3be07`) | 1 failed / 55: `(b) › setup/nav_approval_requests` "names no viewName" | 1 failed: "names its view" | | C | `viewName: 'my_pending'` | 1 failed / 55: `(b)` "is an administrator's entry and names the caller-scoped view" | 1 failed | | D | `viewName: 'all_requestz'` | 1 failed / 55: `(b)` "names "all_requestz", which the object does not declare" | 1 failed | - No `dist/` sits between the mutation and the run: the pin reaches `@objectstack/plugin-approvals` through the source alias (`vitest.config.ts`), and the unit test imports it relatively. - `plugin-approvals` was rebuilt afterwards for its built readers. `ablation-dist-preflight` found the marker in both built files. - The composition guards (a contributor landing nothing, an unresolved object) were not ablated. ## Tests and gates (head `10ff7b0044`) - `pnpm --filter @objectstack/plugin-approvals test`: 61 files, 899 tests passed. - `typecheck` passed for both packages. - `plugin-approvals`: its main program excludes tests. `check:test-typecheck` compiles `nav-contribution.test.ts` through `tsconfig.test.json` (`--listFiles`: 1), and the debt ledger has no entry for it. - `dogfood`: its `tsc` compiles the pin (`--listFiles`: 1). - `pnpm check:app-nav-i18n` reports OK: 11 contributors, setup 55 and account 12 merged nav ids. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` was re-derived on this change with no paths, giving 79 commands. That is the dispatch's 51 plus 28 from the changeset, manifest, lockfile and dogfood files. All 79 exit 0. - `--ran` reconciliation: "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 8 packages outside this closure had no `dist/`). Those were built (41 tasks, all turbo cache hits) and it re-ran to exit 0. - **Lint, narrowed:** eslint `--no-inline-config --format json` over the 9 touched lintable files reported 9 files, 0 ignored, 0 errors and 0 warnings. - The population is the repo's one `eslint.config.mjs`, and none of the 9 is ignored. - That config never enables type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - The repo-wide `pnpm lint` is CI's. ## Acceptance notes (observed, not filed) - `packages/platform-objects/src/apps/account.app.ts:79` says the Account inbox entries "rely on pre-existing `*.mine` / `*.my_pending` listViews". The Approvals entry has opened the inbox component instead. This is comment drift in a file outside this card. - `docs/qa/platform-checklist/areas/platform-core.json:525` lists the Account destination as "Approvals (sys_approval_request/my_pending)", but that entry is the `approvals:inbox` component. This is checklist drift. - The pin's roster and check-app-nav-i18n's `CONTRIBUTORS` are two hand-kept copies of one composition, and nothing mechanical holds them equal. Implemented by the os-dev run of session `session_01WMQprn46CND82KmY8sZWBu` on branch `claude/issue-21984-approvals-requests-all-first`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6befe19 commit f0022c4

12 files changed

Lines changed: 519 additions & 22 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
Setup → Approvals → Requests opens on every approval request, not on the requests pending on the administrator. Before this, the entry named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view (`pending_approvers contains {current_user_id}`) first, so the console opened it.
6+
7+
Clause-②: no
8+
9+
- `sys_approval_request` now declares its unscoped "All" view (`all_requests`) first. "My Pending", "I Submitted" and "Completed" follow it in their previous order, still as tabs. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens "All". No view is added, removed or changed.
10+
- The Setup entry `nav_approval_requests` now names `all_requests` with `viewName`, so it does not depend on the declared order. The Account app's Approvals entry opens the Approvals Inbox component and reads neither.
11+
- The declared order decides which view opens, not which rows a caller may read.
12+
- The generated translation bundles follow the new view order. No translated text changed.
13+
- ⛔ No schema, parse, export or accept-set change.

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,21 @@ export class ApprovalsServicePlugin implements Plugin {
137137
// progress, drawer); the three object entries below stay as the
138138
// admin/diagnostic view of the engine's own tables — reachable only here,
139139
// behind `group_approvals`' `manage_platform_settings` gate.
140+
//
141+
// `nav_approval_requests` names `all_requests` explicitly: an entry that
142+
// names no view opens the object's first declared list view, so an
143+
// administrator's page would otherwise depend on `listViews` order (it
144+
// opened the caller-scoped `my_pending` until that view moved off first
145+
// place). The two other object entries' objects declare no caller-scoped
146+
// view.
140147
navigationContributions: [
141148
{
142149
app: 'setup',
143150
group: 'group_approvals',
144151
priority: 100,
145152
items: [
146153
{ id: 'nav_approvals_inbox', type: 'component', label: 'Approvals Inbox', componentRef: 'approvals:inbox', icon: 'list-checks' },
147-
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
154+
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', viewName: 'all_requests', icon: 'inbox', requiresObject: 'sys_approval_request' },
148155
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
149156
{ id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
150157
],

‎packages/plugins/plugin-approvals/src/nav-contribution.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,25 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { NavigationContributionSchema } from '@objectstack/spec/ui';
45
import { ApprovalsServicePlugin } from './approvals-plugin.js';
6+
import { SysApprovalRequest } from './sys-approval-request.object.js';
7+
8+
/** The plugin's one manifest, as `init` registers it. */
9+
async function registeredManifest(): Promise<any> {
10+
const registered: any[] = [];
11+
const ctx: any = {
12+
getService: (name: string) =>
13+
name === 'manifest' ? { register: (m: any) => registered.push(m) } : undefined,
14+
logger: { info: () => {}, warn: () => {} },
15+
};
16+
await new ApprovalsServicePlugin({ disableService: true }).init(ctx);
17+
expect(registered).toHaveLength(1);
18+
return registered[0];
19+
}
20+
21+
/** A view is caller-scoped when it filters on the caller: the token appears anywhere in it. */
22+
const isCallerScoped = (view: unknown): boolean => JSON.stringify(view ?? {}).includes('{current_user_id}');
523

624
/**
725
* ADR-0029 K2.b / D7 — the approvals plugin owns sys_approval_request /
@@ -80,4 +98,31 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
8098
expect(item.requiresObject).toBe(item.objectName);
8199
}
82100
});
101+
102+
// Setup → Approvals → Requests is an administrator's page. When a route names
103+
// no view, the console opens the object's FIRST declared list view, so two
104+
// things must hold: the first declared view is not filtered to the caller,
105+
// and the Setup entry does not depend on that order at all. The merged-app
106+
// pin in `packages/qa/dogfood` asserts the same two rules over every object
107+
// entry of the runtime-merged Setup and Account apps; this case is the owning
108+
// package's own copy for this one entry.
109+
it('the Requests entry opens the tenant-wide list, and no caller-scoped view is declared first', async () => {
110+
const manifest = await registeredManifest();
111+
const entry = manifest.navigationContributions[0].items.find((i: any) => i.id === 'nav_approval_requests');
112+
expect(entry, 'nav_approval_requests is contributed').toBeDefined();
113+
expect(entry.viewName, 'nav_approval_requests names its view').toBe('all_requests');
114+
115+
const listViews = (SysApprovalRequest as any).listViews as Record<string, unknown>;
116+
expect(Object.keys(listViews)[0], 'sys_approval_request declares all_requests first').toBe('all_requests');
117+
expect(isCallerScoped(listViews.all_requests), 'all_requests is not filtered to the caller').toBe(false);
118+
// Non-vacuity: the object really declares caller-scoped views, so the order
119+
// and the named view are what decide the landing page.
120+
expect(isCallerScoped(listViews.my_pending)).toBe(true);
121+
expect(isCallerScoped(listViews.submitted_by_me)).toBe(true);
122+
123+
// `viewName` is the key the spec already declares on an object nav item;
124+
// it survives the contribution schema rather than being stripped.
125+
const parsed = NavigationContributionSchema.parse(manifest.navigationContributions[0]);
126+
expect(parsed.items.find((i: any) => i.id === 'nav_approval_requests')).toMatchObject({ viewName: 'all_requests' });
127+
});
83128
});

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,26 @@ export const SysApprovalRequest = ObjectSchema.create({
5858

5959
// Curated built-in list views — render as segmented tabs in the console.
6060
// Filters use {current_user_id} substitution wired by the console.
61+
//
62+
// `all_requests` is declared FIRST, and that position is the contract: when
63+
// a route names no view, the console opens an object's first declared list
64+
// view (objectui `ObjectView`: URL view id, `?view=`, `isDefault`, then
65+
// `views[0]`). A caller-scoped view (`my_pending`, `submitted_by_me`) in
66+
// first place turned Setup → Approvals → Requests, and every bare-object door
67+
// (the record page's object breadcrumb, the object switcher), into a list of
68+
// the caller's own rows. A caller-scoped list view is never an object's
69+
// first; an entry that wants one names it. The order decides which view
70+
// opens, never which rows a caller may read.
6171
listViews: {
72+
all_requests: {
73+
type: 'grid',
74+
name: 'all_requests',
75+
label: 'All',
76+
data: { provider: 'object', object: 'sys_approval_request' },
77+
columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
78+
sort: [{ field: 'updated_at', order: 'desc' }],
79+
pagination: { pageSize: 50 },
80+
},
6281
my_pending: {
6382
type: 'grid',
6483
name: 'my_pending',
@@ -96,15 +115,6 @@ export const SysApprovalRequest = ObjectSchema.create({
96115
sort: [{ field: 'completed_at', order: 'desc' }],
97116
pagination: { pageSize: 25 },
98117
},
99-
all_requests: {
100-
type: 'grid',
101-
name: 'all_requests',
102-
label: 'All',
103-
data: { provider: 'object', object: 'sys_approval_request' },
104-
columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
105-
sort: [{ field: 'updated_at', order: 'desc' }],
106-
pagination: { pageSize: 50 },
107-
},
108118
},
109119

110120
fields: {

‎packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
104104
}
105105
},
106106
_views: {
107+
all_requests: {
108+
label: "All"
109+
},
107110
my_pending: {
108111
label: "My Pending",
109112
emptyState: {
@@ -116,9 +119,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
116119
},
117120
completed: {
118121
label: "Completed"
119-
},
120-
all_requests: {
121-
label: "All"
122122
}
123123
},
124124
_actions: {

‎packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
104104
}
105105
},
106106
_views: {
107+
all_requests: {
108+
label: "Todas"
109+
},
107110
my_pending: {
108111
label: "Aprobaciones pendientes",
109112
emptyState: {
@@ -116,9 +119,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
116119
},
117120
completed: {
118121
label: "Completadas"
119-
},
120-
all_requests: {
121-
label: "Todas"
122122
}
123123
},
124124
_actions: {

‎packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
104104
}
105105
},
106106
_views: {
107+
all_requests: {
108+
label: "すべて"
109+
},
107110
my_pending: {
108111
label: "承認待ち",
109112
emptyState: {
@@ -116,9 +119,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
116119
},
117120
completed: {
118121
label: "完了済み"
119-
},
120-
all_requests: {
121-
label: "すべて"
122122
}
123123
},
124124
_actions: {

‎packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
104104
}
105105
},
106106
_views: {
107+
all_requests: {
108+
label: "全部"
109+
},
107110
my_pending: {
108111
label: "待我审批",
109112
emptyState: {
@@ -116,9 +119,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
116119
},
117120
completed: {
118121
label: "已完成"
119-
},
120-
all_requests: {
121-
label: "全部"
122122
}
123123
},
124124
_actions: {

‎packages/qa/dogfood/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@
4141
"@objectstack/verify": "workspace:*"
4242
},
4343
"devDependencies": {
44+
"@objectstack/account": "workspace:*",
4445
"@objectstack/cli": "workspace:*",
4546
"@objectstack/cloud-connection": "workspace:*",
4647
"@objectstack/core": "workspace:*",
4748
"@objectstack/driver-sql": "workspace:*",
4849
"@objectstack/driver-sqlite-wasm": "workspace:*",
4950
"@objectstack/driver-turso": "workspace:*",
51+
"@objectstack/setup": "workspace:*",
5052
"@objectstack/trigger-api": "workspace:*",
5153
"@types/node": "^26.6.3",
5254
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)