Skip to content

Commit a47f338

Browse files
os-zhuangclaude
andauthored
feat(spec): declare namespace on TemplateManifestSchema as a scaffold-only extra (#6861) (#7317)
`objectstack.manifest.json` carries a live `namespace` key that the schema describing that file did not declare: the blank template ships it, `create-objectstack` rewrites it at scaffold time, and `readTemplateNamespace` reads it back as the fallback source for the template's original namespace. The schema is a default strip-mode object, so parsing the manifest through it dropped the key and answered success, and a malformed value passed green. ADR-0049 enforce leg: declare it, do not remove it. The field reuses `CreatePackageRequestSchema.shape.namespace`'s constraints (one vocabulary, ADR-0048 addendum §A.7) but is re-declared rather than inherited, carrying a describe that says it is scaffold-only and NOT the publish namespace — publish still reads that off the compiled artifact's `manifest.namespace` (§A.2), so the second-authoring-surface footgun #6760 avoided stays avoided. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c39a911 commit a47f338

5 files changed

Lines changed: 177 additions & 18 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `namespace` on `TemplateManifestSchema` as a scaffold-only extra (#6861)
6+
7+
`objectstack.manifest.json` carries a live `namespace` key that the schema
8+
claiming to describe that file did not declare. The bundled blank template
9+
ships `"namespace": "blank"`; `create-objectstack` rewrites the key in place
10+
when it stamps a new project; and `readTemplateNamespace`
11+
(`packages/create-objectstack/src/rewrite-identity.ts`) reads it back as the
12+
fallback source for the template's original namespace when the tree carries no
13+
`objectstack.config.ts` to read it from — the remote-template shape #4902
14+
fixed. The getting-started guide has advertised the key on that file all along.
15+
16+
`TemplateManifestSchema` was silent about it, and silence here is not neutral:
17+
the schema is a default strip-mode object, so anything validating the manifest
18+
through it **dropped the key and answered success**, and a malformed value was
19+
accepted rather than refused. That is the ADR-0049 enforce-or-remove shape, and
20+
the key is genuinely live, so this is the ENFORCE leg — declare it, do not
21+
remove it.
22+
23+
`namespace` is now declared on `TemplateManifestSchema`, optional, reusing
24+
`CreatePackageRequestSchema.shape.namespace`'s value constraints so the scaffold
25+
surface and the publish surface judge every namespace identically (ADR-0048
26+
addendum §A.7, "two gates, one vocabulary"). Two consequences for anyone parsing
27+
a template manifest: the key now **survives** the parse instead of being
28+
stripped, and a malformed value is now **rejected** at the `namespace` path with
29+
the shared coded message instead of passing green.
30+
31+
What deliberately did **not** change is the publish surface. The publish
32+
payload's namespace is still read off the compiled artifact's
33+
`manifest.namespace` (ADR-0048 addendum §A.2 Phase A1), never off this file,
34+
because a reservation is only meaningful if it names the object-name prefix the
35+
package really ships. The field is therefore re-declared rather than inherited:
36+
the `.omit()` of the create-request field stays, and the scaffold field carries
37+
its own describe saying in as many words that it is scaffold-only and not the
38+
publish namespace. Collapsing the two into one inherited field would make the
39+
on-disk descriptor a second way to reserve a namespace — the drift the addendum
40+
rules out, and the reason #6760 omitted the key in the first place.

content/docs/references/cloud/template-manifest.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description: Template Manifest protocol schemas
88
`objectstack.manifest.json` — on-disk descriptor for a template / package
99
source tree. Strict projection of `CreatePackageRequestSchema` (server-
1010
managed fields excluded) plus scaffold-time extras (name slug,
11-
specVersion, skills, preview, scaffold, readmePath).
11+
specVersion, namespace, skills, preview, scaffold, readmePath).
1212

1313
<Callout type="info">
1414
**Source:** `packages/spec/src/cloud/template-manifest.zod.ts`
@@ -48,6 +48,7 @@ objectstack.manifest.json — template / package source descriptor
4848
| **translations** | `Record<string, { displayName?: string; description?: string; readme?: string; tagline?: string; … }>` | optional | Locale-keyed overrides; missing keys fall back to base columns |
4949
| **name** | `string` || CLI slug (kebab-case, no namespace prefix) |
5050
| **specVersion** | `string` || Compatible @objectstack/spec semver range |
51+
| **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) |
5152
| **skills** | `string[]` | optional | Skill ids exercised by this template (for docs / picker) |
5253
| **preview** | `{ screenshots?: string[]; demoUrl?: string }` | optional | |
5354
| **scaffold** | `{ variables?: Record<string, any>; postInstall?: string[] }` | optional | |

