Skip to content

Commit 085c4dd

Browse files
committed
fix(platform-objects): drop the ai:approvals entry — it is the AI capability's to contribute (ADR-0029 D7)
Restores platform-objects.test.ts's K2.b slot pin and the Connect-an-Agent Setup tree count to main, removes nav_ai_approvals with its label leaves and source hashes, and narrows the new pins to the two entries this repo ships. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 874c419 commit 085c4dd

14 files changed

Lines changed: 82 additions & 269 deletions

‎.changeset/20142-console-page-nav-entries.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
'@objectstack/plugin-audit': patch
44
---
55

6-
fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log, AI Approvals and Integrations & APIs pages (#20142)
6+
fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log and Integrations & APIs pages (#20142)
77

8-
The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to three pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named these three, so each page was reachable only by a typed URL. Three entries now name them:
8+
The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to several pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named them, so each page was reachable only by a typed URL. Two entries now name the pages whose capability ships in the open framework:
99

1010
| Entry | App / group | `componentRef` | Contributed by | Gate |
1111
| --- | --- | --- | --- | --- |
1212
| `nav_audit_log_browser` ("Audit Log Browser") | Setup / Diagnostics, directly under Audit Logs | `audit:log` | `@objectstack/plugin-audit` | none: it lives and dies with the plugin that owns `sys_audit_log` |
13-
| `nav_ai_approvals` ("AI Approvals") | Setup / Approvals, after the approvals engine's entries | `ai:approvals` | `@objectstack/platform-objects` | `requiresService: 'ai'` |
1413
| `nav_integrations` ("Integrations & APIs") | Studio / Developer, after Public Forms | `developer:integrations` | `@objectstack/platform-objects` | none beyond Studio's own `studio.access` |
1514

1615
**Two audit entries, on purpose.** The existing Audit Logs entry (the `sys_audit_log` object view) stays. It carries the named list views, search, and the actor and tenant rendered as resolved lookups. The new page adds one filterable table whose detail drawer pretty-prints a change's before and after JSON, where the record page shows `old_value` / `new_value` as raw text. Neither surface replaces the other.
1716

18-
**The `ai` gate.** The `ai` service has no implementation in the open framework (it is provided by `@objectstack/service-ai` in Cloud/Enterprise), and the AI Approvals page reads only that service. So on a Community Edition runtime the entry is removed server-side, before the app metadata reaches the browser, and it appears once an `ai` service is registered.
17+
**No entry for the console's AI Approvals page (`ai:approvals`) here.** Under ADR-0029 D7, each capability plugin contributes its own navigation entries into a Setup slot, and the Setup shell does not enumerate capability objects. The AI pending-action queue belongs to the AI capability, whose provider (`@objectstack/service-ai`) ships in Cloud/Enterprise, not in the open framework. Its entry is therefore that capability's to contribute.
1918

2019
The keys are the ones the console registers at the objectui commit this release's console is built from. Labels ship in all four locales (en, zh-CN, ja-JP, es-ES), with their source hashes recorded. Nothing is removed or renamed, and there is nothing to migrate.

‎packages/cli/test/connect-agent-both-halves-wire.pin.test.ts‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -252,15 +252,10 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
252252
expect(allowedIds).toContain('nav_connect_agent');
253253
expect(groupChildren(setupAllowed.body?.item, 'group_integrations'))
254254
.toContain('nav_connect_agent');
255-
// The Setup tree the platform admin is served: 9 authored groups, plus
256-
// 26 contributed entries plus the MCP one. One of the 26 is
257-
// `nav_ai_approvals` (#20142), the only entry this composition folds
258-
// into `group_approvals` (plugin-approvals is not part of it). It is
259-
// gated on the `ai` service, and it rides through HERE only because
260-
// this harness has no service probe, so ADR-0057 D10 fails open (see
261-
// the header); its absence without that service is pinned where a
262-
// probe is wired, in `console-page-nav-entries.pin.test.ts`.
263-
expect(allowedIds).toHaveLength(36);
255+
// The Setup tree the platform admin is served: 9 authored groups less
256+
// `group_approvals`, which nothing contributes into and the filter
257+
// collapses, plus 25 contributed entries plus the MCP one.
258+
expect(allowedIds).toHaveLength(34);
264259
});
265260

