Skip to content

Commit 9a1205f

Browse files
os-zhuangclaude
andauthored
fix(objectql): apply D7 setup-nav contributions in the protocol serving path (#1457)
* docs(adr): add ADR-0028 metadata naming & namespace isolation Propose retiring the hand-written namespace-prefix authoring rule (only objects are protected today; ~23 other metadata kinds collide silently, e.g. connectors last-wins-overwrite) in favor of: - namespace as an identity dimension (short authored names; identity = (namespace, type, name)) - physical table names derived at the storage boundary, invisible to authors/AI (inverting the existing StorageNameMapping pass-through) - namespace as an addressing segment at transport surfaces (data API, metadata API, generated GraphQL/OData/SDK/MCP) - app sandboxing: no cross-app references (security boundary); only app -> kernel references are legal - a single unified reserved kernel namespace (sys) whose contract is unified but whose object ownership is distributed across first-party capability plugins (single-owner-per-object), decomposing the platform-objects monolith Grounded in a codebase scan (current-state findings + kernel cross-reference graph) and mainstream platform practice (Salesforce 2GP / ServiceNow scoped apps / Dataverse). Includes a phased, non-breaking migration plan. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 * docs(adr): fix ADR-0028 broken cross-reference ADR-0004 is "Cloud Control Plane", not the object-namespace-prefix rule (which lives only in manifest.zod.ts / stack.zod.ts, with no standalone ADR). Replace the broken ./0004-object-namespace-prefix.md link with a Supersedes note pointing at the actual source files. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 * docs(adr): breakage-controlled migration plan for ADR-0028 Rework the migration section around three compatibility mechanisms so existing templates are never broken in a flag-day cutover: - per-package `namingMode: 'literal' | 'short'` manifest flag (old and new packages coexist in one instance; migration is opt-in per package) - idempotent, namespace-aware `resolveTableName` (dual-read) so adding derivation does not turn `crm_account` into `crm_crm_account` - sealed artifacts are never force-republished (codemod rewrites source templates only) Each phase (P0 foundations → P5 remove-legacy) now has an explicit exit gate; the only breaking step (P4) is per-package opt-in and driven by an `os migrate namespace` codemod. Kernel refactor (P2) is decoupled and template-transparent. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 * docs(adr): add ADR-0029 kernel object ownership; slim ADR-0028 P2 to reference it Carve the kernel-decomposition concern out of ADR-0028 into its own ADR: first-party capabilities become plugins that own their sys_* objects + behavior (correcting the platform-objects monolith where plugins declare namespace:'sys' but the objects are defined centrally). ADR-0029: - small core (identity/org hub + metadata) vs capability plugins (audit/jobs/email/approvals/sharing/ai/webhooks) that each own their objects - shared reserved `sys` namespace, single-owner-PER-OBJECT (not per-namespace, not a monolith owner) - hub + dependencies/loadOrder instead of centralization - decompose platform-objects behind a re-export facade - template-transparent, independently shippable, sequenced BEFORE the 0028 naming flip; phased K0-K4 with exit gates ADR-0028: Phase 2 now delegates to ADR-0029; header gains it as a prerequisite; D5 points to ADR-0029 as the authoritative source for the ownership mechanics (0028 keeps only the naming/contract decision). https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 * docs(adr): ADR-0029 — add navigation-contribution mechanism for the setup app (D7) Decomposing platform-objects breaks the premise that lets the `setup` admin app be a static monolith (it hard-references every sys_* object; its own comment notes it was made static *because* the objects were centralized, and the runtime-assembling plugin-setup was deleted). manifest.contributes.menus exists but is consumed nowhere, and there is no app-extension analog to objectExtensions. Add D7: setup becomes a base-owned "shell + group slots"; each capability plugin contributes its nav entries via a declarative navigation contribution (the UI analog of objectExtensions), merged by group + priority, each entry gated by the existing requiresObject / requiredPermissions nav fields (which doubles as the disable mechanism). Wire it through the migration plan (K1 builds the shell + mechanism; K2 moves each domain's nav entries out as contributions) and record the schema choice as an open question. https://claude.ai/code/session_01Tv6F1Ub6bhCedrx3r8sZM4 * fix(objectql): apply D7 nav contributions in the protocol serving path The Setup app is a shell of empty group anchors (ADR-0029 D7); menu entries are injected as navigation contributions and merged lazily on read. `SchemaRegistry.getApp` / `getAllApps` did the merge, but the REST app endpoints read through `protocol.getMetaItems` / `getMetaItem`, which returned the raw shell — so every Setup menu group rendered empty. Apply the contribution merge in both protocol read paths (list + single) for the `app`/`apps` types, mirroring `getApp`/`getAllApps`. The stored app is never mutated (structuredClone), so reads stay idempotent. `SchemaRegistry.applyNavContributions` is promoted from private to public so the protocol can reach the same merge logic. Adds regression tests covering both `getMetaItems({type:'app'})` and `getMetaItem({type:'app'})`. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a45780 commit 9a1205f

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

packages/objectql/src/protocol-meta.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,59 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
791791
});
792792
});
793793

