Skip to content

Commit f7dab5b

Browse files
committed
feat(spec): make manifestId optional on TemplateManifestSchema and gate every shipped template manifest (#7319)
`TemplateManifestSchema` describes the on-disk `objectstack.manifest.json`, and the bundled blank template declares it as its `$schema` — but the file did not satisfy it. The schema inherited `manifestId` from `CreatePackageRequestSchema` as required, and no template has ever declared one. Measured on main, that was the only complaint the shipped file produced: manifestId: Invalid input: expected string, received undefined (invalid_type) Nothing broke because nothing parsed the file: `create-objectstack` reads and rewrites it as raw JSON and does not depend on the spec at all, and `objectstack package publish` also reads it raw, resolving the id as `--manifest-id ?? m.manifestId ?? deriveManifestId(artifact, path)`. That fallback is the measurement — on this file the id is a declarative default, not a requirement. Two changes: 1. `manifestId` is relaxed to optional on `TemplateManifestSchema`, declared LOCALLY: the inherited field is omitted and re-declared `.optional()`, reusing the publish field's value constraints — the same omit-then-extend split `namespace` uses (#6861), in the other direction. A malformed id is still rejected. `CreatePackageRequestSchema` is untouched: the publish request still requires `manifestId`, because a request with no package identity is not a request. 2. New gate `check:template-manifests` parses every `objectstack.manifest.json` under `packages/create-objectstack/src/templates/` against the schema, wired unfiltered into the required `TypeScript Type Check` job and classified in the `check:generated` ledger as a source audit. It is the check that would have caught both drifts this file has accumulated (#6861's stripped `namespace`, this one), so the `$schema` line stops being an unverified claim. It walks the tree rather than a file list, and fails rather than reporting success when it finds nothing to parse. Pins in `template-manifest-id.test.ts` hold both halves: optional-but-validated on the descriptor, still-required on the publish request, distinct field instances. Reference docs regenerated.
1 parent f188ed6 commit f7dab5b

8 files changed