266261
it('the Account half widened NOTHING on Setup — the admin tree is unchanged', async () => {
@@ -278,7 +273,7 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', (
278273
expect(withAccountHalf.statusCode).toBe(200);
279274
expect(withoutAccountHalf.statusCode).toBe(200);
280275
expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item));
281-
expect(navIds(withAccountHalf.body?.item)).toHaveLength(36);
276+
expect(navIds(withAccountHalf.body?.item)).toHaveLength(34);
282277

283278
// The mechanism behind the invariance, asserted rather than assumed:
284279
// the bundle aims exactly one contribution at `setup` and one at
Lines changed: 45 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,62 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// #20142 — the three console pages that lost their only in-app link when
4-
// objectui#10520 retired the System Hub card wall and the Developer Hub, read
5-
// back OVER THE WIRE from the composed Setup and Studio apps.
3+
// #20142 — the Integrations & APIs page, one of the console pages that lost
4+
// their only in-app link when objectui#10520 retired the Developer Hub, read
5+
// back OVER THE WIRE from the composed Studio app.
66
//
77
// ---------------------------------------------------------------------------
88
// What this pins
99
// ---------------------------------------------------------------------------
10-
// The card's acceptance, in its own words: "each entry resolves to its
11-
// registered page (a `componentRef` that is registered), and the
12-
// `ai:approvals` entry is absent when the `ai` service is not." For the two
13-
// entries `@objectstack/platform-objects` declares:
10+
// The card's acceptance: "each entry resolves to its registered page (a
11+
// `componentRef` that is registered)". For the entry
12+
// `@objectstack/platform-objects` declares — `nav_integrations` →
13+
// `developer:integrations`, last in Studio's `group_developer` — this file
14+
// reads it from the served body of `GET /api/v1/meta/:type/:name`, after the
15+
// real `SchemaRegistry` registration and the real per-request filter, so a
16+
// regression anywhere between the declaration and the wire turns it red.
1417
//
15-
// 1. `nav_ai_approvals` → `ai:approvals`, in `group_approvals` — ABSENT from
16-
// the served body on a Community Edition composition, PRESENT once an
17-
// `ai` service is registered. Both readings sit in one `it()`: a gated-off
18-
// entry with no lit counterpart proves the gate held exactly as much as
19-
// it proves the fold never reached the entry.
20-
// 2. `nav_integrations` → `developer:integrations`, in Studio's
21-
// `group_developer`.
22-
//
23-
// The third entry, `nav_audit_log_browser` → `audit:log`, carries no gate and
24-
// is contributed by `@objectstack/plugin-audit`; its ref is pinned beside it in
25-
// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. It is not
26-
// folded in here because importing that plugin (or plugin-approvals) from this
27-
// package's tests resolves to its `dist/`, which `check:test-source-alias`
28-
// refuses for a new import — and nothing about an ungated entry needs the
29-
// probe this file exists to wire.
18+
// The other two pages are pinned where they belong:
19+
// - `nav_audit_log_browser` → `audit:log` is contributed by
20+
// `@objectstack/plugin-audit`; its ref is pinned beside it in
21+
// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`.
22+
// Importing that plugin from this package's tests would resolve to its
23+
// `dist/`, which `check:test-source-alias` refuses for a new import.
24+
// - `ai:approvals` is not this repository's entry: ADR-0029 D7 has each
25+
// capability plugin contribute its own navigation, and the `ai`
26+
// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise.
3027
//
3128
// "Registered" is judged against the keys MEASURED at the commit
3229
// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d):
33-
// `registerSystemComponents.tsx` registers `audit:log` and `ai:approvals`,
34-
// `registerDeveloperComponents.tsx` registers `developer:integrations`.
35-
// objectui pins its registration half in
30+
// `registerDeveloperComponents.tsx` registers `developer:integrations` and
31+
// `registerSystemComponents.tsx` registers `audit:log`. objectui pins its
32+
// registration half in
3633
// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`.
3734
//
38-
// ---------------------------------------------------------------------------
39-
// The Community Edition composition, and why the service gate is WIRED here
40-
// ---------------------------------------------------------------------------
41-
// The sibling `connect-agent-both-halves-wire.pin.test.ts` deliberately leaves
42-
// the ADR-0057 D10 capability probe unwired, so every `requiresService` gate
43-
// there fails OPEN. This file is the opposite case: the probe is the subject.
44-
// `serviceExistsProvider` answers the way the most generous Community Edition
45-
// boot would — every service slot some open-framework package can fill is
46-
// registered — derived from `CORE_SERVICE_PROVIDER`, the discovery table that
47-
// names each core slot's installable provider. It records none for `ai`
48-
// (`@objectstack/service-ai` is Cloud/Enterprise only), so `ai` is the one name
49-
// this composition cannot answer for. The probe also records what it was
50-
// asked, so a gate that never ran cannot pass as a gate that held.
51-
//
52-
// It lives in `packages/cli/test/` for the reason its sibling states: `cli` is
53-
// the one workspace package that depends on every piece at once — the shells
54-
// and contributions (`@objectstack/platform-objects`), the fold
55-
// (`@objectstack/objectql`) and the per-request filter (`@objectstack/rest`).
35+
// It lives in `packages/cli/test/` for the reason its sibling
36+
// `connect-agent-both-halves-wire.pin.test.ts` states: `cli` is the one
37+
// workspace package that depends on every piece at once — the app shells
38+
// (`@objectstack/platform-objects`), the registry (`@objectstack/objectql`) and
39+
// the per-request filter (`@objectstack/rest`).
5640