packages/spec/authorable-surface/cloud.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@
416416
"cloud/TemplateManifest:license",
417417
"cloud/TemplateManifest:manifestId",
418418
"cloud/TemplateManifest:name",
419+
"cloud/TemplateManifest:namespace",
419420
"cloud/TemplateManifest:preview",
420421
"cloud/TemplateManifest:publisher",
421422
"cloud/TemplateManifest:readmePath",

packages/spec/src/cloud/package-namespace.test.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@
77
* keyed on the bare namespace (D1) and can check nothing unless the namespace
88
* leaves the artifact. These are the acceptance-face pins for the open side of
99
* that contract: the field exists on both schemas, it is OPTIONAL (§A.2's
10-
* algorithm opens with `if (namespace is absent) -> allow`), it judges values
11-
* exactly as `manifest.namespace` does (§A.7 "two gates, one vocabulary"), and
12-
* the on-disk template descriptor deliberately does NOT declare it.
10+
* algorithm opens with `if (namespace is absent) -> allow`), and it judges
11+
* values exactly as `manifest.namespace` does (§A.7 "two gates, one
12+
* vocabulary").
13+
*
14+
* `TemplateManifestSchema` re-declares the key as a SCAFFOLD-ONLY extra
15+
* (#6861, ADR-0049 enforce leg): the on-disk `objectstack.manifest.json`
16+
* really carries it — written by the template author, rewritten by
17+
* `create-objectstack`, read back by `readTemplateNamespace` — so the schema
18+
* that claims to describe that file declares it instead of silently stripping
19+
* it. It shares this vocabulary and is NOT a second publish surface; the pins
20+
* below hold both halves of that split.
1321
*/
1422

1523
import { describe, it, expect } from 'vitest';
@@ -54,6 +62,10 @@ const NAMESPACE_CASES: ReadonlyArray<readonly [string, boolean]> = [
5462
['my_app_2', true],
5563
['base', true], // shareable at the GATE (D3), still a well-formed string
5664
['sys', true],
65+
// The literal value the bundled blank template ships in
66+
// `packages/create-objectstack/src/templates/blank/objectstack.manifest.json`
67+
// — the scaffold surface's only in-repo instance (#6861).
68+
['blank', true],
5769
['abcdefghijklmnopqrst', true], // 20 chars — the upper bound
5870
['a', false], // 1 char — under the lower bound
5971
['abcdefghijklmnopqrstu', false], // 21 chars — over the upper bound
@@ -124,32 +136,98 @@ describe('two gates, one vocabulary (§A.7)', () => {
124136
const manifestField = ManifestSchema.shape.namespace;
125137
const payloadField = PackageSchema.shape.namespace;
126138
const requestField = CreatePackageRequestSchema.shape.namespace;
139+
// The scaffold surface is a different MEANING but the same VOCABULARY
140+
// (#6861) — a value legal on one side must be legal on the other, or
141+
// `create-objectstack` could stamp a namespace publish would later refuse.
142+
const templateField = TemplateManifestSchema.shape.namespace;
127143

128144
const verdicts = NAMESPACE_CASES.map(([value, expected]) => ({
129145
value,
130146
expected,
131147
manifest: manifestField.safeParse(value).success,
132148
payload: payloadField.safeParse(value).success,
133149
request: requestField.safeParse(value).success,
150+
template: templateField.safeParse(value).success,
134151
}));
135152

136153
// One assertion over the whole table so a drift names the offending value.
137154
expect(verdicts.filter((v) =>
138-
v.manifest !== v.expected || v.payload !== v.expected || v.request !== v.expected,
155+
v.manifest !== v.expected || v.payload !== v.expected
156+
|| v.request !== v.expected || v.template !== v.expected,
139157
)).toEqual([]);
140158
});
141159

142-
it('both fields are optional, so "absent" means the same thing on both sides', () => {
160+
it('every field is optional, so "absent" means the same thing on all sides', () => {
143161
expect(ManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
144162
expect(PackageSchema.shape.namespace.safeParse(undefined).success).toBe(true);
145163
expect(CreatePackageRequestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
164+
expect(TemplateManifestSchema.shape.namespace.safeParse(undefined).success).toBe(true);
146165
});
147166
});
148167

149-
describe('TemplateManifestSchema does not inherit namespace', () => {
150-
it('omits it deliberately — the publish namespace comes from the compiled artifact', () => {
151-
expect(Object.keys(TemplateManifestSchema.shape)).not.toContain('namespace');
152-
// And the projection still carries the rest of the create-request surface.
168+
describe('TemplateManifestSchema declares namespace as a scaffold-only extra (#6861)', () => {
169+
/** A template manifest that is valid except for whatever a case changes. */
170+
function templateManifest(overrides: Record<string, unknown> = {}) {
171+
return {
172+
manifestId: 'com.acme.blank',
173+
displayName: 'Blank Starter',
174+
name: 'blank',
175+
specVersion: '^6.0.0',
176+
...overrides,
177+
};
178+
}
179+
180+
it('declares the field, alongside the rest of the create-request projection', () => {
181+
expect(Object.keys(TemplateManifestSchema.shape)).toContain('namespace');
153182
expect(Object.keys(TemplateManifestSchema.shape)).toContain('manifestId');
154183
});
184+
185+
it('SURVIVES the parse — the key is no longer silently stripped', () => {
186+
// The pin this issue exists for. Before `namespace` was declared, this
187+
// schema's default strip mode dropped a key the scaffolder writes, rewrites
188+
// and reads back: `parse()` succeeded and answered without it.
189+
const parsed = TemplateManifestSchema.parse(templateManifest({ namespace: 'blank' }));
190+
expect('namespace' in parsed).toBe(true);
191+
expect(parsed.namespace).toBe('blank');
192+
});
193+
194+
it('is OPTIONAL — a template-registry manifest declaring none still parses', () => {
195+
// The remote-template shape (#4902): no namespace anywhere on the file, and
196+
// `readTemplateNamespace` correctly yields undefined for it.
197+
const parsed = TemplateManifestSchema.parse(templateManifest());
198+
expect(parsed.namespace).toBeUndefined();
199+
});
200+
201+
it('REJECTS a malformed namespace with the shared coded issue at the namespace path', () => {
202+
// The enforce half of ADR-0049: before the declaration a bad value parsed
203+
// green (stripped), so the schema had no opinion on a key it described.
204+
const result = TemplateManifestSchema.safeParse(templateManifest({ namespace: 'CRM-App' }));
205+
expect(result.success).toBe(false);
206+
const issues = result.success ? [] : result.error.issues;
207+
expect(issues).toHaveLength(1);
208+
expect(issues[0].code).toBe('invalid_format');
209+
expect(issues[0].path).toEqual(['namespace']);
210+
expect(issues[0].message).toBe(
211+
'Namespace must be 2-20 chars, lowercase alphanumeric + underscore',
212+
);
213+
});
214+
215+
it('says scaffold-only in its describe, and is NOT the publish field', () => {
216+
// Two distinct schema instances carrying two distinct meanings. Collapsing
217+
// them — by dropping the `.omit()` and inheriting the create-request field —
218+
// would make this on-disk file a second way to reserve a namespace, which
219+
// is exactly what the ADR-0048 addendum (§A.2 / §A.7) rules out.
220+
const templateField = TemplateManifestSchema.shape.namespace;
221+
const publishField = CreatePackageRequestSchema.shape.namespace;
222+
expect(templateField).not.toBe(publishField);
223+
224+
const templateDoc = templateField.description ?? '';
225+
expect(templateDoc).toMatch(/scaffold-only/i);
226+
expect(templateDoc).toMatch(/NOT the publish namespace/i);
227+
228+
// …and the publish field's own description is untouched by that split.
229+
expect(publishField.description).toBe(
230+
'Metadata namespace claimed by the package (mirrors manifest.namespace; e.g. "crm" → object names "crm_account")',
231+
);
232+
});
155233
});

packages/spec/src/cloud/template-manifest.zod.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* `objectstack.manifest.json` — on-disk descriptor for a template / package
55
* source tree. Strict projection of `CreatePackageRequestSchema` (server-
66
* managed fields excluded) plus scaffold-time extras (name slug,
7-
* specVersion, skills, preview, scaffold, readmePath).
7+
* specVersion, namespace, skills, preview, scaffold, readmePath).
88
*/
99

1010
import { z } from 'zod';
@@ -13,18 +13,57 @@ import { CreatePackageRequestSchema } from './package.zod';
1313

1414
export const TemplateManifestSchema = lazySchema(() =>
1515
CreatePackageRequestSchema
16-
// `namespace` is omitted alongside the server-managed fields, and for the
17-
// same reason: it is not authored here. The publish payload's namespace is
18-
// read off the COMPILED ARTIFACT's `manifest.namespace` (ADR-0048 addendum
19-
// §A.2 Phase A1), because a reservation is only meaningful if it names the
20-
// object-name prefix the package actually ships. Declaring it on this
21-
// on-disk descriptor too would create a second source for one fact — the
22-
// exact drift the addendum's "two gates, one vocabulary" (§A.7) rules out.
16+
// `namespace` is omitted from the inherited create-request projection and
17+
// RE-DECLARED below as a scaffold-only extra. The two surfaces are split
18+
// deliberately, and the omit-then-extend is what keeps the split visible:
19+
//
20+
// publish — the publish payload's namespace is read off the COMPILED
21+
// ARTIFACT's `manifest.namespace` (ADR-0048 addendum §A.2
22+
// Phase A1), never off this file, because a reservation is
23+
// only meaningful if it names the object-name prefix the
24+
// package actually ships. Inheriting the create-request field
25+
// here would make this file a second source for that one fact
26+
// — the drift the addendum's "two gates, one vocabulary"
27+
// (§A.7) rules out.
28+
// scaffold — the key is nonetheless LIVE on this file: the blank template
29+
// ships it, `create-objectstack` rewrites it when stamping a
30+
// new project, and `rewrite-identity.ts` reads it back as the
31+
// fallback source for the template's original namespace. A
32+
// schema that claims to describe this file and stays silent
33+
// about a key it strips is lying by omission, so ADR-0049's
34+
// enforce leg says declare it (#6861).
35+
//
36+
// Value constraints are reused from the publish field (one vocabulary,
37+
// §A.7); only the meaning differs, and the describe says so.
2338
.omit({ ownerOrgId: true, createdBy: true, namespace: true })
2439
.extend({
2540
name: z.string().regex(/^[a-z][a-z0-9-]*$/)
2641
.describe('CLI slug (kebab-case, no namespace prefix)'),
2742
specVersion: z.string().describe('Compatible @objectstack/spec semver range'),
43+
/**
44+
* The template's OWN metadata namespace — a scaffold-time value, not a
45+
* publish-time claim.
46+
*
47+
* Written by whoever authors the template, rewritten in place by
48+
* `create-objectstack` when it stamps a new project
49+
* (`packages/create-objectstack/src/index.ts`), and read back by
50+
* `readTemplateNamespace` (`packages/create-objectstack/src/rewrite-identity.ts`)
51+
* as the FALLBACK source for the template's original namespace when the
52+
* tree carries no `objectstack.config.ts` to read it from — the remote-
53+
* template shape #4902 fixed.
54+
*
55+
* NOT the publish-surface namespace. `objectstack package publish` reads
56+
* that off the compiled artifact's `manifest.namespace` (ADR-0048
57+
* addendum §A.2 Phase A1) and never off this file, so declaring it here
58+
* does not create a second way to reserve a namespace.
59+
*
60+
* Optional, matching `manifest.namespace` and the create-request field:
61+
* a template-registry manifest carries no namespace at all, and
62+
* `readTemplateNamespace` correctly yields `undefined` for it.
63+
*/
64+
namespace: CreatePackageRequestSchema.shape.namespace.describe(
65+
'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)'
66+
),
2867
skills: z.array(z.string()).optional()
2968
.describe('Skill ids exercised by this template (for docs / picker)'),
3069
preview: z.object({

0 commit comments

Comments
 (0)