Lines changed: 535 additions & 4 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `manifestId` is optional on `TemplateManifestSchema`, and every shipped template manifest is now parsed against it (#7319)
6+
7+
`TemplateManifestSchema` describes the on-disk `objectstack.manifest.json`, and
8+
the bundled blank template declares it as its `$schema` — but the file did not
9+
satisfy it. The schema inherited `manifestId` from `CreatePackageRequestSchema`
10+
as **required**, and no template has ever declared one. Measured on `main`, that
11+
was the *only* complaint the shipped file produced:
12+
13+
```
14+
manifestId: Invalid input: expected string, received undefined (invalid_type)
15+
```
16+
17+
Nothing broke, because nothing parsed the file. `create-objectstack` reads and
18+
rewrites it as raw JSON and does not depend on `@objectstack/spec` at all;
19+
`objectstack package publish` also reads it raw, resolving the id as
20+
`--manifest-id ?? manifest.manifestId ?? deriveManifestId(artifact, path)`. That
21+
fallback is the measurement: on this file the id is a declarative **default**,
22+
not a requirement — a template tree that declares none publishes fine, deriving
23+
`local.<slug>` from the compiled artifact.
24+
25+
**The key is now optional on the on-disk descriptor**, declared locally rather
26+
than by loosening the shared base:
27+
28+
- `TemplateManifestSchema` omits the inherited field and re-declares it
29+
`.optional()`, reusing the publish field's value constraints — the same
30+
omit-then-extend split `namespace` uses (#6861), in the other direction. A
31+
malformed id is still rejected: optional is not unvalidated.
32+
- **`CreatePackageRequestSchema` is untouched.** The publish request that reaches
33+
the control plane still requires `manifestId` — the package row is addressed
34+
by it and it is immutable once set. Widening the base would have made a publish
35+
request with no package identity parse, which is the collapse the local
36+
override exists to avoid.
37+
38+
Authoring is unchanged in the accepting direction: a manifest that declares a
39+
`manifestId` still parses exactly as before, and every other required key
40+
(`displayName`, `name`, `specVersion`) is still required.
41+
42+
**New gate — `check:template-manifests`.** Every `objectstack.manifest.json`
43+
under `packages/create-objectstack/src/templates/` is parsed against
44+
`TemplateManifestSchema` on every PR (unfiltered, in the required
45+
`TypeScript Type Check` job). This is the check that would have caught both
46+
drifts this file has now accumulated — #6861's silently stripped `namespace` and
47+
this one — so the `$schema` line those files carry stops being an unverified
48+
claim. It walks the template tree rather than a hand-kept file list, so a
49+
template added later is covered on the day it lands, and it fails rather than
50+
reporting success if it finds nothing to parse.

.github/workflows/lint.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,20 @@ jobs:
10531053
- name: Check the react-blocks contract is in sync with the spec
10541054
run: pnpm --filter @objectstack/spec check:react-blocks
10551055

1056+
# The shipped `objectstack.manifest.json` files declare TemplateManifestSchema
1057+
# as their `$schema`, and until #7319 nothing read that claim: both consumers
1058+
# take the file as raw JSON (create-objectstack does not depend on the spec at
1059+
# all), so the schema and the artifact drifted twice without a red build —
1060+
# #6861's silently stripped `namespace`, then a required `manifestId` the blank
1061+
# template has never carried. One parse per shipped manifest closes the class.
1062+
#
1063+
# Reads `src/` and the template trees, so it needs no build and belongs in this
1064+
# pre-build group with the other source audits. No paths filter and required,
1065+
# for the standard reason: a filter on packages/create-objectstack/** would go
1066+
# dormant on exactly the PR that tightens the schema instead.
1067+
- name: Check every shipped template manifest satisfies TemplateManifestSchema
1068+
run: pnpm --filter @objectstack/spec check:template-manifests
1069+
10561070
# Example apps are AI-authoring reference templates; a red typecheck is a
10571071
# bad signal to copy from. tsup transpiles them without a full typecheck,
10581072
# so build alone will not catch type drift — typecheck them explicitly.

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ 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, namespace, skills, preview, scaffold, readmePath).
11+
specVersion, namespace, skills, preview, scaffold, readmePath), with
12+
`manifestId` locally relaxed to OPTIONAL — this file is a source tree, not
13+
a publish request (#7319; the field's own TSDoc carries the measurement).
14+
15+
Every shipped `objectstack.manifest.json` is parsed against this schema by
16+
`pnpm --filter @objectstack/spec check:template-manifests`, so the
17+
`$schema` line those files carry is a verified claim rather than an
18+
assertion nothing reads.
1219

1320
<Callout type="info">
1421
**Source:** `packages/spec/src/cloud/template-manifest.zod.ts`
@@ -34,7 +41,6 @@ objectstack.manifest.json — template / package source descriptor
3441

3542
| Property | Type | Required | Description |
3643
| :--- | :--- | :--- | :--- |
37-
| **manifestId** | `string` || Globally unique reverse-domain package identifier (e.g. com.acme.crm) |
3844
| **displayName** | `string` || Display name shown in Studio and Marketplace |
3945
| **description** | `string` | optional | Short package description |
4046
| **visibility** | `Enum<'private' \| 'org' \| 'marketplace'>` | optional | Package visibility: private = owner org only; org = all envs in owner org; marketplace = public registry |
@@ -46,6 +52,7 @@ objectstack.manifest.json — template / package source descriptor
4652
| **publisher** | `Enum<'objectstack' \| 'partner' \| 'community' \| 'private'>` | optional | Package publisher provenance tier |
4753
| **isStarter** | `boolean` | optional | |
4854
| **translations** | `Record<string, { displayName?: string; description?: string; readme?: string; tagline?: string; … }>` | optional | Locale-keyed overrides; missing keys fall back to base columns |
55+
| **manifestId** | `string` | optional | Optional declarative default for the published package id (reverse-domain, e.g. com.acme.crm). Absent on a template source tree: `objectstack package publish` falls back to --manifest-id and then to a derived `local.<slug>`. NOT optional on the publish request itself |
4956
| **name** | `string` || CLI slug (kebab-case, no namespace prefix) |
5057
| **specVersion** | `string` || Compatible @objectstack/spec semver range |
5158
| **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) |

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@
220220
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
221221
"check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts",
222222
"check:skill-examples": "tsx scripts/check-skill-examples.ts --self-test && tsx scripts/check-skill-examples.ts",
223+
"check:template-manifests": "tsx scripts/check-template-manifests.ts --self-test && tsx scripts/check-template-manifests.ts",
223224
"check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/spec --project tsconfig.test.json",
224225
"gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/spec --project tsconfig.test.json",
225226
"check:scripts-typecheck": "tsc --noEmit -p tsconfig.scripts.json",

packages/spec/scripts/check-generated.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,17 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
154154
{ check: 'check:liveness', why: 'audits whether declared spec properties have a reader — no artifact' },
155155
{ check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' },
156156
{ check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' },
157+
// #7319. Reads `src/` and the shipped template trees and writes nothing: a
158+
// failure is either a manifest to fix or a schema to fix, never a `gen:` to
159+
// run. It audits the inverse direction from everything in GATED — those
160+
// compare an artifact this package GENERATES against its source, this one
161+
// compares a file another package SHIPS against the schema that claims to
162+
// describe it. Two drifts had already accumulated in that blind spot (#6861's
163+
// stripped `namespace`, #7319's required-but-absent `manifestId`).
164+
{
165+
check: 'check:template-manifests',
166+
why: 'parses every shipped objectstack.manifest.json against TemplateManifestSchema — no artifact',
167+
},
157168
// Landed in #4177 while this ledger landed in #4183 — neither PR could see the
158169
// other, so `main` carried an unclassified script and this reconciliation was
159170
// failing on `main` itself. The doc it checks against is hand-written, so there

0 commit comments

Comments
 (0)