5741
import { describe, expect, it, vi } from 'vitest';
5842
import { SchemaRegistry } from '@objectstack/objectql';
59-
import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from '@objectstack/platform-objects/apps';
43+
import { STUDIO_APP } from '@objectstack/platform-objects/apps';
6044
import { RestServer } from '@objectstack/rest';
61-
import { CORE_SERVICE_PROVIDER } from '@objectstack/spec/system';
6245

6346
type AnyRec = Record<string, any>;
6447

65-
/** The registry keys the pinned console registers for the three pages. */
66-
const MEASURED_CONSOLE_KEYS = ['audit:log', 'ai:approvals', 'developer:integrations'];
48+
/** The registry keys the pinned console registers for the pages this repo links. */
49+
const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations'];
6750

6851
/**
69-
* The real composition: the Setup and Studio shells and the Setup
70-
* contributions `@objectstack/platform-objects` ships. `structuredClone`
71-
* because `registerItem` writes the `_lock` envelope onto what it is handed
52+
* The real registration of the Studio shell. `structuredClone` because
53+
* `registerItem` writes the `_lock` envelope onto what it is handed
7254
* (ADR-0010 §3.7).
7355
*/
7456
function composedRegistry(): SchemaRegistry {
7557
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
7658
(registry as AnyRec).logLevel = 'silent';
77-
registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects');
7859
registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects');
79-
for (const c of SETUP_NAV_CONTRIBUTIONS) {
80-
registry.registerAppNavContribution(c as any, '@objectstack/platform-objects');
81-
}
8260
return registry;
8361
}
8462

@@ -97,14 +75,9 @@ function makeRes() {
9775
return res;
9876
}
9977

