From 8b347c026b98f779490c37949c5415b7182a75ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:09:35 +0000 Subject: [PATCH 1/4] test(runtime): pin the nav kind of the customizations contributions, red on main Co-Authored-By: Claude --- .../domains/packages-customizations.test.ts | 141 ++++++++++++++++-- 1 file changed, 131 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/domains/packages-customizations.test.ts b/packages/runtime/src/domains/packages-customizations.test.ts index e342fee301e..24a7cc2fc70 100644 --- a/packages/runtime/src/domains/packages-customizations.test.ts +++ b/packages/runtime/src/domains/packages-customizations.test.ts @@ -191,6 +191,17 @@ function baseManifest(): Record { columns: ['title', 'status', 'due_date'].map((field) => ({ field })), }, }], + // [#28722] The base's app, with the group other packages contribute into. + apps: [{ + name: 'showcase', + label: 'Showcase', + navigation: [{ + id: 'showcase_work', + type: 'group', + label: 'Work', + children: [{ id: 'showcase_tasks', type: 'object', label: 'Tasks', objectName: 'showcase_task' }], + }], + }], }; } @@ -206,6 +217,11 @@ function otherManifest(): Record { sharingModel: 'private', fields: { body: { name: 'body', label: 'Body', type: 'text' } }, }], + apps: [{ + name: 'other_app', + label: 'Other', + navigation: [{ id: 'other_notes', type: 'object', label: 'Notes', objectName: 'other_note' }], + }], }; } @@ -507,8 +523,23 @@ const flowOn = (name: string, object: string) => ({ edges: [{ id: 'e1', source: 'start', target: 'end' }], }); -/** A code package registered as the engine registers every manifest: an object extension and flows. */ -const registerContributor = (engine: ObjectQL, id: string, fields: readonly string[], flows: ReadonlyArray>) => +/** One `navigationContributions` entry of a manifest (ADR-0029 D7). */ +type NavContribution = { app: string; group?: string; priority?: number; items: Array> }; + +/** A url nav item: the smallest item that carries an id. */ +const navLink = (id: string) => ({ id, type: 'url', label: id, url: `/${id}` }); + +/** + * A code package registered as the engine registers every manifest: an object + * extension, flows, and the navigation it contributes to other packages' apps. + */ +const registerContributor = ( + engine: ObjectQL, + id: string, + fields: readonly string[], + flows: ReadonlyArray>, + navigationContributions: readonly NavContribution[] = [], +) => engine.registerApp({ id, name: id.split('.').join('-'), @@ -518,13 +549,16 @@ const registerContributor = (engine: ObjectQL, id: string, fields: readonly stri fields: Object.fromEntries(fields.map((f) => [f, { name: f, label: f, type: 'text' }])), }], flows: [...flows], + navigationContributions: [...navigationContributions], }); /** - * The card's fixture (#28722): the base ships a flow on its object, the pair - * extends that object and carries a flow on it, and a third code package - * extends the same object with a field and ships a flow on it — plus a flow - * on an object the base does not own. + * The card's fixture (#28722): the base ships a flow on its object and an app, + * the pair extends that object, carries a flow on it and contributes a nav + * item to the base's app, and a third code package extends the same object + * with a field, ships a flow on it and contributes a nav item into the base + * app's group — plus a flow on an object the base does not own. The base + * contributing to its own app is the base's item, not a contribution. */ async function contributedFixture() { const booted = await boot(); @@ -533,12 +567,20 @@ async function contributedFixture() { await install(protocol, PAIR); extend(engine, PAIR, 'showcase_task', { fields: { region__c: { name: 'region__c', label: 'Region', type: 'text' } } }); engine.registry.registerItem('flow', flowOn('showcase_task_region_notify', 'showcase_task'), 'name', PAIR); - registerContributor(engine, THIRD, ['acme_sync_id'], [flowOn('acme_task_sync', 'showcase_task'), flowOn('acme_note_sync', 'other_note')]); + engine.registry.registerAppNavContribution({ app: 'showcase', items: [navLink('showcase_region_board')] }, PAIR); + engine.registry.registerAppNavContribution({ app: 'showcase', group: 'showcase_work', items: [navLink('showcase_inbox')] }, BASE); + registerContributor( + engine, + THIRD, + ['acme_sync_id'], + [flowOn('acme_task_sync', 'showcase_task'), flowOn('acme_note_sync', 'other_note')], + [{ app: 'showcase', group: 'showcase_work', items: [navLink('acme_sync_log')] }], + ); return booted; } describe('[#28722] GET /packages/:id/customizations — the fields and flows other packages contribute to a base', () => { - it('names the third package for its extension field and its flow, and names neither the base nor the pair', async () => { + it('names the third package for its extension field, its flow and its nav item, and names neither the base nor the pair', async () => { const { dispatcher } = await contributedFixture(); const r = await customizations(dispatcher, BASE); expect(r.response?.status).toBe(200); @@ -546,8 +588,9 @@ describe('[#28722] GET /packages/:id/customizations — the fields and flows oth expect(data.contributions).toEqual([ { kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD }, { kind: 'flow', object: 'showcase_task', name: 'acme_task_sync', packageId: THIRD }, + { kind: 'nav', app: 'showcase', group: 'showcase_work', name: 'acme_sync_log', packageId: THIRD }, ]); - expect(data.counts.contributions).toBe(2); + expect(data.counts.contributions).toBe(3); // The pair's member stays where N-15 serves it, and is not a contribution. expect(data.extensions).toEqual([{ object: 'showcase_task', kind: 'field', name: 'region__c' }]); const parsed = GetPackageCustomizationsResponseSchema.safeParse(data); @@ -571,7 +614,7 @@ describe('[#28722] GET /packages/:id/customizations — the fields and flows oth engine.registry.registerItem('flow', { ...flowOn('showcase_task_local', 'showcase_task'), _packageId: 'sys_metadata' } as any); engine.registry.registerItem('flow', { ...flowOn('showcase_task_org', 'showcase_task'), _packageId: THIRD, _provenance: 'org' } as any); const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data; - expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']); + expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync', 'nav:acme_sync_log']); }); it('reads a field\'s stamp and a flow\'s stamp the same way: each stamp lists both kinds or neither', async () => { @@ -608,3 +651,81 @@ describe('[#28722] GET /packages/:id/customizations — the fields and flows oth expect(data.counts.contributions).toBe(1); }); }); + +/** Every nav item id of a folded navigation tree, depth first. */ +const navIds = (items: unknown): string[] => (Array.isArray(items) ? items : []).flatMap((item: any) => [ + ...(typeof item?.id === 'string' ? [item.id] : []), + ...navIds(item?.children), +]); + +describe('[#28722] GET /packages/:id/customizations — the navigation items other packages contribute to a base\'s app', () => { + /** The `nav` entries of the base's answer. */ + const navOf = async (dispatcher: HttpDispatcher, id = BASE) => + ((await customizations(dispatcher, id)).response?.body?.data.contributions as any[]).filter((c) => c.kind === 'nav'); + + it('lists one entry per item a contribution declares with an id, naming its group, the no-group entries first', async () => { + const { dispatcher, engine } = await contributedFixture(); + registerContributor(engine, FOURTH, [], [], [ + // A top-level contribution: a link, a separator with no id (no + // `name` to give, so not an entry), and a group whose child rides + // inside it and is not an entry of its own. + { app: 'showcase', items: [ + navLink('partner_home'), + { type: 'separator' }, + { id: 'partner_tools', type: 'group', label: 'Tools', children: [navLink('partner_tool_a')] }, + ] }, + // A group the app does not declare: still named, though the fold + // appends the item at the app's top level. + { app: 'showcase', group: 'showcase_missing', items: [navLink('partner_help')] }, + ]); + expect(await navOf(dispatcher)).toEqual([ + { kind: 'nav', app: 'showcase', name: 'partner_home', packageId: FOURTH }, + { kind: 'nav', app: 'showcase', name: 'partner_tools', packageId: FOURTH }, + { kind: 'nav', app: 'showcase', group: 'showcase_missing', name: 'partner_help', packageId: FOURTH }, + { kind: 'nav', app: 'showcase', group: 'showcase_work', name: 'acme_sync_log', packageId: THIRD }, + ]); + const { data } = (await customizations(dispatcher, BASE)).response?.body ?? {}; + expect(data.contributions.slice(0, 2).map((c: any) => c.kind), 'the field and flow entries come first').toEqual(['field', 'flow']); + const parsed = GetPackageCustomizationsResponseSchema.safeParse(data); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('a disabled package\'s contributions are not listed, as the fold drops them, and re-enabling restores them', async () => { + const { dispatcher, engine } = await contributedFixture(); + registerContributor(engine, FOURTH, [], [], [{ app: 'showcase', items: [navLink('partner_home')] }]); + // AGREEMENT with the fold: the listed names are the served app's ids + // that neither the base nor its pair put there. + const ownIds = new Set(['showcase_work', 'showcase_tasks', 'showcase_inbox', 'showcase_region_board']); + const servedContributed = () => navIds(engine.registry.getApp('showcase')?.navigation).filter((id) => !ownIds.has(id)).sort(); + const listed = async () => (await navOf(dispatcher)).map((c) => c.name).sort(); + + expect(await listed()).toEqual(['acme_sync_log', 'partner_home']); + expect(servedContributed()).toEqual(await listed()); + + expect(engine.registry.disablePackage(FOURTH), 'precondition: disabled').toBeTruthy(); + expect(await listed()).toEqual(['acme_sync_log']); + expect(servedContributed()).toEqual(await listed()); + + expect(engine.registry.enablePackage(FOURTH), 'precondition: enabled').toBeTruthy(); + expect(await listed()).toEqual(['acme_sync_log', 'partner_home']); + }); + + it('a contribution to an app the base does not own is not the base\'s: it is the owning base\'s, or nobody\'s', async () => { + const { dispatcher, engine } = await contributedFixture(); + registerContributor(engine, FOURTH, [], [], [ + { app: 'other_app', items: [navLink('partner_notes_report')] }, + { app: 'ghost_app', items: [navLink('partner_ghost')] }, + ]); + expect((await navOf(dispatcher)).map((c) => c.name)).toEqual(['acme_sync_log']); + expect(await navOf(dispatcher, OTHER)).toEqual([ + { kind: 'nav', app: 'other_app', name: 'partner_notes_report', packageId: FOURTH }, + ]); + }); + + it('a contribution with no code package behind it is the environment\'s, not a package\'s', async () => { + const { dispatcher, engine } = await contributedFixture(); + engine.registry.registerAppNavContribution({ app: 'showcase', items: [navLink('unstamped_link')] }); + engine.registry.registerAppNavContribution({ app: 'showcase', items: [navLink('sentinel_link')] }, 'sys_metadata'); + expect((await navOf(dispatcher)).map((c) => c.name)).toEqual(['acme_sync_log']); + }); +}); From 6629372302450da0b2ad2569a944bff67c8ed7d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:13:20 +0000 Subject: [PATCH 2/4] feat(runtime): serve the nav contributions of the customizations read readBaseContributions reads the navigation-contribution registry the served app's fold reads, for each app registered under the base: one entry per item with an id, the contribution's group when it names one, the disabled-package skip the fold applies, and the base and pair excluded through the shared stamp predicate. The spec nav arm drops its planned marker; docs regenerated. Co-Authored-By: Claude --- ...-customizations-serve-nav-contributions.md | 19 +++++ content/docs/api/metadata-api.mdx | 5 +- .../docs/references/api/package-lifecycle.mdx | 15 ++-- packages/runtime/src/domains/packages.ts | 70 +++++++++++++++---- .../spec/src/api/package-lifecycle.test.ts | 18 ++--- .../spec/src/api/package-lifecycle.zod.ts | 37 ++++------ 6 files changed, 107 insertions(+), 57 deletions(-) create mode 100644 .changeset/28722-customizations-serve-nav-contributions.md diff --git a/.changeset/28722-customizations-serve-nav-contributions.md b/.changeset/28722-customizations-serve-nav-contributions.md new file mode 100644 index 00000000000..9c674870756 --- /dev/null +++ b/.changeset/28722-customizations-serve-nav-contributions.md @@ -0,0 +1,19 @@ +--- +'@objectstack/runtime': patch +'@objectstack/spec': patch +--- + +feat(runtime): the customizations read serves the `nav` contributions: the navigation items other packages add to a base's apps + +Clause-②: no +Metadata-protocol: none + +`GET /packages/:id/customizations` now lists, in `contributions`, the navigation items other installed packages add to the base's apps, each naming the package that adds them. Studio can draw those menu entries greyed with their source, as it already can for contributed fields and flows (ADR-0029 D7 as amended by ADR-0146 D3). + +- **One entry per item.** Every item of a package's `navigationContributions` entry that declares an `id`, contributed to an app the base ships, is a `nav` entry: `{ kind: 'nav', app, group?, name, packageId }`. `group` is the group the contribution targets, when it targets one. A separator without an `id` is not an entry, and a contributed group's children ride inside it. +- **The same answer the menu shows.** The entries come from the registry the served app's navigation fold reads, never a join on names. A disabled package's items are not listed, as the fold drops them, and they come back when it is enabled again. The base's own items and its `.custom` pair's are not contributions, and no nav item gains a key. +- **Order and counts.** The `field` and `flow` entries come first as before, then the `nav` entries by app, group (no group first), name and package. `counts.contributions` counts them all. + +The spec's `nav` arm drops its `[PLANNED — not yet served]` marker, and the member's descriptions no longer say the kind is unserved. The schema accepts exactly what it accepted before. + +Nothing to do for existing metadata. A client that read `contributions` and assumed only `field` and `flow` entries now also receives `nav` entries, which the declared schema already allowed. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index bfe0ffc477f..219051e0db2 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -110,9 +110,8 @@ never overridden; it is copied under a new name. What the reads then serve is in `POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides, extensions and switched-off items in one read, beside `contributions`: the fields (kind `field`) -and flows (kind `flow`) other installed packages contribute to `hotcrm`'s objects, each entry -naming its package. Its `nav` kind (a navigation item another package contributes to a -`hotcrm` app) is declared and not yet served. +and flows (kind `flow`) other installed packages contribute to `hotcrm`'s objects, and the +navigation items (kind `nav`) they contribute to `hotcrm`'s apps, each entry naming its package. **Body**: `{ type: "object", name: "account", item: { ... } }` **Response**: `{ success: true }` diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index af6ac1be5ab..772da1b39b1 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -43,12 +43,11 @@ declared a VERSION rollback against the live COMMIT-rollback path; `RollbackToPackageCommitResponseSchema` below is the true contract, authored after that retirement per the ruling's sequencing (3A). -Two members are not transcriptions but DECLARED AHEAD OF THEIR PRODUCER -(ADR-0049), each describe opening `[PLANNED — not yet served]` until the -change that serves it drops the marker: the `nav` arm of -`GetPackageCustomizationsResponseSchema.contributions` (#27928) and +One member is not a transcription but DECLARED AHEAD OF ITS PRODUCER +(ADR-0049), its describe opening `[PLANNED — not yet served]` until the +change that serves it drops the marker: `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by -#29342). Their own docblocks say what is not served yet. +#29342). Its own docblock says what is not served yet. **Source:** `packages/spec/src/api/package-lifecycle.zod.ts` @@ -170,7 +169,7 @@ Per-package customization read request: the base package id, no query parameters ## GetPackageCustomizationsResponse -Per-package customization read: overrides, extensions and switched-off items of one installed base, and the fields, flows and (not yet served) navigation items other packages contribute to it. +Per-package customization read: overrides, extensions and switched-off items of one installed base, and the fields, flows and navigation items other packages contribute to it. ### Properties @@ -180,7 +179,7 @@ Per-package customization read: overrides, extensions and switched-off items of | **overrides** | `{ type: string; name: string; member?: string; basedOn?: string; … }[]` | ✅ | Every ACTIVE item of the base's paired `.custom` package that declares `overrides.package` = the base and that the effective read serves over the base's item of the same name: the rows the served-override predicate admits (a declaration from any other package, and an undeclared pair item, are not listed), minus the stored rows every read declines (a shipped flow name). Ordered by type, name and member. | | **extensions** | `{ object: string; kind: Enum<'field' \| 'validation' \| 'index'>; name?: string; fields?: string[] }[]` | ✅ | Every field, validation rule and index the paired `.custom` package declares in an object extension (`objectExtensions`) of an object the base owns, one entry per member, as the extension declares it. Ordered by object, kind and name. | | **switchedOff** | `{ type: string; name: string }[]` | ✅ | Every activation-ledger row (`sys_metadata_activation`) that records the base as its package and `active: false`: the base's items this deployment switched off. Ordered by type and name. | -| **contributions** | `({ kind: Enum<'field' \| 'flow'>; object: string; name: string; packageId: string } \| { kind: 'nav'; app: string; group?: string; name: string; … })[]` | ✅ | Every field, flow and navigation item another installed package contributes to the base, one entry per item and contributing package: the fields of every object extension of an object the base owns, from any package but the base and its pair; every flow registered under such a package whose trigger object the base owns (a flow of a disabled package is not served, so not listed); and, declared ahead of its producer and not yet served, every navigation item such a package contributes to an app the base owns. The entry shape is per `kind`: `object` for `field` and `flow`, `app` and an optional `group` for `nav`. Ordered: the `field` and `flow` entries first, by object, kind, name and package; then the `nav` entries, by app, group (an entry with no group first), name and package. | +| **contributions** | `({ kind: Enum<'field' \| 'flow'>; object: string; name: string; packageId: string } \| { kind: 'nav'; app: string; group?: string; name: string; … })[]` | ✅ | Every field, flow and navigation item another installed package contributes to the base, one entry per item and contributing package: the fields of every object extension of an object the base owns, from any package but the base and its pair; every flow registered under such a package whose trigger object the base owns (a flow of a disabled package is not served, so not listed); and every navigation item such a package contributes to an app the base owns (a disabled package's are not listed). The entry shape is per `kind`: `object` for `field` and `flow`, `app` and an optional `group` for `nav`. Ordered: the `field` and `flow` entries first, by object, kind, name and package; then the `nav` entries, by app, group (an entry with no group first), name and package. | | **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer; contributions: integer }` | ✅ | Each list's length, derived from the lists in this same answer. | ### Nested Shape: `GetPackageCustomizationsResponse.overrides[number]` @@ -222,7 +221,7 @@ A field or flow another package contributes to one of the base's objects. ### Nested Shape: `GetPackageCustomizationsResponse.contributions[number][option 2]` -[PLANNED — not yet served] Declared ahead of its producer (ADR-0049): the read lists no `nav` entry yet. A navigation item another installed package contributes to an app the base owns, one entry per item of the contribution's `items` (a contributed group's children ride inside it and are not entries of their own), read from the same contribution registry the served app's navigation fold reads (ADR-0029 D7). An item that declares no `id` (a separator may omit it) has no `name` to give and is not an entry. A disabled package's contributions are not listed, as the fold drops them. +A navigation item another installed package contributes to an app the base owns, one entry per item of the contribution's `items` (a contributed group's children ride inside it and are not entries of their own), read from the same contribution registry the served app's navigation fold reads (ADR-0029 D7). An item that declares no `id` (a separator may omit it) has no `name` to give and is not an entry. A disabled package's contributions are not listed, as the fold drops them. | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index fac0bdc3bad..21dfef8a101 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3251,7 +3251,8 @@ async function readPackageCustomizations( * [#28722, ADR-0029 D7 as amended by ADR-0146 D3] The `contributions` of * `GET /packages/:id/customizations`: every field and flow a package other than * the base `baseId` and its pair `pairId` contributes to an object the base - * OWNS (`getObjectOwner`), each entry naming that package. Read from the two + * OWNS (`getObjectOwner`), and every navigation item such a package contributes + * to an app the base owns, each entry naming that package. Read from the three * registries that already record the contributor, never inferred: * * - **field** — the object registry's `extend` contributors of the object, as @@ -3264,27 +3265,40 @@ async function readPackageCustomizations( * already leaves a disabled package's out), whose `start` node's * `config.objectName` is a base-owned object, attributed to the package * stamp the registration put on it (its `_packageId`). - * - * Both kinds ask ONE question of their stamp, {@link codePackageOf}: is it a + * - **nav** — [#27928, ruling 6566915346 letter E] the navigation-contribution + * registry (`getAppNavContributions`), the one the served app's fold + * (`applyNavContributions`) reads, for every app registered under the base + * (`listItems('app', baseId)`, which already leaves a disabled base's app + * out: nothing is served to contribute to). One entry per item of a + * contribution's `items` that declares an `id`, naming the contribution's + * `group` when it targets one; a contributed group's children ride inside + * it, as the fold folds them. A disabled package's contributions are + * skipped by the fold's own predicate (`isPackageDisabled`, #28117), so the + * list and the served navigation agree. No key is read from or written to + * any nav item. + * + * Every kind asks ONE question of its stamp, {@link codePackageOf}: is it a * code package's ({@link isCodeArtifactBody}: not the package-less * `sys_metadata` sentinel, not a tenant-authored body), and neither the base * nor its pair? Only where the stamp is read differs: the contributor's - * `packageId` beside its `definition`, or the flow's own `_packageId`. An item - * authored in this environment is the environment's, not a package's, and is - * not listed, whichever kind it is. + * `packageId` beside its `definition`, the flow's own `_packageId`, or the + * nav contribution's `packageId`. An item authored in this environment is the + * environment's, not a package's, and is not listed, whichever kind it is. * - * Ordered by object, kind, name and package. + * Ordered as the member declares: the `field` and `flow` entries first, by + * object, kind, name and package; then the `nav` entries, by app, group (an + * entry with no group first), name and package. */ function readBaseContributions( baseId: string, pairId: string | undefined, registry: any, ): GetPackageCustomizationsResponse['contributions'] { - // The `field` and `flow` entries: the `nav` kind is declared in the spec - // ahead of this producer (#27928) and is not listed here yet (#28722). - type Contribution = Exclude; - const contributions: Contribution[] = []; - if (typeof registry?.getAllObjects !== 'function') return contributions; + type Contribution = GetPackageCustomizationsResponse['contributions'][number]; + type ObjectContribution = Exclude; + type NavContribution = Extract; + const contributions: ObjectContribution[] = []; + const navs: NavContribution[] = []; // The one stamp reading both kinds share: the package `packageId` names // when `body` is a code package's item and the package is neither the base // nor its pair; `undefined` otherwise. @@ -3295,7 +3309,7 @@ function readBaseContributions( }; const owned = new Set(); - for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) { + for (const object of (typeof registry?.getAllObjects === 'function' ? registry.getAllObjects(baseId) : []) as Array<{ name?: unknown }>) { const name = object?.name; if (typeof name === 'string' && registry.getObjectOwner(name)?.packageId === baseId) owned.add(name); } @@ -3324,11 +3338,39 @@ function readBaseContributions( } } + if (typeof registry?.listItems === 'function' && typeof registry.getAppNavContributions === 'function') { + const apps = new Set(); + for (const app of registry.listItems('app', baseId) as Array<{ name?: unknown }>) { + if (typeof app?.name === 'string') apps.add(app.name); + } + for (const app of apps) { + for (const contribution of registry.getAppNavContributions(app) as Array<{ packageId?: unknown; group?: unknown; items?: unknown }>) { + // [#28117] The fold's own predicate, asked per read as the fold asks it. + if (typeof registry.isPackageDisabled === 'function' && registry.isPackageDisabled(contribution.packageId)) continue; + const packageId = codePackageOf(contribution.packageId, undefined); + if (packageId === undefined) continue; + // The fold targets a group only when one is named (`if (c.group)`). + const group = typeof contribution.group === 'string' && contribution.group !== '' ? contribution.group : undefined; + for (const item of Array.isArray(contribution.items) ? contribution.items : []) { + const id = (item as { id?: unknown } | null | undefined)?.id; + if (typeof id !== 'string' || id === '') continue; + navs.push({ kind: 'nav', app, ...(group !== undefined ? { group } : {}), name: id, packageId }); + } + } + } + } + const kindOrder = { field: 0, flow: 1 } as const; - return contributions.sort((a, b) => a.object.localeCompare(b.object) + contributions.sort((a, b) => a.object.localeCompare(b.object) || kindOrder[a.kind] - kindOrder[b.kind] || a.name.localeCompare(b.name) || a.packageId.localeCompare(b.packageId)); + navs.sort((a, b) => a.app.localeCompare(b.app) + || Number(a.group !== undefined) - Number(b.group !== undefined) + || (a.group ?? '').localeCompare(b.group ?? '') + || a.name.localeCompare(b.name) + || a.packageId.localeCompare(b.packageId)); + return [...contributions, ...navs]; } /** diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index a92cf59e852..2bf8b153ebc 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -576,27 +576,27 @@ describe('GetPackageCustomizationsResponseSchema.contributions: the entry shape }); }); -describe('the `nav` kind of `contributions` is declared ahead of its producer (ADR-0049)', () => { +describe('the `nav` kind of `contributions` is served: no planned marker is left (ADR-0049)', () => { type Described = { description?: string }; const member = (GetPackageCustomizationsResponseSchema as unknown as { shape: { contributions: Described & { element: { options: Described[] } } }; }).shape.contributions; const [objectArm, navArm] = member.element.options; - it('the nav arm\'s describe opens with the planned marker and names the ADR, never a tracker id', () => { - expect(navArm.description).toMatch(/^\[PLANNED — not yet served\] /); - expect(navArm.description).toContain('ADR-0049'); + it('the nav arm\'s describe carries no planned marker and names no tracker id', () => { + expect(navArm.description).not.toMatch(/PLANNED|not yet served/); expect(navArm.description).not.toMatch(/#\d{3,}/); }); - it('the member and the field / flow arm say the nav kind is not yet served, and the field / flow arm carries no marker', () => { - expect(member.description).toContain('not yet served'); - expect(objectArm.description).not.toMatch(/PLANNED/); + it('neither the member nor the field / flow arm says any kind is not yet served', () => { + expect(member.description).not.toMatch(/PLANNED|not yet served/); + expect(objectArm.description).not.toMatch(/PLANNED|not yet served/); expect(member.description).not.toMatch(/#\d{3,}/); }); - it('the schema-level describe says the navigation items are not yet served, so serving them drops that too', () => { - expect(GetPackageCustomizationsResponseSchema.description).toContain('not yet served'); + it('the schema-level describe no longer says the navigation items are not yet served', () => { + expect(GetPackageCustomizationsResponseSchema.description).not.toContain('not yet served'); + expect(GetPackageCustomizationsResponseSchema.description).toContain('navigation items'); }); it('the nav arm says an item with no id (a separator may omit it) is not an entry: `name` is required', () => { diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 1ff8889028f..d0a4638d1af 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -38,12 +38,11 @@ * `RollbackToPackageCommitResponseSchema` below is the true contract, * authored after that retirement per the ruling's sequencing (3A). * - * Two members are not transcriptions but DECLARED AHEAD OF THEIR PRODUCER - * (ADR-0049), each describe opening `[PLANNED — not yet served]` until the - * change that serves it drops the marker: the `nav` arm of - * `GetPackageCustomizationsResponseSchema.contributions` (#27928) and + * One member is not a transcription but DECLARED AHEAD OF ITS PRODUCER + * (ADR-0049), its describe opening `[PLANNED — not yet served]` until the + * change that serves it drops the marker: * `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by - * #29342). Their own docblocks say what is not served yet. + * #29342). Its own docblock says what is not served yet. */ import { z } from 'zod'; @@ -509,16 +508,12 @@ export const GetPackageCustomizationsRequestSchema = lazySchema(() => z.object({ * `overrides`, the pair's extension members are in `extensions`, and any other * pair flow is in no list of this answer. * - * [#27928, ruling 6566915346 letter E] DECLARED AHEAD OF ITS PRODUCER - * (ADR-0049): the `nav` kind is declared before the runtime serves it. Until - * the server slice of #28722 (Wanted 3) reads the navigation-contribution - * registry, this read lists no `nav` entry; the `nav` arm's describe opens - * `[PLANNED — not yet served]`, and that slice drops the marker in the change - * that serves the kind. When served, a `nav` entry MUST be read from the same - * `appNavContributions` registry the served app's navigation fold reads - * (ADR-0029 D7), never joined by name; a disabled package's contributions - * MUST NOT be listed, as the fold drops them; and the served app's - * `navigation` stays as declared: no nav item carries a contributor key. + * [#27928, ruling 6566915346 letter E; served by #28722] A `nav` entry MUST be + * read from the same `appNavContributions` registry the served app's + * navigation fold reads (ADR-0029 D7), never joined by name; a disabled + * package's contributions MUST NOT be listed, as the fold drops them; and the + * served app's `navigation` stays as declared: no nav item carries a + * contributor key. */ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object({ packageId: z.string().describe('The base package the customizations are of (the request\'s id).'), @@ -581,9 +576,6 @@ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object( + 'under. Never the base and never its `.custom` pair.', ), }).strict().describe('A field or flow another package contributes to one of the base\'s objects.'), - // Declared ahead of its producer: the server slice of #28722 (Wanted 3) - // serves this arm and drops the `[PLANNED — not yet served]` marker in the - // same change (the pin is in `package-lifecycle.test.ts`). z.object({ kind: z.literal('nav').describe( 'What is contributed: `nav`, a navigation item another package\'s `navigationContributions` entry adds ' @@ -601,8 +593,7 @@ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object( + 'the base and never its `.custom` pair.', ), }).strict().describe( - '[PLANNED — not yet served] Declared ahead of its producer (ADR-0049): the read lists no `nav` entry yet. ' - + 'A navigation item another installed package contributes to an app the base owns, one entry per item of ' + 'A navigation item another installed package contributes to an app the base owns, one entry per item of ' + 'the contribution\'s `items` (a contributed group\'s children ride inside it and are not entries of their ' + 'own), read from the same contribution registry the served app\'s navigation fold reads (ADR-0029 D7). ' + 'An item that declares no `id` (a separator may omit it) has no `name` to give and is not an entry. ' @@ -612,8 +603,8 @@ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object( 'Every field, flow and navigation item another installed package contributes to the base, one entry per ' + 'item and contributing package: the fields of every object extension of an object the base owns, from any ' + 'package but the base and its pair; every flow registered under such a package whose trigger object the ' - + 'base owns (a flow of a disabled package is not served, so not listed); and, declared ahead of its producer ' - + 'and not yet served, every navigation item such a package contributes to an app the base owns. The entry ' + + 'base owns (a flow of a disabled package is not served, so not listed); and every navigation item such a ' + + 'package contributes to an app the base owns (a disabled package\'s are not listed). The entry ' + 'shape is per `kind`: `object` for `field` and `flow`, `app` and an optional `group` for `nav`. Ordered: ' + 'the `field` and `flow` entries first, by object, kind, name and package; then the `nav` entries, by app, ' + 'group (an entry with no group first), name and package.', @@ -626,7 +617,7 @@ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object( }).strict().describe('Each list\'s length, derived from the lists in this same answer.'), }).strict().describe( 'Per-package customization read: overrides, extensions and switched-off items of one installed base, and the ' - + 'fields, flows and (not yet served) navigation items other packages contribute to it.', + + 'fields, flows and navigation items other packages contribute to it.', )); /** From a4c5be41bc36d3bd359f7d33c64138788fbe31dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:59:55 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the package-lifecycle reference after the main merge Co-Authored-By: Claude --- content/docs/references/api/package-lifecycle.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 772da1b39b1..f47e0a5299b 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -322,7 +322,7 @@ A navigation item another installed package contributes to an app the base owns, | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **packages** | `({ id: string; name: string; version: string; label?: string; … } & Record)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. | +| **packages** | `({ id: string; name: string; version: string; namespace?: string; … } & Record)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. | ### Nested Shape: `PackageExportBundle.packages[number]` @@ -331,6 +331,8 @@ A navigation item another installed package contributes to an app the base owns, | **id** | `string` | ✅ | The exported package's id. | | **name** | `string` | ✅ | The exported package's machine name. | | **version** | `string` | ✅ | The exported package's version. | +| **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. | +| **dependencies** | `Record` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). | | **label** | `string` | optional | Display label, when the package declares one. | | **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | @@ -346,6 +348,8 @@ A navigation item another installed package contributes to an app the base owns, | **id** | `string` | ✅ | The exported package's id. | | **name** | `string` | ✅ | The exported package's machine name. | | **version** | `string` | ✅ | The exported package's version. | +| **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. | +| **dependencies** | `Record` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). | | **label** | `string` | optional | Display label, when the package declares one. | | **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. | From be2875456d3eb4a0f32bfaf0021cc62a0d57b1c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:28:05 +0000 Subject: [PATCH 4/4] docs(spec): the package-lifecycle header names both remaining declared-ahead members Co-Authored-By: Claude --- content/docs/references/api/package-lifecycle.mdx | 9 +++++---- packages/spec/src/api/package-lifecycle.zod.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index f47e0a5299b..458db344778 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -43,11 +43,12 @@ declared a VERSION rollback against the live COMMIT-rollback path; `RollbackToPackageCommitResponseSchema` below is the true contract, authored after that retirement per the ruling's sequencing (3A). -One member is not a transcription but DECLARED AHEAD OF ITS PRODUCER -(ADR-0049), its describe opening `[PLANNED — not yet served]` until the -change that serves it drops the marker: +Two declarations are not transcriptions but DECLARED AHEAD OF THEIR +PRODUCER (ADR-0049), each describe opening `[PLANNED — not yet served]` +until the change that serves it drops the marker: `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by -#29342). Its own docblock says what is not served yet. +#29342) and `PackageExportBundleSchema` (`GET /packages/export`, #28914). +Their own docblocks say what is not served yet. **Source:** `packages/spec/src/api/package-lifecycle.zod.ts` diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index b7daff63f1e..6b671788224 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -38,11 +38,12 @@ * `RollbackToPackageCommitResponseSchema` below is the true contract, * authored after that retirement per the ruling's sequencing (3A). * - * One member is not a transcription but DECLARED AHEAD OF ITS PRODUCER - * (ADR-0049), its describe opening `[PLANNED — not yet served]` until the - * change that serves it drops the marker: + * Two declarations are not transcriptions but DECLARED AHEAD OF THEIR + * PRODUCER (ADR-0049), each describe opening `[PLANNED — not yet served]` + * until the change that serves it drops the marker: * `PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by - * #29342). Its own docblock says what is not served yet. + * #29342) and `PackageExportBundleSchema` (`GET /packages/export`, #28914). + * Their own docblocks say what is not served yet. */ import { z } from 'zod';