diff --git a/.changeset/template-manifest-scaffold-namespace.md b/.changeset/template-manifest-scaffold-namespace.md new file mode 100644 index 0000000000..8ccbba76bc --- /dev/null +++ b/.changeset/template-manifest-scaffold-namespace.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare `namespace` on `TemplateManifestSchema` as a scaffold-only extra (#6861) + +`objectstack.manifest.json` carries a live `namespace` key that the schema +claiming to describe that file did not declare. The bundled blank template +ships `"namespace": "blank"`; `create-objectstack` rewrites the key in place +when it stamps a new project; and `readTemplateNamespace` +(`packages/create-objectstack/src/rewrite-identity.ts`) reads it back as the +fallback source for the template's original namespace when the tree carries no +`objectstack.config.ts` to read it from — the remote-template shape #4902 +fixed. The getting-started guide has advertised the key on that file all along. + +`TemplateManifestSchema` was silent about it, and silence here is not neutral: +the schema is a default strip-mode object, so anything validating the manifest +through it **dropped the key and answered success**, and a malformed value was +accepted rather than refused. That is the ADR-0049 enforce-or-remove shape, and +the key is genuinely live, so this is the ENFORCE leg — declare it, do not +remove it. + +`namespace` is now declared on `TemplateManifestSchema`, optional, reusing +`CreatePackageRequestSchema.shape.namespace`'s value constraints so the scaffold +surface and the publish surface judge every namespace identically (ADR-0048 +addendum §A.7, "two gates, one vocabulary"). Two consequences for anyone parsing +a template manifest: the key now **survives** the parse instead of being +stripped, and a malformed value is now **rejected** at the `namespace` path with +the shared coded message instead of passing green. + +What deliberately did **not** change is the publish surface. The publish +payload's namespace is still read off the compiled artifact's +`manifest.namespace` (ADR-0048 addendum §A.2 Phase A1), never off this file, +because a reservation is only meaningful if it names the object-name prefix the +package really ships. The field is therefore re-declared rather than inherited: +the `.omit()` of the create-request field stays, and the scaffold field carries +its own describe saying in as many words that it is scaffold-only and not the +publish namespace. Collapsing the two into one inherited field would make the +on-disk descriptor a second way to reserve a namespace — the drift the addendum +rules out, and the reason #6760 omitted the key in the first place. diff --git a/content/docs/references/cloud/template-manifest.mdx b/content/docs/references/cloud/template-manifest.mdx index 9ba4a1a3e2..0c07c43f1a 100644 --- a/content/docs/references/cloud/template-manifest.mdx +++ b/content/docs/references/cloud/template-manifest.mdx @@ -8,7 +8,7 @@ description: Template Manifest protocol schemas `objectstack.manifest.json` — on-disk descriptor for a template / package source tree. Strict projection of `CreatePackageRequestSchema` (server- managed fields excluded) plus scaffold-time extras (name slug, -specVersion, skills, preview, scaffold, readmePath). +specVersion, namespace, skills, preview, scaffold, readmePath). **Source:** `packages/spec/src/cloud/template-manifest.zod.ts` @@ -48,6 +48,7 @@ objectstack.manifest.json — template / package source descriptor | **translations** | `Record` | optional | Locale-keyed overrides; missing keys fall back to base columns | | **name** | `string` | ✅ | CLI slug (kebab-case, no namespace prefix) | | **specVersion** | `string` | ✅ | Compatible @objectstack/spec semver range | +| **namespace** | `string` | optional | Scaffold-only: the template’s own metadata namespace, rewritten by create-objectstack at scaffold time and read back as the fallback source for the template’s original namespace. NOT the publish namespace — publish reads that off the compiled artifact’s manifest.namespace (ADR-0048 addendum §A.2) | | **skills** | `string[]` | optional | Skill ids exercised by this template (for docs / picker) | | **preview** | `{ screenshots?: string[]; demoUrl?: string }` | optional | | | **scaffold** | `{ variables?: Record; postInstall?: string[] }` | optional | | diff --git a/packages/spec/authorable-surface/cloud.json b/packages/spec/authorable-surface/cloud.json index a4d95d058d..ecfbbe51d8 100644 --- a/packages/spec/authorable-surface/cloud.json +++ b/packages/spec/authorable-surface/cloud.json @@ -416,6 +416,7 @@ "cloud/TemplateManifest:license", "cloud/TemplateManifest:manifestId", "cloud/TemplateManifest:name", + "cloud/TemplateManifest:namespace", "cloud/TemplateManifest:preview", "cloud/TemplateManifest:publisher", "cloud/TemplateManifest:readmePath", diff --git a/packages/spec/src/cloud/package-namespace.test.ts b/packages/spec/src/cloud/package-namespace.test.ts index 76ed919d2e..03f6d0565d 100644 --- a/packages/spec/src/cloud/package-namespace.test.ts +++ b/packages/spec/src/cloud/package-namespace.test.ts @@ -7,9 +7,17 @@ * 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. + * algorithm opens with `if (namespace is absent) -> allow`), and it judges + * values exactly as `manifest.namespace` does (§A.7 "two gates, one + * vocabulary"). + * + * `TemplateManifestSchema` re-declares the key as a SCAFFOLD-ONLY extra + * (#6861, ADR-0049 enforce leg): the on-disk `objectstack.manifest.json` + * really carries it — written by the template author, rewritten by + * `create-objectstack`, read back by `readTemplateNamespace` — so the schema + * that claims to describe that file declares it instead of silently stripping + * it. It shares this vocabulary and is NOT a second publish surface; the pins + * below hold both halves of that split. */ import { describe, it, expect } from 'vitest'; @@ -54,6 +62,10 @@ const NAMESPACE_CASES: ReadonlyArray = [ ['my_app_2', true], ['base', true], // shareable at the GATE (D3), still a well-formed string ['sys', true], + // The literal value the bundled blank template ships in + // `packages/create-objectstack/src/templates/blank/objectstack.manifest.json` + // — the scaffold surface's only in-repo instance (#6861). + ['blank', true], ['abcdefghijklmnopqrst', true], // 20 chars — the upper bound ['a', false], // 1 char — under the lower bound ['abcdefghijklmnopqrstu', false], // 21 chars — over the upper bound @@ -124,6 +136,10 @@ describe('two gates, one vocabulary (§A.7)', () => { const manifestField = ManifestSchema.shape.namespace; const payloadField = PackageSchema.shape.namespace; const requestField = CreatePackageRequestSchema.shape.namespace; + // The scaffold surface is a different MEANING but the same VOCABULARY + // (#6861) — a value legal on one side must be legal on the other, or + // `create-objectstack` could stamp a namespace publish would later refuse. + const templateField = TemplateManifestSchema.shape.namespace; const verdicts = NAMESPACE_CASES.map(([value, expected]) => ({ value, @@ -131,25 +147,87 @@ describe('two gates, one vocabulary (§A.7)', () => { manifest: manifestField.safeParse(value).success, payload: payloadField.safeParse(value).success, request: requestField.safeParse(value).success, + template: templateField.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, + v.manifest !== v.expected || v.payload !== v.expected + || v.request !== v.expected || v.template !== v.expected, )).toEqual([]); }); - it('both fields are optional, so "absent" means the same thing on both sides', () => { + it('every field is optional, so "absent" means the same thing on all 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); + expect(TemplateManifestSchema.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. +describe('TemplateManifestSchema declares namespace as a scaffold-only extra (#6861)', () => { + /** A template manifest that is valid except for whatever a case changes. */ + function templateManifest(overrides: Record = {}) { + return { + manifestId: 'com.acme.blank', + displayName: 'Blank Starter', + name: 'blank', + specVersion: '^6.0.0', + ...overrides, + }; + } + + it('declares the field, alongside the rest of the create-request projection', () => { + expect(Object.keys(TemplateManifestSchema.shape)).toContain('namespace'); expect(Object.keys(TemplateManifestSchema.shape)).toContain('manifestId'); }); + + it('SURVIVES the parse — the key is no longer silently stripped', () => { + // The pin this issue exists for. Before `namespace` was declared, this + // schema's default strip mode dropped a key the scaffolder writes, rewrites + // and reads back: `parse()` succeeded and answered without it. + const parsed = TemplateManifestSchema.parse(templateManifest({ namespace: 'blank' })); + expect('namespace' in parsed).toBe(true); + expect(parsed.namespace).toBe('blank'); + }); + + it('is OPTIONAL — a template-registry manifest declaring none still parses', () => { + // The remote-template shape (#4902): no namespace anywhere on the file, and + // `readTemplateNamespace` correctly yields undefined for it. + const parsed = TemplateManifestSchema.parse(templateManifest()); + expect(parsed.namespace).toBeUndefined(); + }); + + it('REJECTS a malformed namespace with the shared coded issue at the namespace path', () => { + // The enforce half of ADR-0049: before the declaration a bad value parsed + // green (stripped), so the schema had no opinion on a key it described. + const result = TemplateManifestSchema.safeParse(templateManifest({ 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('says scaffold-only in its describe, and is NOT the publish field', () => { + // Two distinct schema instances carrying two distinct meanings. Collapsing + // them — by dropping the `.omit()` and inheriting the create-request field — + // would make this on-disk file a second way to reserve a namespace, which + // is exactly what the ADR-0048 addendum (§A.2 / §A.7) rules out. + const templateField = TemplateManifestSchema.shape.namespace; + const publishField = CreatePackageRequestSchema.shape.namespace; + expect(templateField).not.toBe(publishField); + + const templateDoc = templateField.description ?? ''; + expect(templateDoc).toMatch(/scaffold-only/i); + expect(templateDoc).toMatch(/NOT the publish namespace/i); + + // …and the publish field's own description is untouched by that split. + expect(publishField.description).toBe( + 'Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account")', + ); + }); }); diff --git a/packages/spec/src/cloud/template-manifest.zod.ts b/packages/spec/src/cloud/template-manifest.zod.ts index 6023e80c80..e9d5cee92f 100644 --- a/packages/spec/src/cloud/template-manifest.zod.ts +++ b/packages/spec/src/cloud/template-manifest.zod.ts @@ -4,7 +4,7 @@ * `objectstack.manifest.json` — on-disk descriptor for a template / package * source tree. Strict projection of `CreatePackageRequestSchema` (server- * managed fields excluded) plus scaffold-time extras (name slug, - * specVersion, skills, preview, scaffold, readmePath). + * specVersion, namespace, skills, preview, scaffold, readmePath). */ import { z } from 'zod'; @@ -13,18 +13,57 @@ import { CreatePackageRequestSchema } from './package.zod'; export const TemplateManifestSchema = lazySchema(() => CreatePackageRequestSchema - // `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. + // `namespace` is omitted from the inherited create-request projection and + // RE-DECLARED below as a scaffold-only extra. The two surfaces are split + // deliberately, and the omit-then-extend is what keeps the split visible: + // + // publish — the publish payload's namespace is read off the COMPILED + // ARTIFACT's `manifest.namespace` (ADR-0048 addendum §A.2 + // Phase A1), never off this file, because a reservation is + // only meaningful if it names the object-name prefix the + // package actually ships. Inheriting the create-request field + // here would make this file a second source for that one fact + // — the drift the addendum's "two gates, one vocabulary" + // (§A.7) rules out. + // scaffold — the key is nonetheless LIVE on this file: the blank template + // ships it, `create-objectstack` rewrites it when stamping a + // new project, and `rewrite-identity.ts` reads it back as the + // fallback source for the template's original namespace. A + // schema that claims to describe this file and stays silent + // about a key it strips is lying by omission, so ADR-0049's + // enforce leg says declare it (#6861). + // + // Value constraints are reused from the publish field (one vocabulary, + // §A.7); only the meaning differs, and the describe says so. .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)'), specVersion: z.string().describe('Compatible @objectstack/spec semver range'), + /** + * The template's OWN metadata namespace — a scaffold-time value, not a + * publish-time claim. + * + * Written by whoever authors the template, rewritten in place by + * `create-objectstack` when it stamps a new project + * (`packages/create-objectstack/src/index.ts`), and read back by + * `readTemplateNamespace` (`packages/create-objectstack/src/rewrite-identity.ts`) + * as the FALLBACK source for the template's original namespace when the + * tree carries no `objectstack.config.ts` to read it from — the remote- + * template shape #4902 fixed. + * + * NOT the publish-surface namespace. `objectstack package publish` reads + * that off the compiled artifact's `manifest.namespace` (ADR-0048 + * addendum §A.2 Phase A1) and never off this file, so declaring it here + * does not create a second way to reserve a namespace. + * + * Optional, matching `manifest.namespace` and the create-request field: + * a template-registry manifest carries no namespace at all, and + * `readTemplateNamespace` correctly yields `undefined` for it. + */ + namespace: CreatePackageRequestSchema.shape.namespace.describe( + 'Scaffold-only: the template’s own metadata namespace, rewritten by create-objectstack at scaffold time and read back as the fallback source for the template’s original namespace. NOT the publish namespace — publish reads that off the compiled artifact’s manifest.namespace (ADR-0048 addendum §A.2)' + ), skills: z.array(z.string()).optional() .describe('Skill ids exercised by this template (for docs / picker)'), preview: z.object({