100-
/**
101-
* A `RestServer` serving the composed apps to a platform admin, with the
102-
* capability probe answering from `serviceExists` and recording every name
103-
* it was asked about.
104-
*/
105-
function serve(serviceExists: (name: string) => boolean) {
78+
/** A `RestServer` serving the composed Studio app to a caller holding `studio.access`. */
79+
function serve() {
10680
const registry = composedRegistry();
107-
const probed: string[] = [];
10881
const protocol: AnyRec = {
10982
getDiscovery: vi.fn().mockResolvedValue({
11083
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
@@ -123,13 +96,9 @@ function serve(serviceExists: (name: string) => boolean) {
12396
const rest: AnyRec = new RestServer(
12497
createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
12598
);
126-
rest.resolveExecCtx = async () => ({
127-
userId: 'u1',
128-
systemPermissions: ['setup.access', 'studio.access', 'manage_platform_settings'],
129-
});
130-
rest.serviceExistsProvider = (name: string) => { probed.push(name); return serviceExists(name); };
99+
rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['studio.access'] });
131100
rest.registerRoutes();
132-
return { rest, probed };
101+
return rest;
133102
}
134103

135104
/** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */
@@ -153,45 +122,9 @@ function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined {
153122

154123
const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id));
155124

156-
/** The most generous Community Edition: every slot an open package can fill is filled. */
157-
const communityEdition = (name: string) => CORE_SERVICE_PROVIDER[name] !== null;
158-
159-
describe('#20142 — the console pages\' nav entries, served from the composed apps', () => {
160-
it('ai:approvals is absent on a Community Edition composition and present once `ai` is registered', async () => {
161-
// The premise the CE probe is built on, asserted rather than assumed.
162-
expect(CORE_SERVICE_PROVIDER.ai).toBeNull();
163-
164-
// ── Community Edition: no `ai` service ─────────────────────────────
165-
const ce = serve(communityEdition);
166-
const ceSetup = await getApp(ce.rest, 'setup');
167-
expect(ceSetup.statusCode).toBe(200);
168-
// The gate RAN: the probe was asked about `ai`. Without this, an
169-
// unwired probe (fail-open) and a held gate read the same way below.
170-
expect(ce.probed).toContain('ai');
171-
// Absent from the served tree AND from the wire bytes. `nav_ai_approvals`
172-
// is the only entry this composition folds into `group_approvals`
173-
// (plugin-approvals is not part of it), so stripping it leaves the group
174-
// with nothing to serve.
175-
expect(ids(group(ceSetup.body?.item, 'group_approvals'))).toEqual([]);
176-
expect(JSON.stringify(ceSetup.body)).not.toContain('nav_ai_approvals');
177-
expect(JSON.stringify(ceSetup.body)).not.toContain('ai:approvals');
178-
// The rest of the served Setup tree is untouched by the gate: a
179-
// neighbouring component entry from the same contributor still arrives.
180-
expect(ids(group(ceSetup.body?.item, 'group_apps'))).toContain('nav_packaged_automation');
181-
182-
// ── Cloud / Enterprise: `ai` registered — the lit counterpart ──────
183-
const cloud = serve(() => true);
184-
const cloudSetup = await getApp(cloud.rest, 'setup');
185-
expect(cloudSetup.statusCode).toBe(200);
186-
expect(cloud.probed).toContain('ai');
187-
const approvals = group(cloudSetup.body?.item, 'group_approvals');
188-
expect(ids(approvals)).toEqual(['nav_ai_approvals']);
189-
expect(approvals?.[0]).toMatchObject({ type: 'component', componentRef: 'ai:approvals' });
190-
});
191-
125+
describe('#20142 — the Integrations & APIs nav entry, served from the composed Studio app', () => {
192126
it('developer:integrations is served last in Studio\'s Developer group', async () => {
193-
const { rest } = serve(communityEdition);
194-
const studio = await getApp(rest, 'studio');
127+
const studio = await getApp(serve(), 'studio');
195128
expect(studio.statusCode).toBe(200);
196129
const developer = group(studio.body?.item, 'group_developer');
197130
expect(ids(developer)).toEqual([
@@ -200,16 +133,12 @@ describe('#20142 — the console pages\' nav entries, served from the composed a
200133
expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' });
201134
});
202135

203-
it('every served ref of the two entries is a key the pinned console registers', async () => {
204-
const { rest } = serve(() => true);
205-
const served = [
206-
...((await getApp(rest, 'setup')).body?.item?.navigation ?? []),
207-
...((await getApp(rest, 'studio')).body?.item?.navigation ?? []),
208-
].flatMap((g: AnyRec) => (g?.children ?? []) as AnyRec[]);
209-
const refs = served
210-
.filter((i) => ['nav_ai_approvals', 'nav_integrations'].includes(i?.id))
211-
.map((i) => i.componentRef);
212-
expect(refs).toHaveLength(2);
213-
for (const ref of refs) expect(MEASURED_CONSOLE_KEYS).toContain(ref);
136+
it('the served ref is a key the pinned console registers', async () => {
137+
const studio = await getApp(serve(), 'studio');
138+
const served = ((studio.body?.item?.navigation ?? []) as AnyRec[])
139+
.flatMap((g) => (g?.children ?? []) as AnyRec[])
140+
.filter((i) => i?.id === 'nav_integrations');
141+
expect(served).toHaveLength(1);
142+
expect(MEASURED_CONSOLE_KEYS).toContain(served[0].componentRef);
214143
});
215144
});

0 commit comments

Comments
 (0)