From 19f2905fd09535b31ff6b61298ff29d0bc830ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:30:18 +0000 Subject: [PATCH 1/4] feat(spec,runtime): a package export carries its namespace and dependencies (#29617) PackageExportManifestSchema declares namespace and dependencies as optional fixed keys, each the install manifest's own shape; assemblePackageManifest writes both from the package's registry row, so a reinstalled .custom pair keeps its namespace and the edge to its base. Claude-Session: https://claude.ai/code/session_01JQwxbWPCwHb9Zxnf7ou79C Co-authored-by: Claude --- packages/runtime/src/domains/packages.ts | 11 ++++++++++ .../spec/src/api/package-lifecycle.zod.ts | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index fac0bdc3bad..b9d7334058c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3466,6 +3466,17 @@ context: HttpProtocolContext, if (pkg?.manifest?.label ?? pkg?.label) { manifest.label = pkg?.manifest?.label ?? pkg?.label; } + // [#29617, ADR-0141 D5 / ADR-0146 D2] The two install-manifest keys an + // install of this export decides by, read off the registry row's manifest + // as it declares them (`PackageExportManifestSchema.namespace` / + // `.dependencies`): without `namespace` the install door derives one from + // the id (a `.custom` pair lands under `custom`), and without + // `dependencies` a pair loses the edge to its base that orders its install + // and admits its overrides. Absent on the row ⇒ absent here. + const namespace = pkg?.manifest?.namespace; + if (namespace !== undefined && namespace !== null) manifest.namespace = namespace; + const dependencies = pkg?.manifest?.dependencies; + if (dependencies !== undefined && dependencies !== null) manifest.dependencies = { ...dependencies }; return manifest; } diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index ef904ecdaed..10228199428 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -42,6 +42,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataLockSchema } from '../kernel/metadata-protection.zod'; +import { ManifestSchema } from '../kernel/manifest.zod'; // Ruling 5A — the `/api` re-export of the one existing declaration. The // schema (and its type) stay declared in `system/metadata-persistence.zod.ts` @@ -171,6 +172,16 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({ * `GET /packages/:id/export` — the package's portable manifest (ADR-0070 * offline export), the same shape `marketplace-install-local` consumes. * + * Two fixed keys beyond the four counted below come from the package's + * registry row rather than from its items: `namespace` and `dependencies`, + * each the install manifest's own declaration (`ManifestSchema`), so an + * export reinstalled elsewhere keeps both. Without them an install door falls + * back to the namespace derived from the id (a `.custom` pair lands under + * `custom`, not the namespace it was installed with), and the pair loses its + * edge to its base, which orders the install after the base (ADR-0141 D5) and + * admits its overrides (ADR-0146 D2). Each is written when the row declares it + * and absent when it does not. + * * HONESTLY OPEN (#12038 ruling 4A). `assemblePackageManifest` * (`runtime/src/domains/packages.ts`) builds the key set DYNAMICALLY from the * metadata type registry — one plural key per type present @@ -184,6 +195,16 @@ export const PackageExportManifestSchema = lazySchema(() => z.object({ id: z.string().describe('The exported package\'s id.'), name: z.string().describe('The exported package\'s machine name.'), version: z.string().describe('The exported package\'s version.'), + namespace: ManifestSchema.shape.namespace.describe( + '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: ManifestSchema.shape.dependencies.describe( + '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: z.string().optional().describe('Display label, when the package declares one.'), }).catchall(z.unknown().describe( 'One key per metadata type present in the package (plural spelling, e.g. ' From 628e8d8367692eb94c20f8422dee41f6c1fdd6b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:33:50 +0000 Subject: [PATCH 2/4] test: pin the export's namespace and dependencies, and the pair's round trip through install-local; changeset Claude-Session: https://claude.ai/code/session_01JQwxbWPCwHb9Zxnf7ou79C Co-authored-by: Claude --- ...7-package-export-namespace-dependencies.md | 16 +++ ...port-reinstall-custom-pair.dogfood.test.ts | 126 ++++++++++++++++++ .../packages-export-registry-row-keys.test.ts | 56 ++++++++ packages/spec/authorable-surface/api.json | 2 + .../spec/src/api/package-lifecycle.test.ts | 36 +++++ 5 files changed, 236 insertions(+) create mode 100644 .changeset/29617-package-export-namespace-dependencies.md create mode 100644 packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts create mode 100644 packages/runtime/src/domains/packages-export-registry-row-keys.test.ts diff --git a/.changeset/29617-package-export-namespace-dependencies.md b/.changeset/29617-package-export-namespace-dependencies.md new file mode 100644 index 00000000000..75e2d942563 --- /dev/null +++ b/.changeset/29617-package-export-namespace-dependencies.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +--- + +feat(spec,runtime): a package export carries its namespace and its dependencies, so a reinstalled `.custom` pair keeps both + +Clause-②: yes (widening) +Metadata-protocol: added + +`GET /packages/:id/export` answered a package's items and its `id`, `name`, `version` and `label`, and nothing of the two install-manifest keys an install of the export decides by. A `.custom` pair exported and reinstalled through `POST /api/v1/marketplace/install-local` therefore lost its namespace (the install door derives one from the id, so the pair landed under `custom` instead of, for example, `showcase_custom`) and its edge to its base, which orders the pair's install after the base (ADR-0141 D5) and admits its `overrides` declarations at the managed install (ADR-0146 D2). + +- **Spec.** `PackageExportManifestSchema` declares two more optional fixed keys, `namespace` and `dependencies`, each the install manifest's own declaration (`ManifestSchema.shape.namespace` / `.dependencies`), so an export refuses exactly what an install would. The schema stays open for the per-type collections. +- **Runtime.** The export writes both from the package's registry row, as the row's manifest declares them, and writes neither when the row declares none. + +Nothing to do for existing exports: an export taken before this change still installs as before. Export the package again to carry both keys. `type` is still not written, and `POST /api/v1/packages` still refuses an export; the install-local door is the one that reinstalls an export. diff --git a/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts b/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts new file mode 100644 index 00000000000..b37924cb70c --- /dev/null +++ b/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#29617, ADR-0141 D5 / ADR-0146 D2] A `.custom` pair exported by +// `GET /packages/:id/export` reinstalls, through +// `POST /api/v1/marketplace/install-local`, as the pair it was: under the +// namespace it was installed with, depending on its base, its override +// admitted. +// +// ## What was measured before the fix +// +// The export answered the pair's items and `id`, `name`, `version`, and +// nothing of the two install-manifest keys its registry row holds. So the +// reinstall knew no namespace (the install door derives one from the id: +// `custom`) and no base edge, and the base edge is what orders the pair after +// its base (ADR-0141 D5) and what admits the pair's `overrides` declarations +// at the managed install (ADR-0146 D2). +// +// ## What this file pins, over two showcase boots +// +// 1. source: the ensure door creates the showcase's pair, a dashboard +// override is published into it, and the pair's export carries the +// `namespace` and `dependencies` its registry row declares (the row is +// the control); +// 2. target: a fresh showcase installs that export through install-local, +// 200, and the reinstalled pair's registry row declares the same +// namespace and the same edge to the showcase, its override carried +// with its `overrides` block. +// +// Boots fixture stacks of its own (the install-local plugin mounted on the +// target), so it stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { bootShowcase } from './showcase-boot.js'; +import { declaredOverride, ensurePair, inPair, pairOf } from './customization-pair.js'; + +const SHOWCASE = 'com.example.showcase'; +const PAIR = pairOf(SHOWCASE); +const ROUTE = '/marketplace/install-local'; +const DASHBOARD = 'showcase_revenue_pulse'; +const EDITED = 'Revenue pulse (customized)'; + +type Answer = { status: number; body: any }; + +async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown): Promise { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +/** The manifest a `GET /packages/:id` answer carries for the package's registry row. */ +const rowManifest = (answer: Answer): Record | undefined => + answer.body?.data?.manifest ?? answer.body?.data?.package?.manifest; + +describe('dogfood: a `.custom` pair\'s export reinstalls under its own namespace, depending on its base', () => { + let storageDir: string; + let sourceRow: Answer; + let published: Answer; + let exported: Answer; + let installed: Answer; + let targetRow: Answer; + + beforeAll(async () => { + const source = await bootShowcase(); + try { + const token = await source.signIn(); + await ensurePair(source, token, SHOWCASE); + sourceRow = await call(source, token, 'GET', `/packages/${PAIR}`); + const saved = await call(source, token, 'PUT', inPair(`/meta/dashboard/${DASHBOARD}?mode=draft`, SHOWCASE), + declaredOverride({ name: DASHBOARD, label: EDITED, columns: 12, widgets: [] }, SHOWCASE, '0.1.0')); + expect(saved.status, JSON.stringify(saved.body)).toBe(200); + published = await call(source, token, 'POST', `/packages/${PAIR}/publish-drafts`, {}); + exported = await call(source, token, 'GET', `/packages/${PAIR}/export`); + } finally { + await source.stop(); + } + + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-reinstall-pair-')); + const target = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + try { + const token = await target.signIn(); + installed = await call(target, token, 'POST', ROUTE, { manifest: exported.body?.data }); + targetRow = await call(target, token, 'GET', `/packages/${PAIR}`); + } finally { + await target.stop(); + } + }, 600_000); + + afterAll(() => { + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('source: the pair\'s export carries the namespace and the base edge its registry row declares', () => { + expect(published.status, JSON.stringify(published.body)).toBe(200); + expect(sourceRow.status, JSON.stringify(sourceRow.body)).toBe(200); + // CONTROL: the row the export reads declares both. + const row = rowManifest(sourceRow); + expect(row?.namespace, JSON.stringify(sourceRow.body)).toBe('showcase_custom'); + expect(Object.keys(row?.dependencies ?? {})).toEqual([SHOWCASE]); + + expect(exported.status, JSON.stringify(exported.body)).toBe(200); + const manifest = exported.body?.data; + expect(manifest?.namespace).toBe(row?.namespace); + expect(manifest?.dependencies).toEqual(row?.dependencies); + expect((manifest?.dashboards ?? []).map((d: any) => [d.name, d.label, d.overrides?.package])) + .toEqual([[DASHBOARD, EDITED, SHOWCASE]]); + }); + + it('target: install-local reinstalls the export, 200, with its override admitted', () => { + expect(installed.status, JSON.stringify(installed.body)).toBe(200); + expect(installed.body?.data?.manifestId).toBe(PAIR); + }); + + it('target: the reinstalled pair declares the namespace it was exported with and its edge to the showcase', () => { + expect(targetRow.status, JSON.stringify(targetRow.body)).toBe(200); + const row = rowManifest(targetRow); + expect(row?.namespace, JSON.stringify(targetRow.body)).toBe('showcase_custom'); + expect(row?.dependencies).toEqual(rowManifest(sourceRow)?.dependencies); + }); +}); diff --git a/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts b/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts new file mode 100644 index 00000000000..6685f61faf2 --- /dev/null +++ b/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29617, ADR-0141 D5 / ADR-0146 D2] `GET /packages/:id/export` writes the + * two install-manifest keys an install of the export decides by, read off the + * package's registry row: `namespace` and `dependencies` + * (`PackageExportManifestSchema`). Without them a reinstalled `.custom` pair + * lands under the namespace derived from its id (`custom`) and without the + * edge to its base that orders its install and admits its overrides. + * + * The door is the real dispatcher over a registry and protocol double: what + * is pinned is what the door writes from the row. The round trip through + * install-local is pinned on two real showcase boots in + * `packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts`. + */ +import { describe, expect, it, vi } from 'vitest'; +import { PackageExportManifestSchema } from '@objectstack/spec/api'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const BASE = 'com.example.crm'; +const PAIR = 'com.example.crm.custom'; + +const system = (): any => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } }); + +async function exported(row: Record): Promise> { + const registry = { getPackage: vi.fn(() => row), getAllPackages: vi.fn().mockReturnValue([]) }; + const protocol = { getMetaItemsPerPackage: vi.fn(async () => ({ items: [] })), getMetaItems: vi.fn(async () => ({ items: [] })) }; + const kernel: any = { + context: { getService: (name: string) => (name === 'objectql' ? { registry } : name === 'protocol' ? protocol : null) }, + }; + const r = await new HttpDispatcher(kernel).handlePackages(`/${encodeURIComponent(PAIR)}/export`, 'GET', undefined, {}, system()); + expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(200); + const body: any = r.response?.body; + return body?.data ?? body; +} + +describe('[#29617] GET /packages/:id/export writes the namespace and the dependencies its registry row declares', () => { + it('a `.custom` pair\'s export carries its namespace and its edge to its base, and parses as the declared export', async () => { + const dependencies = { [BASE]: '^1' }; + const row = { id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies } }; + const manifest = await exported(row); + expect(manifest).toMatchObject({ id: PAIR, namespace: 'crm_custom', dependencies: { [BASE]: '^1' } }); + expect(manifest.dependencies, 'a copy of the row\'s map, never the row\'s own object').not.toBe(dependencies); + expect(PackageExportManifestSchema.safeParse(manifest).success).toBe(true); + }); + + it('a row that declares neither writes neither — no key is invented', async () => { + const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0' } }); + expect(manifest).toEqual({ id: PAIR, name: 'crm-custom', version: '0.1.0' }); + }); + + it('`type` stays out of the export: it is not one of the keys this door writes', async () => { + const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom' } }); + expect(Object.keys(manifest).sort()).toEqual(['id', 'name', 'namespace', 'version']); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index ff3d9739939..836005d9c4a 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1302,9 +1302,11 @@ "api/OperatorMapping:operator", "api/OperatorMapping:rest", "api/PackageExportBundle:packages", + "api/PackageExportManifest:dependencies", "api/PackageExportManifest:id", "api/PackageExportManifest:label", "api/PackageExportManifest:name", + "api/PackageExportManifest:namespace", "api/PackageExportManifest:version", "api/PackageInstallManifest:capabilities [RETIRED]", "api/PackageInstallManifest:configuration [RETIRED]", diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index 8bb51ac72ed..a6ba289f26f 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -553,3 +553,39 @@ describe('the `nav` kind of `contributions` is declared ahead of its producer (A expect(GetPackageCustomizationsResponseSchema.shape.contributions.element.safeParse(noName).success).toBe(false); }); }); + +describe('PackageExportManifestSchema declares the install-manifest keys an install of the export decides by', () => { + const identity = { id: 'com.example.crm.custom', name: 'example-crm-custom', version: '0.1.0' }; + /** A `.custom` pair's export, as `assemblePackageManifest` writes it from the pair's registry row. */ + const pairExport = { + ...identity, + namespace: 'crm_custom', + dependencies: { 'com.example.crm': '^1' }, + dashboards: [{ name: 'crm_board', label: 'Board', overrides: { package: 'com.example.crm', basedOn: '2.1.0' } }], + }; + + it('parses a pair\'s export and PRESERVES its namespace and its base edge', () => { + const result = PackageExportManifestSchema.safeParse(pairExport); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual(pairExport); + }); + + it('both are optional: an export of a package whose row declares neither still parses', () => { + expect(PackageExportManifestSchema.safeParse(identity).success).toBe(true); + }); + + it('each is the install manifest\'s own declaration, refusing what an install would refuse', () => { + expect(PackageExportManifestSchema.safeParse({ ...identity, namespace: '1crm' }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, namespace: 'Crm' }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, dependencies: { 'com.example.crm': 1 } }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, dependencies: ['com.example.crm'] }).success).toBe(false); + }); + + it('each describe says where the value comes from and why an install needs it, citing no tracker id', () => { + const { namespace, dependencies } = PackageExportManifestSchema.shape; + expect(namespace.description).toContain('registry row'); + expect(dependencies.description).toContain('ADR-0141 D5'); + expect(dependencies.description).toContain('ADR-0146 D2'); + for (const d of [namespace.description, dependencies.description]) expect(d).not.toMatch(/#\d{3,}/); + }); +}); From 6d249e4f5370d08ebc9be95d792b514c92883ee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:51:20 +0000 Subject: [PATCH 3/4] docs(spec): regenerate the package-lifecycle reference for the export's namespace and dependencies Claude-Session: https://claude.ai/code/session_01JQwxbWPCwHb9Zxnf7ou79C 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 2b163f98475..51639002b12 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -316,7 +316,7 @@ A field or flow another package contributes to one of the base's objects. | 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]` @@ -325,6 +325,8 @@ A field or flow another package contributes to one of the base's objects. | **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. | @@ -339,6 +341,8 @@ A field or flow another package contributes to one of the base's objects. | **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. | From 3d93d712732f2ee47fc2566e072cc6fd2c1d3ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:36:35 +0000 Subject: [PATCH 4/4] fix(spec): count the export's seven fixed keys after main brought withheldMaps; regenerate Claude-Session: https://claude.ai/code/session_01JQwxbWPCwHb9Zxnf7ou79C Co-authored-by: Claude --- .../docs/references/api/package-lifecycle.mdx | 17 +++++++++++++++++ packages/spec/authorable-surface/api.json | 1 + packages/spec/src/api/package-lifecycle.test.ts | 2 +- packages/spec/src/api/package-lifecycle.zod.ts | 10 +++++----- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 51639002b12..686288df26d 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -43,6 +43,13 @@ 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 +`PackageExportManifestSchema.withheldMaps` (#28640 ruling ③A, served by +#29342). Their own docblocks say what is not served yet. + **Source:** `packages/spec/src/api/package-lifecycle.zod.ts` @@ -328,6 +335,7 @@ A field or flow another package contributes to one of the base's objects. | **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. | --- @@ -344,6 +352,15 @@ A field or flow another package contributes to one of the base's objects. | **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. | + +### Nested Shape: `PackageExportManifest.withheldMaps[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'connector'` | ✅ | Metadata type of the item whose map was withheld. | +| **name** | `string` | ✅ | Name of the item whose map was withheld. | +| **path** | `Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …>` | ✅ | Dot path of the withheld map inside the item. | --- diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 836005d9c4a..304ccd0f5d4 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1308,6 +1308,7 @@ "api/PackageExportManifest:name", "api/PackageExportManifest:namespace", "api/PackageExportManifest:version", + "api/PackageExportManifest:withheldMaps", "api/PackageInstallManifest:capabilities [RETIRED]", "api/PackageInstallManifest:configuration [RETIRED]", "api/PackageInstallManifest:contributes", diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index ab5a7798459..bcf00bb46ae 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -198,7 +198,7 @@ describe('RollbackToPackageCommitResponseSchema declares the COMMIT-rollback bod }); }); -describe('PackageExportManifestSchema declares the five fixed keys and stays open', () => { +describe('PackageExportManifestSchema declares the seven fixed keys and stays open', () => { /** A verbatim-shaped capture of a real `assemblePackageManifest` return. */ const realResponse = { id: 'com.example.crm', diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 7656afaf5c2..aa958041959 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -179,10 +179,10 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({ * `GET /packages/:id/export` — the package's portable manifest (ADR-0070 * offline export), the same shape `marketplace-install-local` consumes. * - * Two fixed keys beyond the four counted below come from the package's - * registry row rather than from its items: `namespace` and `dependencies`, - * each the install manifest's own declaration (`ManifestSchema`), so an - * export reinstalled elsewhere keeps both. Without them an install door falls + * Two of the fixed keys below come from the package's registry row rather + * than from its items: `namespace` and `dependencies`, each the install + * manifest's own declaration (`ManifestSchema`), so an export reinstalled + * elsewhere keeps both. Without them an install door falls * back to the namespace derived from the id (a `.custom` pair lands under * `custom`, not the namespace it was installed with), and the pair loses its * edge to its base, which orders the install after the base (ADR-0141 D5) and @@ -193,7 +193,7 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({ * (`runtime/src/domains/packages.ts`) builds the key set DYNAMICALLY from the * metadata type registry — one plural key per type present * (`manifest[plural] = items.map(clean)`), partitioning `views` per #5320 — - * plus the five fixed keys below. Only the fixed keys are pinnable; + * plus the seven fixed keys below. Only the fixed keys are pinnable; * enumerating the registry here would freeze this contract against future * metadata types, so everything else deliberately falls to the open * catch-all. Freezes nothing.