diff --git a/.changeset/manifest-open-object-prose-sweep.md b/.changeset/manifest-open-object-prose-sweep.md new file mode 100644 index 0000000000..de9df125f0 --- /dev/null +++ b/.changeset/manifest-open-object-prose-sweep.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": patch +"@objectstack/core": patch +--- + +Documentation: the manifest surface no longer describes itself as an open object. + +`ManifestSchema` became a `strictObject` when the manifest surface was closed against unknown keys, but five prose sites still described the earlier posture. They shipped, so an author (or an AI writing metadata) reading the declarations was told the manifest tolerates undeclared keys — while the runtime rejects them by name and offers the declared spelling for a near miss. Prose that contradicts a tightened contract teaches exactly the wrong reflex, so each site now states the current refusal rather than merely dropping the old claim: + +- `AssembledPackageBodySchema`'s docblock no longer explains its lack of a `strictObject` spelling by calling `ManifestSchema` open. The posture is inherited: the schema is `ManifestSchema.extend(...)`, and `.extend()` carries the base's unknown-key handling, so an undeclared key on an assembled body is refused — measured, with the rename suggestion intact. +- The artifact-registration seam kept the half of its reasoning that still holds (the schema applies defaults, so a parsed clone would not be byte-identical) and retired the half that does not ("Zod strips undeclared keys") — the key is now refused at that parse rather than dropped from the clone. +- The `os compile` per-package rule pass explains why a body may be re-read as its own manifest: nothing parses that superset, and against `ManifestSchema` it would now be refused. + +No schema, behaviour or export changed; `check:api-surface` and the generated reference pages are unmoved. diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 84f933667d..8abcba22da 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -172,8 +172,11 @@ required; the optional fields are `namespace`, `defaultDatasource`, `scope`, `loading` keys are **retired tombstones** (ADR-0049 enforce-or-remove): nothing ever read any of them, so authoring one is now a compile error and a parse error carrying the migration prescription. Any other key is refused at parse — the schema is closed -against unknown keys, at the manifest root and inside `contributes`, `engine` and -`engines` — with a message naming the key and, for a near miss, the declared spelling. +against unknown keys at the manifest root, inside `contributes`, inside each entry of +`contributes.kinds[]`, and inside `engine` and `engines` — with a message naming the +key and, for a near miss, the declared spelling. The closure is inherited, not just +declared: `AssembledPackageBodySchema` extends this schema, so an assembled artifact +body is refused on an undeclared key too. The `displayName` / `author` / `license` / `homepage` / `optionalDependencies` / `peerDependencies` / `metadata` / `config` / `marketplace` keys above are diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 29f7d4708d..1ccf87007c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -1615,7 +1615,13 @@ carefully was not tightening it.** Whole-spec context (fourteen directories, out of the ruling's forced scope, same instrument): 1940 → 1722 sites, strict 15 → 361, strip 1903 → 1338. The untriaged directories are coarse-classified in the section above and are not this campaign's -debt; `api/`, `system/`, `kernel/` and `cloud/` are wire surface by construction. +debt; of them only `cloud/` is wire surface by construction. `api/`, `system/` and +`kernel/` all read **mixed** in that table, each with an authored file named in its +own row — `endpoint.zod.ts` (split at #5384), the manifest/datasource blocks, and +`manifest.zod.ts` (split at #14192, `strictObject` since). ⛔ Do not read this +paragraph as a blanket wire verdict over those three: the rows above are the +finding, and twice already a flat `wire` cell was correct when written and then +silently expired. ### The three closing claims, confirmed diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ede9c1cf05..50b111a0d2 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -85,8 +85,16 @@ function artifactPackages(parsed: Record): Array<{ * "manifest fields" and "collections" would need a second copy of the key set * `AssembledPackageBodySchema` derives, and the rules do not need the split — * they read collections off the top level (already there) and identity off - * `manifest` (a superset of the manifest, and `ManifestSchema` is an open - * object). + * `manifest`. + * + * That `manifest` is a SUPERSET of a real manifest — it is the whole body — and + * that is safe here for one reason only: nothing parses it. `runAuthoringRules` + * reads fields off this object and never hands it to a schema. ⛔ Do not start + * parsing it against `ManifestSchema`, and do not reach for a widened schema to + * make that possible: `ManifestSchema` is `strictObject` since #14192, so it + * would REFUSE, by name, every collection key this superset deliberately puts + * under `manifest` — and re-opening it to stop the refusal would re-open the + * real manifest surface with it. */ function packageBodyAsStack(body: Record): Record { return { ...body, manifest: body }; diff --git a/packages/core/src/artifact-packages.ts b/packages/core/src/artifact-packages.ts index 06ab9989d9..859ac55c4f 100644 --- a/packages/core/src/artifact-packages.ts +++ b/packages/core/src/artifact-packages.ts @@ -77,10 +77,17 @@ * ⛔ The body handed to `registerApp` is still the caller's original * `entry.manifest`, never `verdict.data.manifest`. The parse is a GATE, and the * reason is unchanged by road B: `ManifestSchema` carries defaults - * (`defaultDatasource: 'default'`, `scope: 'project'`) and Zod strips - * undeclared keys, so registering a parsed clone would put different bytes into - * the registry than the singular-`manifest` branch does for the same authored - * package. D7 pins that those two branches do not disagree. + * (`defaultDatasource: 'default'`, `scope: 'project'`), so registering a parsed + * clone would put different bytes into the registry than the singular-`manifest` + * branch does for the same authored package. D7 pins that those two branches do + * not disagree. + * + * ⛔ The other half of that reason — "and Zod strips undeclared keys" — is GONE, + * not merely reworded. `ManifestSchema` is `strictObject` since #14192 and + * `AssembledPackageBodySchema` inherits the closed posture through `.extend()`, + * so an undeclared key on an entry is REFUSED by this very parse, by name, and + * never reaches a clone to be dropped from. Defaults are what still move bytes; + * the drop that used to is now a loud rejection. * * ## Ordering reuses the ONE sorter (D5) * diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 159aa311ac..ff17c1886e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1141,11 +1141,20 @@ function assembledPackageBodyShape(): Pick