Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/template-manifest-scaffold-namespace.md
Original file line numberDiff line numberDiff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/cloud/template-manifest.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).

<Callout type="info">
**Source:** `packages/spec/src/cloud/template-manifest.zod.ts`
Expand DownExpand Up@@ -48,6 +48,7 @@ objectstack.manifest.json — template / package source descriptor
| **translations** | `Record<string, { displayName?: string; description?: string; readme?: string; tagline?: string; … }>` | 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<string, any>; postInstall?: string[] }` | optional | |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/cloud.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
96 changes: 87 additions & 9 deletions packages/spec/src/cloud/package-namespace.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -54,6 +62,10 @@ const NAMESPACE_CASES: ReadonlyArray<readonly [string, boolean]> = [
['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
Expand DownExpand Up@@ -124,32 +136,98 @@ 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,
expected,
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<string, unknown> = {}) {
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")',
);
});
});
55 changes: 47 additions & 8 deletions packages/spec/src/cloud/template-manifest.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -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({
Expand Down
Loading