794+
// ═══════════════════════════════════════════════════════════════
795+
// ADR-0029 D7 — navigation contributions reach the serving path
796+
//
797+
// Regression: the setup app is a shell of empty group anchors; menu
798+
// entries are injected as navigation contributions and merged lazily on
799+
// read. `registry.getApp` / `getAllApps` did the merge, but the REST app
800+
// endpoints read through `protocol.getMetaItems` / `getMetaItem`, which
801+
// returned the raw shell — leaving every Setup menu group empty.
802+
// ═══════════════════════════════════════════════════════════════
803+
804+
describe('app navigation contributions (ADR-0029 D7)', () => {
805+
const shellApp = {
806+
name: 'setup',
807+
label: 'Setup',
808+
navigation: [
809+
{ id: 'group_diagnostics', type: 'group', label: 'Diagnostics', children: [] },
810+
],
811+
};
812+
813+
const contribution = {
814+
app: 'setup',
815+
group: 'group_diagnostics',
816+
priority: 100,
817+
items: [{ id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log' }],
818+
};
819+
820+
it('getMetaItems({type:"app"}) merges contributions into the served app', async () => {
821+
registry.registerItem('app', shellApp, 'name');
822+
registry.registerAppNavContribution(contribution, 'platform-objects');
823+
824+
const result = await protocol.getMetaItems({ type: 'app' });
825+
826+
const setup = (result.items as any[]).find((a) => a.name === 'setup');
827+
expect(setup).toBeDefined();
828+
const group = setup.navigation.find((g: any) => g.id === 'group_diagnostics');
829+
expect(group.children).toHaveLength(1);
830+
expect(group.children[0].id).toBe('nav_audit_logs');
831+
// The stored shell is never mutated — repeated reads stay idempotent.
832+
expect((registry.getItem('app', 'setup') as any).navigation[0].children).toHaveLength(0);
833+
});
834+
835+
it('getMetaItem({type:"app"}) merges contributions for a single-app fetch', async () => {
836+
registry.registerItem('app', shellApp, 'name');
837+
registry.registerAppNavContribution(contribution, 'platform-objects');
838+
839+
const result = await protocol.getMetaItem({ type: 'app', name: 'setup' });
840+
841+
const group = (result.item as any).navigation.find((g: any) => g.id === 'group_diagnostics');
842+
expect(group.children).toHaveLength(1);
843+
expect(group.children[0].id).toBe('nav_audit_logs');
844+
});
845+
});
846+
794847
// ═══════════════════════════════════════════════════════════════
795848
// loadMetaFromDb — startup hydration
796849
// ═══════════════════════════════════════════════════════════════

packages/objectql/src/protocol.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
12811281
items = (items as any[]).filter((it) => !isAggregatedViewContainer(it));
12821282
}
12831283

1284+
// Merge registered navigation contributions into each served app
1285+
// (ADR-0029 D7). The setup app is a shell of empty group anchors;
1286+
// platform-objects and capability plugins inject their menu entries as
1287+
// contributions, merged lazily on read. REST app endpoints read through
1288+
// this path (not registry.getAllApps), so the merge must happen here too
1289+
// or every contributed group renders empty.
1290+
if (request.type === 'app' || request.type === 'apps') {
1291+
items = (items as any[]).map((app) => this.engine.registry.applyNavContributions(app));
1292+
}
1293+
12841294
return {
12851295
type: request.type,
12861296
items: decorateMetadataItems(
@@ -1417,6 +1427,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
14171427
}
14181428
}
14191429

1430+
// Merge registered navigation contributions into a served app
1431+
// (ADR-0029 D7) — parity with the getMetaItems list path so a
1432+
// single-app fetch (GET /meta/app/<name>) also sees the contributed
1433+
// menu entries, not just the empty group-anchor shell.
1434+
if ((request.type === 'app' || request.type === 'apps') && item) {
1435+
item = this.engine.registry.applyNavContributions(item);
1436+
}
1437+
14201438
// ADR-0010 §3.3 — artifact-level protection (lock/packageId) always
14211439
// wins over any overlay row. The metadata service may return a
14221440
// persisted overlay copy that pre-dates the artifact's `_lock`

packages/objectql/src/registry.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,8 +1012,13 @@ export class SchemaRegistry {
10121012
* Return a copy of `app` with all registered navigation contributions
10131013
* merged into its `navigation` tree. The stored app is never mutated, so
10141014
* repeated reads stay idempotent.
1015+
*
1016+
* Public so the protocol serving path (`getMetaItems` / `getMetaItem` for
1017+
* `app`) can merge contributions the same way `getApp` / `getAllApps` do —
1018+
* the REST app endpoints read through the protocol, not these helpers, so
1019+
* the merge must be reachable from there too (ADR-0029 D7).
10151020
*/
1016-
private applyNavContributions(app: any): any {
1021+
applyNavContributions(app: any): any {
10171022
const contributions = this.appNavContributions.get(app?.name);
10181023
if (!contributions || contributions.length === 0) return app;
10191024

0 commit comments

Comments
 (0)