diff --git a/.changeset/publish-payload-namespace.md b/.changeset/publish-payload-namespace.md new file mode 100644 index 0000000000..b75ff5de45 --- /dev/null +++ b/.changeset/publish-payload-namespace.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +feat(spec,cli): carry the package namespace on the publish payload (#6760) + +ADR-0048's addendum defines a publish-time namespace exclusivity registry +(`namespace → publisher`), so a cross-vendor namespace collision is caught while +exactly one party can still fix it cheaply — the publisher, before anything ships +— instead of surfacing at install time, where the tenant who suffers it can do +nothing. That gate is enterprise-side (Phase A2), and it could not be built +because the namespace never left the artifact: `PackageSchema` had no +`namespace` field at all, `CreatePackageRequestSchema` did not accept one, and +`objectstack package publish` transmitted `manifest_id` only. This is Phase A1, +the open-side half that gives the gate an input. + +**`PackageSchema` and `CreatePackageRequestSchema` gain an optional +`namespace`.** It mirrors `manifest.namespace` exactly — same 2-20 character +rule (`/^[a-z][a-z0-9_]{1,19}$/`), same optionality — so the publish payload and +the artifact manifest cannot disagree about what a namespace is. Optional is the +ruled shape, not a convenience: the addendum's algorithm opens with +`if (namespace is absent) -> allow`, and a package that declares no namespace +makes no reservation and is not gated. A parity test judges both fields against +one table of values, so a change to either side fails. + +**`objectstack package publish` sends it, read off the compiled artifact's +`manifest.namespace`** — the same place the command already reads +`manifest.id`. Three behaviours, matching the addendum's algorithm: + +- namespace present → it travels on the `POST /cloud/packages` body as + `namespace`, and is echoed in the publish summary; +- namespace absent → the key is omitted entirely (not `null`, not `''`), so + "declares no namespace" never becomes a value the gate has to interpret; +- namespace malformed → the publish is refused before any network call, naming + the rule and the fix. + +The namespace is deliberately **not** overridable by a flag or by +`objectstack.manifest.json`, unlike `manifestId`: a reservation is only +meaningful if it names the object-name prefix the package actually ships, and a +second declaration surface would let a publisher reserve `foo` while installing +`bar_*` objects. For the same reason `TemplateManifestSchema` omits the field +rather than inheriting it. + +Nothing about install-time behaviour changes. The in-process install gate, +`NamespaceConflictError`, the shareable `base`/`system`/`sys` set and the +`OS_METADATA_COLLISION=warn` downgrade are untouched, and the install path +acquires no network dependency. diff --git a/content/docs/references/cloud/package.mdx b/content/docs/references/cloud/package.mdx index bf86267bf9..b4f85dc169 100644 --- a/content/docs/references/cloud/package.mdx +++ b/content/docs/references/cloud/package.mdx @@ -44,6 +44,7 @@ Register a new package in the Control Plane | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **manifestId** | `string` | ✅ | Globally unique reverse-domain package identifier (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") | | **ownerOrgId** | `string` | ✅ | Owner organization ID | | **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace | | **description** | `string` | optional | Short package description | @@ -69,6 +70,7 @@ Register a new package in the Control Plane | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | UUID of the package (stable, never reused) | | **manifestId** | `string` | ✅ | Globally unique reverse-domain package identifier (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account") | | **ownerOrgId** | `string` | ✅ | Organization ID of the package owner/publisher | | **displayName** | `string` | ✅ | Display name shown in Studio and Marketplace | | **description** | `string` | optional | Short package description | diff --git a/packages/cli/src/commands/package/publish.ts b/packages/cli/src/commands/package/publish.ts index 7e407b1ab6..29e29c4696 100644 --- a/packages/cli/src/commands/package/publish.ts +++ b/packages/cli/src/commands/package/publish.ts @@ -26,6 +26,15 @@ import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config. const MANIFEST_ID_RE = /^[a-z0-9][a-z0-9._-]{0,254}$/i; +/** + * Mirror of `manifest.namespace`'s pattern in `@objectstack/spec` + * (`kernel/manifest.zod.ts`, and `cloud/package.zod.ts` for the publish + * payload): 2-20 chars, a lowercase letter followed by lowercase letters, + * digits or underscores. Pinned against the spec schema in + * `test/package-publish.test.ts`. + */ +export const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/; + function slugify(input: string): string { return input .toLowerCase() @@ -53,6 +62,28 @@ function deriveManifestId(artifact: any, artifactPath: string): string { return `local.${slugify(basename(artifactPath).replace(/\.json$/i, ''))}`; } +/** + * Read the metadata namespace off the COMPILED ARTIFACT's manifest — the same + * place {@link deriveManifestId} reads `manifest.id`. + * + * ADR-0048 addendum §A.2 (Phase A1) requires the namespace to travel with the + * publish payload so the publish-time exclusivity gate has an input to check. + * It is deliberately NOT overridable by a flag or by `objectstack.manifest.json` + * (unlike `manifestId`): the namespace is the physical object-name prefix baked + * into the artifact at build time, and a reservation that names a different + * string than the package actually ships would be worse than no reservation — + * a publisher could reserve `foo` while installing `bar_*` objects. + * + * Returns `undefined` when the artifact declares no namespace. That is a + * supported artifact shape (`manifest.namespace` is optional), and §A.2's + * algorithm opens with `if (namespace is absent) -> allow`. + */ +function readArtifactNamespace(artifact: any): string | undefined { + const ns = artifact?.manifest?.namespace; + if (typeof ns !== 'string' || !ns.trim()) return undefined; + return ns.trim(); +} + function deriveDisplayName(artifact: any, manifestId: string): string { const n = artifact?.manifest?.name; if (typeof n === 'string' && n.trim()) return n.trim(); @@ -294,6 +325,21 @@ export default class PackagePublish extends Command { this.exit(1); return; } + // ADR-0048 addendum §A.2 Phase A1 — the namespace must reach the control + // plane, and it must be the artifact's real one. A malformed value is + // refused here, before any network call: silently dropping it would + // publish an artifact whose namespace the exclusivity gate never sees, + // which is precisely the hole this phase exists to close. + const namespace = readArtifactNamespace(artifact); + if (namespace !== undefined && !NAMESPACE_RE.test(namespace)) { + printError( + `Invalid manifest.namespace '${namespace}' in the artifact. Expected 2-20 characters: ` + + 'a lowercase letter followed by lowercase letters, digits or underscores ' + + "(e.g. 'crm'). Fix `manifest.namespace` in objectstack.config.ts and rebuild.", + ); + this.exit(1); + return; + } const displayName = ( flags['display-name'] ?? (typeof m.displayName === 'string' ? m.displayName : undefined) @@ -339,6 +385,11 @@ export default class PackagePublish extends Command { display_name: displayName, visibility: flags.visibility, }; + // Absent namespace ⇒ absent key. `CreatePackageRequestSchema.namespace` + // is optional and §A.2 allows an unnamespaced publish; sending `null` or + // `''` would turn "declares no namespace" into a value the gate has to + // interpret. + if (namespace) pkgBody.namespace = namespace; const desc = flags.description ?? (typeof m.description === 'string' ? m.description : undefined); if (desc) pkgBody.description = desc; const cat = flags.category ?? (typeof m.category === 'string' ? m.category : undefined); @@ -516,6 +567,7 @@ export default class PackagePublish extends Command { printSuccess('Package published'); printKV(' Package', manifestId); printKV(' Package ID', String(pkg?.id ?? '—')); + if (namespace) printKV(' Namespace', namespace); printKV(' Version', String(ver?.version ?? version)); printKV(' Version ID', String(ver?.id ?? '—')); if (ver?.checksum) printKV(' Checksum', String(ver.checksum).slice(0, 16)); diff --git a/packages/cli/test/package-publish-namespace.test.ts b/packages/cli/test/package-publish-namespace.test.ts new file mode 100644 index 0000000000..aef5e82013 --- /dev/null +++ b/packages/cli/test/package-publish-namespace.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0048 addendum §A.2 Phase A1 — `os package publish` carries the artifact's + * namespace to the control plane. + * + * The publish-time exclusivity gate (Phase A2, enterprise-side) reads the + * namespace off the publish payload. Before this phase the namespace never left + * the artifact, so the gate had nothing to check. These cases pin the three + * behaviours the addendum's algorithm distinguishes: a namespace present (it + * travels), a namespace absent (`if (namespace is absent) -> allow` — the key + * is simply not sent), and a namespace that is not a namespace (refused before + * any network call). + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CreatePackageRequestSchema } from '@objectstack/spec/cloud'; +import PackagePublish, { NAMESPACE_RE } from '../src/commands/package/publish.js'; + +type Call = { url: string; body: any }; + +function artifactJson(manifest: Record): string { + return JSON.stringify({ + manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.2.0', ...manifest }, + objects: [], + }); +} + +/** Stub `fetch` so both publish POSTs succeed, and record what was sent. */ +function stubCloud(): Call[] { + const calls: Call[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => { + calls.push({ url, body: JSON.parse(init.body) }); + const data = url.endsWith('/versions') + ? { id: 'ver_1', version: '1.2.0', listing_status: 'draft' } + : { id: 'pkg_1', created: true, visibility: 'org' }; + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) } as any; + })); + return calls; +} + +describe('os package publish — namespace on the publish payload', () => { + let dir = ''; + const prevEnv = { url: process.env.OS_CLOUD_URL, key: process.env.OS_CLOUD_API_KEY }; + const prevCwd = process.cwd(); + + afterEach(async () => { + process.chdir(prevCwd); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + process.env.OS_CLOUD_URL = prevEnv.url; + process.env.OS_CLOUD_API_KEY = prevEnv.key; + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + async function artifactAt(manifest: Record): Promise { + dir = await mkdtemp(join(tmpdir(), 'package-publish-ns-')); + const path = join(dir, 'objectstack.json'); + await writeFile(path, artifactJson(manifest)); + process.env.OS_CLOUD_URL = 'http://cloud.test'; + process.env.OS_CLOUD_API_KEY = 'tok_123'; + return path; + } + + it('sends `namespace` read off the compiled artifact manifest', async () => { + const path = await artifactAt({ namespace: 'crm' }); + const calls = stubCloud(); + + await PackagePublish.run([path]); + + expect(calls).toHaveLength(2); + expect(calls[0].url).toBe('http://cloud.test/api/v1/cloud/packages'); + expect(calls[0].body).toMatchObject({ manifest_id: 'com.acme.crm', namespace: 'crm' }); + // The value the CLI puts on the wire is one the acceptance face accepts. + expect(CreatePackageRequestSchema.shape.namespace.safeParse(calls[0].body.namespace).success).toBe(true); + }); + + // Reverse verification, direction 1: an artifact with NO namespace must not + // grow one. §A.2 allows an absent namespace, and the key must be absent + // rather than null/'' so the gate never has to interpret an empty value. + it('omits the key entirely when the artifact declares no namespace', async () => { + const path = await artifactAt({}); + const calls = stubCloud(); + + await PackagePublish.run([path]); + + expect(calls).toHaveLength(2); + expect('namespace' in calls[0].body).toBe(false); + expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true); + }); + + // Reverse verification, direction 2: a malformed namespace is refused, and + // refused BEFORE the network call — silently dropping it would publish an + // artifact whose namespace the A2 gate never sees. + it('refuses a malformed namespace with exit code 1 and never calls the cloud', async () => { + const path = await artifactAt({ namespace: 'CRM-App' }); + const calls = stubCloud(); + const errors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + + let exitCode: number | undefined; + try { + await PackagePublish.run([path]); + } catch (err: any) { + exitCode = err?.oclif?.exit ?? err?.exitCode; + } + + expect(exitCode).toBe(1); + expect(calls).toEqual([]); + expect(errors.join('\n')).toContain("Invalid manifest.namespace 'CRM-App'"); + // The message names the rule and the remedy, not just the failure. + expect(errors.join('\n')).toContain('objectstack.config.ts'); + // …and the payload schema agrees this value is not a namespace. + const rejected = CreatePackageRequestSchema.shape.namespace.safeParse('CRM-App'); + expect(rejected.success).toBe(false); + expect(rejected.success === false && rejected.error.issues[0].code).toBe('invalid_format'); + }); + + // The namespace has exactly ONE source. `objectstack.manifest.json` may + // override manifestId/displayName/category; it must not be able to claim a + // namespace the artifact does not ship, or the reservation would name a + // different string than the installed object prefix. + it('ignores a namespace in objectstack.manifest.json — the artifact wins', async () => { + const path = await artifactAt({ namespace: 'crm' }); + await writeFile( + join(dir, 'objectstack.manifest.json'), + JSON.stringify({ name: 'acme-crm', namespace: 'squatted', displayName: 'Acme CRM' }), + ); + process.chdir(dir); + const calls = stubCloud(); + + await PackagePublish.run([path]); + + expect(calls[0].body.namespace).toBe('crm'); + }); +}); + +describe('the CLI namespace rule is the spec namespace rule', () => { + it('agrees with CreatePackageRequestSchema on every value', () => { + const cases: ReadonlyArray = [ + ['crm', true], + ['todo', true], + ['a1', true], + ['my_app_2', true], + ['abcdefghijklmnopqrst', true], + ['a', false], + ['abcdefghijklmnopqrstu', false], + ['1crm', false], + ['CRM', false], + ['crm-app', false], + ['crm.account', false], + ['crm account', false], + ['', false], + ]; + const disagreements = cases.filter(([value, expected]) => { + const cli = NAMESPACE_RE.test(value); + const spec = CreatePackageRequestSchema.shape.namespace.safeParse(value).success; + return cli !== expected || spec !== expected; + }); + expect(disagreements).toEqual([]); + }); +}); diff --git a/packages/spec/authorable-surface/cloud.json b/packages/spec/authorable-surface/cloud.json index d616018422..a4d95d058d 100644 --- a/packages/spec/authorable-surface/cloud.json +++ b/packages/spec/authorable-surface/cloud.json @@ -56,6 +56,7 @@ "cloud/CreatePackageRequest:isStarter", "cloud/CreatePackageRequest:license", "cloud/CreatePackageRequest:manifestId", + "cloud/CreatePackageRequest:namespace", "cloud/CreatePackageRequest:ownerOrgId", "cloud/CreatePackageRequest:publisher", "cloud/CreatePackageRequest:tags", @@ -245,6 +246,7 @@ "cloud/Package:isStarter", "cloud/Package:license", "cloud/Package:manifestId", + "cloud/Package:namespace", "cloud/Package:ownerOrgId", "cloud/Package:publisher", "cloud/Package:readme", diff --git a/packages/spec/src/cloud/package-namespace.test.ts b/packages/spec/src/cloud/package-namespace.test.ts new file mode 100644 index 0000000000..76ed919d2e --- /dev/null +++ b/packages/spec/src/cloud/package-namespace.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0048 addendum §A.2 Phase A1 — `namespace` travels on the publish payload. + * + * The publish-time namespace exclusivity gate (Phase A2, enterprise-side) is + * keyed on the bare namespace (D1) and can check nothing unless the namespace + * leaves the artifact. These are the acceptance-face pins for the open side of + * that contract: the field exists on both schemas, it is OPTIONAL (§A.2's + * algorithm opens with `if (namespace is absent) -> allow`), it judges values + * exactly as `manifest.namespace` does (§A.7 "two gates, one vocabulary"), and + * the on-disk template descriptor deliberately does NOT declare it. + */ + +import { describe, it, expect } from 'vitest'; +import { ManifestSchema } from '../kernel/manifest.zod'; +import { CreatePackageRequestSchema, PackageSchema } from './package.zod'; +import { TemplateManifestSchema } from './template-manifest.zod'; + +/** A Package row that is valid except for whatever the case under test changes. */ +function packageRow(overrides: Record = {}) { + return { + id: '3f1c2a52-6b0e-4a3f-9c1d-2e5b7a8d9f01', + manifestId: 'com.acme.crm', + ownerOrgId: 'org_acme', + displayName: 'Acme CRM', + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + createdBy: 'usr_1', + ...overrides, + }; +} + +/** A CreatePackageRequest that is valid except for the case under test. */ +function createRequest(overrides: Record = {}) { + return { + manifestId: 'com.acme.crm', + ownerOrgId: 'org_acme', + displayName: 'Acme CRM', + createdBy: 'usr_1', + ...overrides, + }; +} + +/** + * The value table both gates are judged against. `true` = the string is a + * legal namespace per `manifest.namespace`'s documented rule (2-20 chars, + * leading lowercase letter, then lowercase letters / digits / underscores). + */ +const NAMESPACE_CASES: ReadonlyArray = [ + ['crm', true], + ['todo', true], + ['a1', true], + ['my_app_2', true], + ['base', true], // shareable at the GATE (D3), still a well-formed string + ['sys', true], + ['abcdefghijklmnopqrst', true], // 20 chars — the upper bound + ['a', false], // 1 char — under the lower bound + ['abcdefghijklmnopqrstu', false], // 21 chars — over the upper bound + ['1crm', false], // leading digit + ['CRM', false], // uppercase + ['crm-app', false], // hyphen + ['crm account', false], // space + ['crm.account', false], // dot (that is manifest_id's alphabet) + ['', false], +]; + +describe('PackageSchema.namespace (ADR-0048 addendum Phase A1)', () => { + it('declares the field and accepts a well-formed namespace', () => { + const parsed = PackageSchema.parse(packageRow({ namespace: 'crm' })); + expect(parsed.namespace).toBe('crm'); + }); + + it('is OPTIONAL — a row with no namespace parses and carries none', () => { + const parsed = PackageSchema.parse(packageRow()); + expect(parsed.namespace).toBeUndefined(); + expect('namespace' in parsed).toBe(false); + }); + + it('rejects a malformed namespace with a coded issue AT the namespace path', () => { + const result = PackageSchema.safeParse(packageRow({ namespace: 'CRM-App' })); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_format'); + expect(issues[0].path).toEqual(['namespace']); + expect(issues[0].message).toBe( + 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore', + ); + }); + + it('rejects a non-string namespace with invalid_type at the namespace path', () => { + const result = PackageSchema.safeParse(packageRow({ namespace: 42 })); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues[0].code).toBe('invalid_type'); + expect(issues[0].path).toEqual(['namespace']); + }); +}); + +describe('CreatePackageRequestSchema.namespace (the publish payload)', () => { + it('accepts a namespace on the publish request', () => { + const parsed = CreatePackageRequestSchema.parse(createRequest({ namespace: 'crm' })); + expect(parsed.namespace).toBe('crm'); + }); + + it('is OPTIONAL — §A.2 allows a publish that declares no namespace', () => { + const parsed = CreatePackageRequestSchema.parse(createRequest()); + expect(parsed.namespace).toBeUndefined(); + }); + + it('rejects a malformed namespace with a coded issue at the namespace path', () => { + const result = CreatePackageRequestSchema.safeParse(createRequest({ namespace: '1crm' })); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('invalid_format'); + expect(issues[0].path).toEqual(['namespace']); + }); +}); + +describe('two gates, one vocabulary (§A.7)', () => { + it('the publish payload judges every namespace exactly as manifest.namespace does', () => { + const manifestField = ManifestSchema.shape.namespace; + const payloadField = PackageSchema.shape.namespace; + const requestField = CreatePackageRequestSchema.shape.namespace; + + const verdicts = NAMESPACE_CASES.map(([value, expected]) => ({ + value, + expected, + manifest: manifestField.safeParse(value).success, + payload: payloadField.safeParse(value).success, + request: requestField.safeParse(value).success, + })); + + // One assertion over the whole table so a drift names the offending value. + expect(verdicts.filter((v) => + v.manifest !== v.expected || v.payload !== v.expected || v.request !== v.expected, + )).toEqual([]); + }); + + it('both fields are optional, so "absent" means the same thing on both sides', () => { + expect(ManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true); + expect(PackageSchema.shape.namespace.safeParse(undefined).success).toBe(true); + expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true); + }); +}); + +describe('TemplateManifestSchema does not inherit namespace', () => { + it('omits it deliberately — the publish namespace comes from the compiled artifact', () => { + expect(Object.keys(TemplateManifestSchema.shape)).not.toContain('namespace'); + // And the projection still carries the rest of the create-request surface. + expect(Object.keys(TemplateManifestSchema.shape)).toContain('manifestId'); + }); +}); diff --git a/packages/spec/src/cloud/package.zod.ts b/packages/spec/src/cloud/package.zod.ts index f7943dcf0c..1689a62eeb 100644 --- a/packages/spec/src/cloud/package.zod.ts +++ b/packages/spec/src/cloud/package.zod.ts @@ -167,6 +167,35 @@ export const PackageSchema = lazySchema(() => z.object({ .regex(/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/) .describe('Globally unique reverse-domain package identifier (e.g. com.acme.crm)'), + /** + * Metadata namespace the artifact installs under — the mandatory prefix of + * every object name it ships (`crm` → `crm_account`). Mirrors + * `manifest.namespace` (`packages/spec/src/kernel/manifest.zod.ts`) and is + * carried here so the namespace leaves the artifact at publish time. + * + * ADR-0048 addendum §A.2 (Phase A1): the publish-time namespace exclusivity + * gate is keyed on the **bare namespace** (D1) and can check nothing unless + * the namespace travels on the publish payload. This field is that input. + * + * **Optional, deliberately.** `manifest.namespace` is itself optional today, + * and §A.2's algorithm opens with `if (namespace is absent) -> allow`. A + * package that declares no namespace makes no reservation and is not gated. + * + * The value is written from the compiled artifact's manifest by + * `objectstack package publish` — it is never authored separately, because a + * reservation is only meaningful if it names the prefix the package really + * ships. + */ + namespace: z + .string() + // Kept byte-identical to `ManifestSchema.shape.namespace`'s pattern; the + // two are pinned against each other by `package-namespace.test.ts` so the + // publish payload and the manifest cannot drift into disagreeing about + // what a namespace is (addendum §A.7 "two gates, one vocabulary"). + .regex(/^[a-z][a-z0-9_]{1,19}$/, 'Namespace must be 2-20 chars, lowercase alphanumeric + underscore') + .optional() + .describe('Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account")'), + /** Organization that owns and publishes this package. */ ownerOrgId: z.string().describe('Organization ID of the package owner/publisher'), @@ -251,6 +280,12 @@ export type PackageParsed = z.infer; */ export const CreatePackageRequestSchema = lazySchema(() => z.object({ manifestId: PackageSchema.shape.manifestId, + // ADR-0048 addendum §A.2 Phase A1 — the namespace travels with the publish + // payload, or the publish-time exclusivity gate (A2) has nothing to check. + // Optional, matching `manifest.namespace` and the algorithm's first line + // (`if (namespace is absent) -> allow`). Sent by `objectstack package + // publish`, read off the compiled artifact's manifest. + namespace: PackageSchema.shape.namespace, ownerOrgId: z.string().describe('Owner organization ID'), displayName: PackageSchema.shape.displayName, description: PackageSchema.shape.description, diff --git a/packages/spec/src/cloud/template-manifest.zod.ts b/packages/spec/src/cloud/template-manifest.zod.ts index c91d053e40..6023e80c80 100644 --- a/packages/spec/src/cloud/template-manifest.zod.ts +++ b/packages/spec/src/cloud/template-manifest.zod.ts @@ -13,7 +13,14 @@ import { CreatePackageRequestSchema } from './package.zod'; export const TemplateManifestSchema = lazySchema(() => CreatePackageRequestSchema - .omit({ ownerOrgId: true, createdBy: true }) + // `namespace` is omitted alongside the server-managed fields, and for the + // same reason: it is not authored here. The publish payload's namespace is + // read off the COMPILED ARTIFACT's `manifest.namespace` (ADR-0048 addendum + // §A.2 Phase A1), because a reservation is only meaningful if it names the + // object-name prefix the package actually ships. Declaring it on this + // on-disk descriptor too would create a second source for one fact — the + // exact drift the addendum's "two gates, one vocabulary" (§A.7) rules out. + .omit({ ownerOrgId: true, createdBy: true, namespace: true }) .extend({ name: z.string().regex(/^[a-z][a-z0-9-]*$/) .describe('CLI slug (kebab-case, no namespace prefix)'),