From 0306841e8882fac9470ba34e032c982525030c9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:45:49 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20refuse=20unknown=20keys=20ins?= =?UTF-8?q?ide=20manifest:=20=E2=80=94=20ManifestSchema=20goes=20strict=20?= =?UTF-8?q?(#14192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measurement first (all seven doors that reach ManifestSchema, from src and through the built dist, identical): an unknown key inside `manifest:` parsed green and was dropped at every one; `namesapce` left manifest.namespace undefined with exit 0; the same held one level down for contributes.kind, contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the schema and no bookkeeping key reaches any door, so no envelope debt had to be paid first. Exactly one undeclared key was found at a real door — `main`, read by `os plugin build` off objectstack.plugin.json and written back into the compiled manifest — and it is declared rather than refused. ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema are strictObject now; retired keys keep their retiredKey() tombstones; one curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal pins (assembled-package-body, cli protocol-version-gap); liveness row for main. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- .changeset/manifest-unknown-keys-refused.md | 40 +++ content/docs/protocol/kernel/plugin-spec.mdx | 6 +- .../2026-07-unknown-key-strictness-ledger.md | 2 +- .../src/utils/protocol-version-gap.test.ts | 38 ++- packages/spec/liveness/manifest.json | 7 + .../spec/src/assembled-package-body.test.ts | 19 +- packages/spec/src/kernel/index.ts | 6 +- .../src/kernel/manifest-unknown-keys.test.ts | 302 ++++++++++++++++++ packages/spec/src/kernel/manifest.test.ts | 30 +- packages/spec/src/kernel/manifest.zod.ts | 135 ++++++-- .../kernel/plugin-loading-retirement.test.ts | 8 +- 11 files changed, 528 insertions(+), 65 deletions(-) create mode 100644 .changeset/manifest-unknown-keys-refused.md create mode 100644 packages/spec/src/kernel/manifest-unknown-keys.test.ts diff --git a/.changeset/manifest-unknown-keys-refused.md b/.changeset/manifest-unknown-keys-refused.md new file mode 100644 index 0000000000..931ce6eb03 --- /dev/null +++ b/.changeset/manifest-unknown-keys-refused.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse unknown keys inside `manifest:` — `ManifestSchema` goes strict, with its nested `contributes` / `kinds[]` / `engine` / `engines` blocks + +**BREAKING** accept-set narrowing on a published spec schema, landing after the +v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). + + + +The package manifest — the `manifest:` block of every `objectstack.config.ts`, the +`objectstack.plugin.json` that `os plugin build` reads, the Studio package form — +was a plain open object: an unknown key inside it parsed green and its value was +silently dropped, at every door. A transposed `namesapce` therefore left +`manifest.namespace` undefined with exit 0, and that key decides every object's +table name, REST path and the install-time namespace gate — objects landed under a +name the author never wrote. The same silent drop held one level down: `kind:` for +`kinds:` inside `contributes`, `protocl:` for `protocol:` inside `engines`, which +quietly switched the load-time protocol handshake off. + +**What is refused:** any key `ManifestSchema` does not declare — at the manifest +root and inside `contributes`, `contributes.kinds[]`, `engine` and `engines` — with +a message naming the surface and the offending key. A near miss carries the declared +spelling (`namesapce` is answered with `namespace`, `protocl` with `protocol`). +`specVersion` is answered with a wrong-layer pointer: the protocol axis the runtime +checks is `engines.protocol`. The retired keys (`loading`, `capabilities`, +`configuration`, `extensions`, the retired `contributes` members, `kinds[].globs`) +keep answering with their upgrade prescriptions, and a typo is never pointed at one +of them. The refusal reaches every door: `defineStack` / `os validate` / +`os compile`, `devPlugins`, the artifact `packages[]` entry (the assembled body +inherits it), `os plugin build`, and the package request shapes that embed the +manifest. + +**What stays accepted:** every declared key, byte-identically — the four example +apps, every `os init` template and every shipped `manifest:` block parse unchanged. +**Newly declared:** `main` (optional), the entry module of a code-bearing plugin. +`os plugin build` has always read it off the source manifest and written it into +the compiled one as `dist/index.mjs`; it is declared now because a closed surface +that did not declare it would have refused every plugin that names its entry. diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 7949e08c29..b23570ee05 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -168,10 +168,12 @@ export default definePlugin({ required; the optional fields are `namespace`, `defaultDatasource`, `scope`, `description`, `permissions`, `objects`, `datasources`, `dependencies`, `contributes`, `data`, `navigationContributions`, `engine`, `engines`, `runtime`, -`packaging` and `integrity`. The `configuration`, `capabilities`, `extensions` and +`packaging`, `main` and `integrity`. The `configuration`, `capabilities`, `extensions` and `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. +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. 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 aa36b048f7..16eff59a5e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -1341,7 +1341,7 @@ rest at #5107. |---|---|---| | `api/` | **mixed · `endpoint.zod.ts` authorable, the rest wire** | ⚠️ **Split at #5384 — and the flat `wire` verdict this row used to carry was correct when written and then silently expired, which is the finding.** The rest of the directory is unchanged: REST/GraphQL request/response contracts, tolerant by design. But `endpoint.zod.ts` stopped being one of them at **#5312**, which registered `api` as a metadata type (`DEFAULT_METADATA_TYPE_REGISTRY` / `BUILTIN_METADATA_TYPE_SCHEMAS`) — from that moment `ApiEndpointSchema` was simultaneously an AUTHORING surface (`defineStack({ apis })`, the Studio metadata-admin form, `PUT /meta/api/:name`'s 422) and a wire shape, while this row still told every reader the whole directory was "tolerant by design". A row is read as licence, which is exactly what this ledger's own gate exists to prevent. `ApiEndpointSchema` is now `strictObject` (#5384): an undeclared key on an endpoint is a named rejection carrying the surface, the offending key and a rename, instead of a silent strip that let a `cacheTTL` / `objectParam` / `outputMappings` typo publish green and serve without the policy or projection its author wrote. Two curated wrong-layer pointers ship with it: **`namespace`** (ADR-0121 D2 — the namespace segment of `path` is derived from `manifest.namespace` and has never been per-endpoint; `publish-endpoint-gate.test.ts` pins that the gate does not believe it) and the six **stored-envelope bookkeeping** keys (`packageId`, `state`, `version`, `published*`). ⚠️ **The order is the part worth keeping.** Closing this shape was measured and REFUSED first (2026-08-05, maintainer): the same schema parsed STORED rows at `buildEndpointIndex` and `gateApiItemsForPublish`, so a naked `strictObject` failed every row with `unrecognized_keys: ['packageId', 'state']` — the load-time backstop excluded the endpoint (404) and the publish gate reported a schema error in place of the ADR-0121 D6 verdict it exists to give, 11 tests red in `packages/metadata`. The debt was real and it was NOT in this vocabulary, so it was paid at the layer that owned it: **#5309 (PR #6576)** peeled the envelope off before the body parse (`peelStoredEnvelope`), after which a strict probe left exactly ONE red — a fixture planting an authored `namespace`, re-spelled by #5384 rather than deleted. Teaching `ApiEndpointSchema` two bookkeeping keys to buy strictness would have made the authoring contract describe the storage layer; that trade was refused and did not have to be made. `api` left `STILL_STRIP` (`kernel/metadata-type-schemas.test.ts`) with this change — closed registered types 24 → 25 of 26 — and the CLI gate row moved from `NOT_YET_CLOSED` into `GATED_AT` (`packages/cli/test/metadata-type-schema-gate.test.ts`), the deliberate ratchet step that file's own note asked for. `view` is now the only entry left on `STILL_STRIP` | | `system/` | mixed | manifest/datasource blocks are authored; runtime envelopes are wire | -| `kernel/` | wire | plugin/kernel contracts, code-to-code | +| `kernel/` | **mixed · `manifest.zod.ts` authorable, the rest wire** | ⚠️ **Split at #14192 — the flat `wire` verdict this row carried was right for the directory and wrong for one file in it, the `api/` row's finding one directory over.** `kernel/manifest.zod.ts` is `manifest:` — the hand-authored identity block of every `objectstack.config.ts`, every `os init` template, every example app, the Studio package form and the `objectstack.plugin.json` that `os plugin build` reads — and it was a plain open `z.object` under a cell that read "code-to-code". Measured first (2026-09-02 at `bd4aa4e49`, from `src` and through the built `dist`, identical readings): an unknown key parsed green and was DROPPED at all seven doors that reach `ManifestSchema` — the stack `manifest:` member (`defineStack` / `os validate` / `os compile`), `devPlugins[]`, the artifact `packages[]` entry in both its authoring and assembled forms, `os plugin build`, the `InstalledPackage` row schema, and the six package request/snapshot wire shapes that embed it. A transposed `namesapce` left `manifest.namespace` undefined at every one of them with exit 0 — and that key decides table names, REST paths and the install-time namespace gate (ADR-0129 D1–D2, ADR-0048 §3.2, ADR-0130 D1/D3). The same silent drop held one level down for `contributes` (`kind:` for `kinds:`), `contributes.kinds[]` and `engines` (`protocl:` for `protocol:`, which switches the load-time protocol handshake off). **The order the `api/` row records was kept: measurement, then the close.** No stored-row caller feeds this schema — `registry.validate('package' \| 'plugin')` has no live caller (`installPackage` stores the caller's object verbatim, `registerPlugin` is uncalled), the durable `sys_packages` row is re-registered through `registerApp` without a parse, and no bookkeeping key (`packageId` / `state` / `published*` / `package`) reaches any door — so the `peelStoredEnvelope` debt the `api/` row had to pay first does not exist here, and no bookkeeping key was taught to the authoring schema. Exactly ONE undeclared key was found at a real door, and it was DECLARED rather than curated: `main`, which `os plugin build` reads off the source `objectstack.plugin.json` to pick its entry and writes back into the compiled manifest as `dist/index.mjs`; three CLI fixtures author it, and a naked `strictObject` would have refused every plugin that names its entry — the shape this row would otherwise have recorded as refused. `ManifestSchema`, `contributes`, `contributes.kinds[]`, `engine` and `PluginEnginesSchema` are `strictObject` now: an undeclared key is a named rejection carrying the surface, the key and a rename (`namesapce` → `namespace`, `protocl` → `protocol`); the retired keys keep their `retiredKey()` tombstones (a tombstone types the key `never` and carries the upgrade text, which a bare refusal would not, and `acceptsNothing` keeps them out of the rename candidates); one curated wrong-layer pointer ships, `specVersion` (the retired CLI advisory axis — the live one is `engines.protocol`). `AssembledPackageBodySchema` (`stack.zod.ts`) inherits the closed posture through `.extend()`, so the load gate refuses an undeclared key on an assembled body too; that file's docblock sentence "NOT `strictObject`: `ManifestSchema` is an open object" is stale as of this change and is reported for its owner rather than edited here. Blast radius, measured with a strict preview before the close: 0 undeclared keys across the four example apps, the three `os init` templates, the `create-objectstack` blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest; the only shipped-block failure — `packages/cli/src/commands/create.ts`'s template manifest — fails the OPEN schema too (it lacks `id` and `type`) and is reported separately. Cloud is NOT MEASURED for this file (no checkout in the container, code search refused); if cloud parses a published `.osplugin` manifest through this schema, the compiled manifest it receives carries only declared keys (`main` and `integrity` included) by construction of `os plugin build`. The rest of `kernel/` is unchanged: plugin/kernel contracts, code-to-code | | `cloud/` | wire | multi-tenant runtime | | `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | | `integration/` | wire | connector payloads — upstream adds fields freely | diff --git a/packages/cli/src/utils/protocol-version-gap.test.ts b/packages/cli/src/utils/protocol-version-gap.test.ts index 84dc45d0ed..efeab355cb 100644 --- a/packages/cli/src/utils/protocol-version-gap.test.ts +++ b/packages/cli/src/utils/protocol-version-gap.test.ts @@ -20,13 +20,15 @@ describe('checkProtocolVersionGap', () => { // ── The reachability this advisory never had ─────────────────────────── // // Before the axis moved, the advisory read `manifest.specVersion` — a key - // `ManifestSchema` does not declare, on a schema that is not `.strict()`. - // It was therefore DEAD for stack configs: it could only fire for a manifest - // carrying a key the schema does not offer. These two cases are the pins for - // that: the axis it reads now survives the schema parse, and the axis it - // retired does not. Assert them THROUGH `ManifestSchema.parse` rather than on - // a hand-built literal — a literal would prove the function works on input - // the platform never produces, which is exactly the state being repaired. + // `ManifestSchema` does not declare, on a schema that was not `.strict()` at + // the time. It was therefore DEAD for stack configs: it could only fire for a + // manifest carrying a key the schema does not offer. These two cases are the + // pins for that: the axis it reads now survives the schema parse, and the + // axis it retired is REFUSED there (the schema is closed now, and the + // refusal points at `engines.protocol`). Assert them THROUGH `ManifestSchema` + // rather than on a hand-built literal — a literal would prove the function + // works on input the platform never produces, which is exactly the state + // being repaired. it('FIRES for a gap on a manifest the schema actually declares', () => { const parsed = ManifestSchema.parse(manifest({ engines: { protocol: '^16' } })); @@ -42,15 +44,23 @@ describe('checkProtocolVersionGap', () => { expect(gap!.hint).toContain('https://objectstack.ai/docs/releases/v17'); }); - it('is silent for the RETIRED `specVersion` key, which the schema drops', () => { + it('the RETIRED `specVersion` key is refused at parse, pointing at `engines.protocol`', () => { // The pin the old suite spent on `checkSpecVersionGap({ specVersion: - // '^12.0.0' }, '14.7.0')`, re-aimed at the contract that replaced it. - // `ManifestSchema` is not `.strict()`, so the key is accepted and dropped - // with nothing said — the silence that made the old advisory unreachable. - const parsed = ManifestSchema.parse(manifest({ specVersion: '^12.0.0' })) as Record; - expect(parsed.specVersion, 'ManifestSchema does not declare specVersion').toBeUndefined(); + // '^12.0.0' }, '14.7.0')`, re-aimed at the contract that replaced it. This + // used to assert the key was accepted and DROPPED with nothing said — the + // silence that made the old advisory unreachable. `ManifestSchema` is + // closed now, so the same input is a named refusal carrying the axis that + // replaced it; the advisory itself still has nothing to say about a + // hand-built literal that names only the retired axis. + const result = ManifestSchema.safeParse(manifest({ specVersion: '^12.0.0' })); + expect(result.success, 'specVersion must be refused, not dropped').toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, 'the refusal is an unrecognized_keys issue at the manifest root').toBeDefined(); + expect(issue!.path).toEqual([]); + expect((issue as unknown as { keys: string[] }).keys).toContain('specVersion'); + expect(issue!.message).toContain('engines.protocol'); expect(checkProtocolVersionGap({ specVersion: '^12.0.0' }, '17.2.0')).toBeNull(); - expect(checkProtocolVersionGap(parsed, '17.2.0')).toBeNull(); }); it('is silent when the declared range covers the installed platform', () => { diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index ea1d0d669c..9f3120527d 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -263,6 +263,13 @@ "evidenceScope": "in-repo", "note": "Live on a real behavioural fork, not on a read: the value selects whether esbuild bundles dependencies into the artifact or externalizes them for install-time resolution, so the two tiers produce different artifacts. Contrast its neighbour `runtime`, which is read only to be printed. 2026-08-28: RE-ANCHORED (#13003) — both citations were still ACCURATE (`:126` names the read, `:159-161` the externalization), so this is a grammar migration with no repair. The file is 233 lines end to end, which is the batch's clearest correlation: every accurate citation in it sits in a small or structurally quiet file, and every rotted one in a file of 1800 to 20255 lines. Re-closed by hand against c459da6bc." }, + "main": { + "status": "live", + "evidence": "packages/cli/src/commands/plugin/build.ts#PluginBuild (`typeof rawManifest.main === 'string' ? rawManifest.main : undefined` — the entry module esbuild bundles when `--entry` is absent, ahead of the `src/index.*` probe; the same command then writes `main: 'dist/index.mjs'` into the compiled manifest it packs)", + "verifiedAt": "2026-09-02", + "evidenceScope": "in-repo", + "note": "DECLARED at the strictness close of `ManifestSchema` (2026-09-02), not new behaviour: the build door has read this key off the raw `objectstack.plugin.json` since the plugin-distribution CLI landed, while the schema never declared it — so the key was honoured undeclared, and a naked `strictObject` would have refused every plugin that names its entry (three CLI fixtures author it: `osplugin.test.ts`, `plugin-sign.test.ts`, `plugin-publish.test.ts`). Live on the entry choice, not on a print: the value decides which module is bundled into `dist/index.mjs`. `os plugin publish` does not read it back (it reads `id` / `version` / `name` / `runtime` / `integrity` / `description`); cloud unmeasured (see `_note`)." + }, "integrity": { "status": "dead", "verifiedAt": "2026-08-30", diff --git a/packages/spec/src/assembled-package-body.test.ts b/packages/spec/src/assembled-package-body.test.ts index 294e934001..749d417700 100644 --- a/packages/spec/src/assembled-package-body.test.ts +++ b/packages/spec/src/assembled-package-body.test.ts @@ -187,11 +187,20 @@ describe('#14242 B — two stages, two declarations, neither tolerant of the oth expect(ArtifactPackageSchema.safeParse(assembledBody).success).toBe(false); }); - it('the body schema is not `strict` — `ManifestSchema` has never had that door', () => { - // Stated as a pin because it is a deliberate choice, not an oversight: this - // change adds a SHAPE gate on the collections, not a new unknown-key - // refusal on a manifest surface that is open by design. - expect(AssembledPackageBodySchema.safeParse({ ...coreManifest, somethingUndeclared: 1 }).success).toBe(true); + it('the body schema inherits the manifest door: an undeclared key on an assembled body is refused', () => { + // This pin used to assert the opposite — "not strict, `ManifestSchema` has + // never had that door" — and was true while the manifest was open. + // `ManifestSchema` is closed now (`strictObject`), and `.extend()` carries + // the closed posture and the error map onto the assembled body, so the + // refusal is inherited rather than declared twice: a key that is neither a + // manifest field nor a stack collection is refused at the load gate, + // naming the key. + const verdict = AssembledPackageBodySchema.safeParse({ ...coreManifest, somethingUndeclared: 1 }); + expect(verdict.success).toBe(false); + if (verdict.success) return; + const issue = verdict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue).toBeDefined(); + expect((issue as unknown as { keys: string[] }).keys).toEqual(['somethingUndeclared']); }); }); diff --git a/packages/spec/src/kernel/index.ts b/packages/spec/src/kernel/index.ts index 7273e96ad3..ec1146209b 100644 --- a/packages/spec/src/kernel/index.ts +++ b/packages/spec/src/kernel/index.ts @@ -67,8 +67,10 @@ export * from './plugin-loading.zod'; // PluginPerformanceMonitoringSchema + every type alias) was REMOVED per ADR-0049 // enforce-or-remove (#4914), together with the `Manifest.loading` key that was // its only carrier — that key is now a `retiredKey()` tombstone in -// `manifest.zod.ts`, because `ManifestSchema` is not `.strict()` and a plain -// deletion would strip it in silence. +// `manifest.zod.ts`: when it landed `ManifestSchema` was not `.strict()` and a +// plain deletion would have stripped it in silence; the surface has since been +// closed (`strictObject`), and the tombstone stays because it types the key +// `never` and carries the prescription a bare unknown-key refusal would not. // // The block declared a full loading policy — lazy/eager strategy, preloading, // code splitting, dynamic import, initialization, dependency resolution, hot diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts new file mode 100644 index 0000000000..7e3ebdcc20 --- /dev/null +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ManifestSchema` refuses unknown keys — the #8687 door one level down. + * + * ## The measurement this file inverts + * + * Measured on `origin/main` (17.2.0, from `src` and through the built `dist`, + * identical): an unknown key inside `manifest:` parsed green and was silently + * DROPPED at every door that reaches this schema — the stack `manifest:` + * member (`defineStack` / `os validate` / `os compile`), `devPlugins[]`, the + * artifact `packages[]` entry (authoring and assembled), `os plugin build`, + * the `InstalledPackage` row schema and the package request shapes that embed + * it. A transposed `namesapce` therefore left `manifest.namespace` + * `undefined` with exit 0, and that key decides every object's table name, + * REST path and the install-time namespace gate. One level further down the + * same held for `contributes.kind` (for `kinds`), `contributes.kinds[].glob` + * and `engines.protocl` (for `protocol`, which switched the load-time + * protocol handshake off in silence). + * + * ## Rejection-pin convention (the standing minimum) + * + * Each rejection asserts the Zod issue's **`code` and `path`** and the + * offending key via `keys`. `status` is the publish door's uniform wrap — the + * ADR-0112 envelope is applied where a parse failure crosses the HTTP + * boundary, not minted per-schema — so at this layer it is the family + * convention rather than an assertion (the `stack-top-level-strict.test.ts` + * precedent). + * + * ## What is deliberately NOT here + * + * The retired keys' prescriptions are pinned by their own retirement files + * (`manifest.test.ts`, `plugin-loading-retirement.test.ts`); this file pins + * only that closing the surface did not change them — a tombstone still + * answers with its prescription, and a typo is never pointed at one. + */ + +import { describe, it, expect } from 'vitest'; + +import { ManifestSchema, PluginEnginesSchema } from './manifest.zod'; +import { ArtifactPackageSchema, ObjectStackDefinitionSchema } from '../stack.zod'; + +/** A legal manifest, the shape every scaffold and example stamps. */ +const legal = () => ({ + id: 'com.example.probe', + namespace: 'probe', + version: '1.0.0', + type: 'app' as const, + name: 'Probe', + description: 'A legal manifest', + engines: { protocol: '^17' }, +}); + +const unrecognized = (result: ReturnType) => { + if (result.success) return undefined; + return result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | (typeof result.error.issues[number] & { keys: string[] }) + | undefined; +}; + +describe("#14192 — unknown keys inside `manifest:` are refused at parse (the card's measurement, inverted)", () => { + it('a transposed `namesapce` is a named refusal carrying the rename — not an undefined namespace', () => { + // The card's own measurement: parse success, key dropped, namespace undefined. + const { namespace: _omitted, ...withoutNamespace } = legal(); + const result = ManifestSchema.safeParse({ ...withoutNamespace, namesapce: 'probe' }); + + expect(result.success, 'the typo must refuse at parse').toBe(false); + const issue = unrecognized(result); + expect(issue, 'the refusal is an unrecognized_keys issue').toBeDefined(); + expect(issue!.code).toBe('unrecognized_keys'); + // Raised at the manifest root — the block the author wrote. + expect(issue!.path).toEqual([]); + expect(issue!.keys).toEqual(['namesapce']); + expect(issue!.message).toContain('Unrecognized key(s) on this package manifest: `namesapce`'); + // The near-miss guidance rides the refusal itself (the #8687 shape). + expect(issue!.message).toContain('Did you mean `namesapce` → `namespace`?'); + }); + + it('an arbitrary unknown key is refused, naming the surface and where the legal keys live', () => { + const result = ManifestSchema.safeParse({ ...legal(), zzzBogusManifestKey: 1 }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.path).toEqual([]); + expect(issue.keys).toEqual(['zzzBogusManifestKey']); + expect(issue.message).toContain('this package manifest'); + expect(issue.message).toContain('ManifestSchema'); + // Nothing within edit distance — no invented rename. + expect(issue.message).not.toContain('Did you mean'); + }); + + it('positive control: the same manifest without the stray key parses green, byte-for-byte on the declared keys', () => { + const result = ManifestSchema.safeParse(legal()); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatchObject(legal()); + }); + + it('answers the retired CLI advisory axis `specVersion` with the wrong-layer pointer, not a rename', () => { + // `os doctor` / `os lint` used to read `manifest.specVersion`; the axis is + // `engines.protocol` now and the key was dropped in silence until this close. + const result = ManifestSchema.safeParse({ ...legal(), specVersion: '^12.0.0' }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.keys).toEqual(['specVersion']); + expect(issue.message).toContain('engines.protocol'); + expect(issue.message).not.toContain('Did you mean'); + }); +}); + +describe('#14192 — the refusal reaches every door the measurement listed', () => { + const typo = () => { + const { namespace: _omitted, ...rest } = legal(); + return { ...rest, namesapce: 'probe' }; + }; + + it('through the stack `manifest:` member (`defineStack` / `os validate` / `os compile`)', () => { + const result = ObjectStackDefinitionSchema.safeParse({ manifest: typo() }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | { path: (string | number)[]; keys: string[] } + | undefined; + expect(issue).toBeDefined(); + expect(issue!.path).toEqual(['manifest']); + expect(issue!.keys).toEqual(['namesapce']); + }); + + it('through the assembled artifact `packages[]` entry — the load gate inherits the door via `.extend()`', () => { + const body = { + ...typo(), + objects: [{ name: 'probe_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }], + }; + const result = ArtifactPackageSchema.safeParse({ manifest: body }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | { path: (string | number)[]; keys: string[] } + | undefined; + expect(issue).toBeDefined(); + expect(issue!.path).toEqual(['manifest']); + expect(issue!.keys).toEqual(['namesapce']); + }); + + it('through `devPlugins[]` — refused, with the refusal nested inside the union issue', () => { + // `devPlugins` is `z.union([ManifestSchema, z.string()])`: a manifest object + // carrying an unknown key now fails BOTH branches, so the top-level issue is + // `invalid_union` and the named refusal sits in its nested `errors`. Stated + // here because it is what an author sees at this door: loud, but one level + // deeper than the other doors (the state-machine row's known flattening + // limitation, not a silent strip). + const result = ObjectStackDefinitionSchema.safeParse({ manifest: legal(), devPlugins: [typo()] }); + expect(result.success).toBe(false); + if (result.success) return; + const union = result.error.issues.find((i) => i.code === 'invalid_union') as + | { path: (string | number)[]; errors: Array> } + | undefined; + expect(union).toBeDefined(); + expect(union!.path).toEqual(['devPlugins', 0]); + const nested = union!.errors.flat().find((i) => i.code === 'unrecognized_keys'); + expect(nested, 'the named refusal is carried inside the union issue').toBeDefined(); + expect(nested!.keys).toEqual(['namesapce']); + }); +}); + +describe('#14192 — the nested blocks inside `manifest:` are closed under the same measurement', () => { + it('`contributes.kind` (for `kinds`) is refused at the block, with the rename', () => { + const result = ManifestSchema.safeParse({ ...legal(), contributes: { kind: [{ id: 'sys.bi.report' }] } }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.path).toEqual(['contributes']); + expect(issue.keys).toEqual(['kind']); + expect(issue.message).toContain('Did you mean `kind` → `kinds`?'); + }); + + it('`contributes.kinds[].glob` is refused at the entry — and is NOT pointed at the retired `globs` tombstone', () => { + const result = ManifestSchema.safeParse({ + ...legal(), + contributes: { kinds: [{ id: 'sys.bi.report', glob: ['**/*.report.ts'] }] }, + }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.path).toEqual(['contributes', 'kinds', 0]); + expect(issue.keys).toEqual(['glob']); + // `acceptsNothing`: a tombstone stays in the shape (writing it raises its + // prescription) but never becomes a rename target — otherwise the fix + // would signpost the author into a second rejection. (`glob` is one edit + // from `globs`, so a rename channel that still saw the tombstone WOULD + // offer it; the block's `history` sentence names `globs` only to say it is + // retired.) + expect(issue.message).not.toContain('Did you mean'); + }); + + it('`engines.protocl` (for `protocol`) is refused at the block — the handshake range can no longer vanish in silence', () => { + const result = ManifestSchema.safeParse({ ...legal(), engines: { protocl: '^17' } }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.path).toEqual(['engines']); + expect(issue.keys).toEqual(['protocl']); + expect(issue.message).toContain('Did you mean `protocl` → `protocol`?'); + // The exported block schema is the same door. + expect(PluginEnginesSchema.safeParse({ protocl: '^17' }).success).toBe(false); + }); + + it('the legacy `engine.objectstak` is refused at the block', () => { + const result = ManifestSchema.safeParse({ ...legal(), engine: { objectstack: '>=17.0.0', objectstak: '>=1.0.0' } }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.path).toEqual(['engine']); + expect(issue.keys).toEqual(['objectstak']); + }); + + it('positive control: the live nested members still parse and are kept', () => { + const parsed = ManifestSchema.parse({ + ...legal(), + contributes: { kinds: [{ id: 'sys.bi.report', description: 'BI report kind' }] }, + engine: { objectstack: '>=17.0.0' }, + engines: { platform: '>=17.0.0 <18', protocol: '^17' }, + }); + expect(parsed.contributes?.kinds).toEqual([{ id: 'sys.bi.report', description: 'BI report kind' }]); + expect(parsed.engine).toEqual({ objectstack: '>=17.0.0' }); + expect(parsed.engines).toEqual({ platform: '>=17.0.0 <18', protocol: '^17' }); + }); +}); + +describe('#14192 — the accept side does not move, and `main` is declared', () => { + it('every live key parses and survives — the whole declared vocabulary in one manifest', () => { + const full = { + ...legal(), + defaultDatasource: 'memory', + scope: 'project' as const, + permissions: { services: ['object'], hooks: ['record.beforeInsert'] }, + objects: ['./src/objects/*.object.ts'], + datasources: ['./src/datasources/*.datasource.yml'], + dependencies: { '@objectstack/plugin-auth': '^2.0.0' }, + contributes: { kinds: [{ id: 'sys.bi.report' }] }, + data: [], + navigationContributions: [ + { app: 'setup', items: [{ id: 'nav_x', type: 'url' as const, label: 'X', url: '/x' }] }, + ], + engine: { objectstack: '>=17.0.0' }, + runtime: 'sandbox' as const, + packaging: 'bundled' as const, + main: 'src/index.ts', + integrity: { 'dist/index.mjs': 'sha256-x' }, + }; + const result = ManifestSchema.safeParse(full); + expect(result.success).toBe(true); + if (!result.success) return; + for (const key of Object.keys(full)) { + expect(result.data, `${key} must survive the parse`).toHaveProperty(key); + } + }); + + it('`main` is accepted in both shapes the `os plugin build` door produces — the source manifest and the compiled one', () => { + // Source: the author names the entry. Compiled: the build rewrites it to + // the bundle and adds `integrity`. Both went through this schema + // undeclared before the close; a naked `strictObject` would have refused + // every plugin that names its entry. + const source = ManifestSchema.safeParse({ ...legal(), type: 'plugin', main: 'src/index.ts' }); + expect(source.success).toBe(true); + if (source.success) expect(source.data.main).toBe('src/index.ts'); + + const compiled = ManifestSchema.safeParse({ + ...legal(), + type: 'plugin', + main: 'dist/index.mjs', + integrity: { 'dist/index.mjs': 'sha256-x' }, + }); + expect(compiled.success).toBe(true); + if (compiled.success) expect(compiled.data.main).toBe('dist/index.mjs'); + }); + + it('a retired key still answers with its tombstone prescription — closing the surface did not replace it with a bare refusal', () => { + for (const [key, value] of [ + ['loading', { strategy: 'lazy' }], + ['capabilities', { implements: [] }], + ['configuration', { title: 'Cfg' }], + ['extensions', { a: 1 }], + ] as const) { + const result = ManifestSchema.safeParse({ ...legal(), [key]: value }); + expect(result.success, `${key} must refuse`).toBe(false); + if (result.success) continue; + // The tombstone's own issue, at the key, carrying the removal record — + // and NO unrecognized_keys issue: the key is declared (as `never`), not unknown. + const tombstone = result.error.issues.find((i) => i.path[0] === key); + expect(tombstone, `${key} answers at its own path`).toBeDefined(); + expect(tombstone!.message).toMatch(new RegExp(`manifest\\.${key}.*removed in @objectstack/spec 17`, 's')); + expect(unrecognized(result), `${key} is a tombstone, not an unknown key`).toBeUndefined(); + } + }); + + it('a near miss of a retired key is refused WITHOUT being pointed at the tombstone', () => { + // `capabilitis` is one keystroke from the retired `capabilities`. The rename + // channel must not send the author there — the second rejection would tell + // them to delete what they were just told to write. + const result = ManifestSchema.safeParse({ ...legal(), capabilitis: {} }); + expect(result.success).toBe(false); + const issue = unrecognized(result)!; + expect(issue.keys).toEqual(['capabilitis']); + expect(issue.message).not.toContain('`capabilities`'); + }); +}); diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index 69f6abe71d..beee1f7e03 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -351,10 +351,11 @@ describe('ManifestSchema', () => { describe('contributes dead-member retirement (#10724, ADR-0049 — tombstoned, not deleted)', () => { // Nine members had zero readers monorepo-wide (#10627's controlled census, - // completed on cloud 2026-08-24). `ManifestSchema` and the `contributes` - // object are NOT `.strict()`, so a plain deletion would have silently - // stripped the keys — `retiredKey()` is what makes each rejection carry the - // prescription, and the prescription is what these pins assert (the specific + // completed on cloud 2026-08-24). When they were retired `ManifestSchema` and + // the `contributes` object were NOT `.strict()`, so a plain deletion would + // have silently stripped the keys; both are `strictObject` now, and the keys + // stay `retiredKey()` tombstones because a bare unknown-key refusal would not + // carry the prescription — which is what these pins assert (the specific // zod issue, never just "it threw"). const base = { id: 'com.example.retired', version: '1.0.0', type: 'plugin', name: 'Retired' }; const authored: Array<[member: string, value: unknown]> = [ @@ -405,9 +406,10 @@ describe('contributes.routes retirement (#10726, ADR-0049 — maintainer-ruled O // The one `contributes` member split onto its own card: zero readers like // its nine #10724 siblings (#10627's controlled census, cloud leg closed // clean by #10812), but four published surfaces taught it as THE way to - // serve a code-handler endpoint, so removal needed its own ruling. Neither - // `ManifestSchema` nor the `contributes` object is `.strict()`, so the - // removal is a `retiredKey()` tombstone; the pin asserts the SPECIFIC zod + // serve a code-handler endpoint, so removal needed its own ruling. The + // removal is a `retiredKey()` tombstone (it carries the prescription, which + // the since-closed block's bare unknown-key refusal would not); the pin + // asserts the SPECIFIC zod // issue — located at the key, carrying the removal record and the // imperative `http.server` fix — never just "it threw". const base = { id: 'com.example.routes', version: '1.0.0', type: 'plugin', name: 'Routes' }; @@ -442,8 +444,9 @@ describe('contributes.kinds[].globs retirement (#11169, ADR-0049 — maintainer- // The sub-field promised glob-driven file-type discovery that actually runs // off the metadata type registry's `filePatterns` — which `contributes.kinds` // does not extend — so an authored `globs` was stored, served back, and never - // consulted. The kinds item object is not `.strict()`, so the removal is a - // `retiredKey()` tombstone; the pin asserts the SPECIFIC zod issue. + // consulted. The removal is a `retiredKey()` tombstone (the kinds entry + // shape is closed now, and a tombstone still carries the prescription a bare + // refusal would not); the pin asserts the SPECIFIC zod issue. const base = { id: 'com.example.kinds', version: '1.0.0', type: 'plugin', name: 'Kinds' }; it('REJECTS an authored kinds[].globs with the prescription as the issue', () => { @@ -480,10 +483,11 @@ describe('dead-container retirement (#11332, ADR-0049 — tombstoned, not delete // Three top-level manifest containers had ZERO reads of the container itself // monorepo-wide (objectstack + objectui + cloud, controlled census), which // settles every key beneath them at once — a key cannot be read if the - // object holding it never is. `ManifestSchema` is NOT `.strict()`, so a - // plain deletion would have silently stripped the keys — `retiredKey()` is - // what makes each rejection carry the prescription, and the prescription is - // what these pins assert (the specific zod issue, never just "it threw"). + // object holding it never is. When they were retired `ManifestSchema` was NOT + // `.strict()`, so a plain deletion would have silently stripped the keys; the + // surface is `strictObject` now and the keys stay `retiredKey()` tombstones + // because a bare unknown-key refusal would not carry the prescription — which + // is what these pins assert (the specific zod issue, never just "it threw"). const base = { id: 'com.example.retired', version: '1.0.0', type: 'plugin', name: 'Retired' }; const authored: Array<[container: string, value: unknown, mustMention: RegExp]> = [ [ diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index c0720cb8d0..8d138e453c 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { retiredKey } from '../shared/retired-key'; +import { strictObject } from '../shared/strict-object'; import { SeedSchema } from '../data/seed.zod'; import { NavigationContributionSchema } from '../ui/app.zod'; @@ -63,14 +64,19 @@ export type ManifestPermissions = z.input; * takes precedence over `platform` (the engine release range), so a plugin * keeps working across platform releases that preserve the protocol. */ -export const PluginEnginesSchema = z - .object({ - platform: z.string().optional() - .describe('ObjectStack platform release range (SemVer, e.g. ">=4.0 <5")'), - protocol: z.string().optional() - .describe('Runtime/metadata protocol range, checked first (ADR §3.10 #3)'), - }) - .describe('Plugin compatibility ranges (ADR-0025 §3.2)'); +export const PluginEnginesSchema = strictObject({ + surface: 'the `engines` block of this package manifest', + history: + 'Until this block was closed, an unknown key inside `engines:` parsed green and was ' + + 'silently dropped — a transposed `protocl` left `engines.protocol` undeclared, so the ' + + 'load-time protocol handshake had no range to check and every runtime accepted the ' + + 'package. The declared keys are `platform` and `protocol`.', +}, { + platform: z.string().optional() + .describe('ObjectStack platform release range (SemVer, e.g. ">=4.0 <5")'), + protocol: z.string().optional() + .describe('Runtime/metadata protocol range, checked first (ADR §3.10 #3)'), +}).describe('Plugin compatibility ranges (ADR-0025 §3.2)'); export type PluginEngines = z.input; @@ -140,6 +146,32 @@ export type PluginIntegrity = z.input; * Schema for the ObjectStack Manifest. * This defines the structure of a package configuration in the ObjectStack ecosystem. * All packages (apps, plugins, drivers, modules) must conform to this schema. + * + * CLOSED against unknown keys (`strictObject`), like every inner authorable + * surface the #4001 campaign closed and the top-level stack door (#8687) one + * level up. Before this, an unknown key inside `manifest:` parsed green and + * was silently dropped at every door — measured on 17.2.0 at all seven: + * `defineStack` / `os validate` / `os compile` (`ObjectStackDefinitionSchema`), + * `devPlugins[]`, the artifact `packages[]` entry (authoring and assembled), + * `os plugin build` (`objectstack.plugin.json`), the Studio package form, the + * `InstalledPackage` row schema and the package request/snapshot wire shapes + * that embed this one. A transposed `namesapce` therefore left + * `manifest.namespace` undefined, and the namespace decides every object's + * table name, REST path and the install-time namespace gate (ADR-0129 D1-D2, + * ADR-0048 §3.2, ADR-0130 D1/D3) — a two-letter transposition shipped objects + * under a name the author never wrote, with exit 0. The nested `contributes`, + * `contributes.kinds[]`, `engine` and `engines` blocks are closed under the + * same measurement (the same doors, the same silent drop one level down). + * + * The retired keys stay `retiredKey()` tombstones rather than becoming + * `guidance` entries: a tombstone types the key `never` for `tsc` AND raises + * its prescription at parse, which is strictly stronger than a curated + * unknown-key line, and `strictObject` keeps tombstones out of the rename + * candidates (`acceptsNothing`) so a typo is never pointed at a dead key. + * `main` is DECLARED rather than curated: the `os plugin build` door reads it + * off the source manifest and writes it into the compiled one, so a + * prescription telling authors to delete it would have described neither + * file (see the key's own doc below). * * @example App Package * ```yaml @@ -155,7 +187,23 @@ export type PluginIntegrity = z.input; * - "./src/objects/*.object.yml" * ``` */ -export const ManifestSchema = z.object({ +export const ManifestSchema = strictObject({ + surface: 'this package manifest', + history: + 'Until this surface was closed, an unknown key inside `manifest:` parsed green and its ' + + 'value was silently dropped — a transposed `namesapce` left `manifest.namespace` ' + + 'undefined, so every object name, table name, REST path and the install-time namespace ' + + 'gate were decided against a namespace the author never wrote, with `os validate` ' + + 'exiting 0. The declared keys are enumerated by `ManifestSchema` (@objectstack/spec, ' + + 'kernel/manifest.zod.ts) and in the package-manifest reference docs.', + guidance: { + specVersion: + '`specVersion` is not a package-manifest key. The protocol axis the runtime checks at ' + + 'load is `engines.protocol` — declare `engines: { protocol: \'^17\' }` (the range the ' + + 'scaffold stamps); `specVersion` keeps its meaning only on the marketplace TEMPLATE ' + + 'manifest (`cloud/template-manifest.zod.ts`), which is a different surface.', + }, +}, { /** * Unique package identifier using reverse domain notation. * Must be unique across the entire ecosystem. @@ -325,8 +373,9 @@ export const ManifestSchema = z.object({ * "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, * masked, resolved or even parsed the value — an author writing * `secret: true` next to an API key got exactly the same handling as - * `secret: false`. Tombstoned rather than deleted because `ManifestSchema` - * is not `.strict()`: a plain deletion would silently strip the key (the + * `secret: false`. Tombstoned rather than deleted: the tombstone types the + * key `never` for `tsc` and raises this prescription at parse, which a bare + * unknown-key refusal on the (since-closed) surface would not carry (the * `loading` precedent below). */ configuration: retiredKey( @@ -356,14 +405,21 @@ export const ManifestSchema = z.object({ * `manifest.contributes` in the entire monorepo — * `packages/objectql/src/engine.ts` reading `kinds` — so every other * member parsed, entered the manifest, and changed nothing. - * Tombstoned rather than deleted because this object is not `.strict()`: - * a plain deletion would silently strip the key, replacing an inert - * declaration with an invisible one (the `loading` precedent below). + * Tombstoned rather than deleted: each tombstone types its key `never` and + * raises the prescription at parse — a plain deletion under the since-closed + * block would answer with a bare unknown-key refusal instead of the upgrade + * text (the `loading` precedent below). * * Survivor: `kinds` (live reader: engine → `registry.registerKind`) — * the block's sole remaining live member. */ - contributes: z.object({ + contributes: strictObject({ + surface: 'the `contributes` block of this package manifest', + history: + 'Until this block was closed, an unknown key inside `contributes:` parsed green and was ' + + 'silently dropped — `kind:` for `kinds:` registered no metadata kind and said nothing. ' + + 'The one live member is `kinds`; the retired members answer with their prescriptions.', + }, { /** * Register new Metadata Kinds (identifiers). * @@ -374,7 +430,13 @@ export const ManifestSchema = z.object({ * declaration never fed — the former `globs` sub-field promised exactly * that and was retired for it (#11169). */ - kinds: z.array(z.object({ + kinds: z.array(strictObject({ + surface: 'a `contributes.kinds` entry of this package manifest', + history: + 'Until this entry shape was closed, an unknown key on a kind entry parsed green and ' + + 'was silently dropped. The declared keys are `id` and `description`; `globs` is a ' + + 'retired tombstone.', + }, { id: z.string().describe('The generic identifier of the kind (e.g., "sys.bi.report")'), /** REMOVED (#11169) — discovery reads the metadata type registry's `filePatterns`, never this. */ globs: retiredKey( @@ -524,8 +586,8 @@ export const ManifestSchema = z.object({ * stack-level `capabilities` collection) — none is reached from a * manifest. `PluginCapabilityManifestSchema` itself stays exported: the * plugin-registry surface (`plugin-registry.zod.ts`) still declares it. - * Tombstoned rather than deleted because `ManifestSchema` is not - * `.strict()` (the `loading` precedent below). + * Tombstoned rather than deleted so the rejection carries this + * prescription in both channels (the `loading` precedent below). */ capabilities: retiredKey( '`manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 ' + @@ -544,8 +606,8 @@ export const ManifestSchema = z.object({ * z.unknown())` — with zero readers anywhere, so whatever an author parked * here was stored and never consulted. Its emptiness in-repo was itself * evidence: an untyped catch-all with no users is a cheaper removal than - * one with unknown users. Tombstoned rather than deleted because - * `ManifestSchema` is not `.strict()` (the `loading` precedent below). + * one with unknown users. Tombstoned rather than deleted so the + * rejection carries this prescription (the `loading` precedent below). */ extensions: retiredKey( '`manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 ' + @@ -574,9 +636,12 @@ export const ManifestSchema = z.object({ * `preload`, `codeSplitting`, `dynamicImport`, `initialization`, * `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring` * — and NOTHING read any of it. It parsed, it entered the manifest, and it - * changed nothing. Tombstoned rather than deleted because `ManifestSchema` is - * not `.strict()`: a plain deletion would silently strip the key, replacing an - * inert declaration with an invisible one. + * changed nothing. Tombstoned rather than deleted: when this landed + * `ManifestSchema` was not `.strict()`, so a plain deletion would have + * silently stripped the key, replacing an inert declaration with an invisible + * one; the surface has since been closed, and the tombstone stays because it + * types the key `never` and carries this prescription, which a bare refusal + * would not. * * See `plugin-loading.zod.ts` for the full record, including why `sandboxing` * made this a security concern and not merely tidying. @@ -611,7 +676,12 @@ export const ManifestSchema = z.object({ * objectstack: ">=3.0.0" * ``` */ - engine: z.object({ + engine: strictObject({ + surface: 'the legacy `engine` block of this package manifest', + history: + 'Until this block was closed, an unknown key inside `engine:` parsed green and was ' + + 'silently dropped; its one declared key is `objectstack` (superseded by `engines`).', + }, { /** ObjectStack platform version requirement (SemVer range) */ objectstack: z.string() .regex(/^[><=~^]*\d+\.\d+\.\d+/) @@ -650,6 +720,23 @@ export const ManifestSchema = z.object({ packaging: PluginPackagingSchema.optional() .describe('Dependency packaging strategy (ADR-0025 §3.3)'), + /** + * Entry module of a code-bearing plugin, relative to the plugin root + * (ADR-0025 §3.4 step 1). + * + * `os plugin build` reads it off the SOURCE `objectstack.plugin.json` to pick + * the module esbuild bundles (`--entry` overrides it; with neither, the build + * probes `src/index.{ts,tsx,mjs,js}`), and REWRITES it into the COMPILED + * manifest inside the artifact as `dist/index.mjs`, the bundle a host loads. + * Declared because that door reads it: before this surface was closed the + * key was honoured while undeclared, so the schema described neither the + * source manifest an author writes nor the compiled one the artifact + * carries, and closing the surface without declaring it would have refused + * every plugin that names its entry. + */ + main: z.string().optional() + .describe('Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4)'), + /** * Per-file content digests of the packaged artifact (ADR-0025 §3.2). * Computed at build, self-checked at the publish preflight; unpack-time diff --git a/packages/spec/src/kernel/plugin-loading-retirement.test.ts b/packages/spec/src/kernel/plugin-loading-retirement.test.ts index 523062a98c..60717f175c 100644 --- a/packages/spec/src/kernel/plugin-loading-retirement.test.ts +++ b/packages/spec/src/kernel/plugin-loading-retirement.test.ts @@ -99,10 +99,10 @@ describe('[#4914] manifest.loading retirement', () => { it('parses cleanly once the key is deleted, and grows no `loading` property', () => { const parsed = ManifestSchema.parse({ ...baseManifest }); expect(parsed.id).toBe('com.example.plugin'); - // The non-strict strip path: absence must stay absence. If the tombstone - // were ever replaced by a plain deletion, an authored `loading` would be - // stripped here in silence — this pin plus the rejection above are what - // make that regression loud. + // Absence must stay absence. `ManifestSchema` is closed now, so a plain + // deletion would no longer strip an authored `loading` in silence — it would + // refuse it with a bare unknown-key message, without the prescription; this + // pin plus the rejection above are what keep the tombstone honest. expect(parsed).not.toHaveProperty('loading'); }); From f3946663ca898393baa601d69dfd76c9cd2f3ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:59:48 +0000 Subject: [PATCH 2/3] chore(spec): regenerate reference docs, authorable surface and liveness counts for the declared main key (#14192) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- content/docs/references/api/package-api.mdx | 3 +++ content/docs/references/api/protocol.mdx | 1 + content/docs/references/kernel/manifest.mdx | 1 + content/docs/references/kernel/package-registry.mdx | 2 ++ content/docs/references/kernel/package-upgrade.mdx | 2 ++ packages/spec/authorable-surface/kernel.json | 1 + packages/spec/liveness/state-counts.md | 4 ++-- 7 files changed, 12 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index ce9256bfad..5cc32e1223 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -210,6 +210,7 @@ Install package request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | ### Nested Shape: `PackageInstallRequest.artifactRef` @@ -332,6 +333,7 @@ Upgrade package request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -416,6 +418,7 @@ Resolve dependencies request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 837a525011..1a123c8985 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1893,6 +1893,7 @@ Install package request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 27984c7743..5fa552a07a 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -50,6 +50,7 @@ const result = ManifestSchema.parse(data); | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | ### Nested Shape: `Manifest.permissions` diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index 0cb9c50214..6e805f05d0 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -214,6 +214,7 @@ Install package request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -309,6 +310,7 @@ Installed package with runtime lifecycle state | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | ### Nested Shape: `InstalledPackage.upgradeHistory[number]` diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index d65043fd6a..387886614b 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -163,6 +163,7 @@ Upgrade package request | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | @@ -305,6 +306,7 @@ Pre-upgrade state snapshot for rollback capability | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | | **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | | **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | | **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index f9d3af8850..7883c8928b 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -293,6 +293,7 @@ "kernel/Manifest:id", "kernel/Manifest:integrity", "kernel/Manifest:loading [RETIRED]", + "kernel/Manifest:main", "kernel/Manifest:name", "kernel/Manifest:namespace", "kernel/Manifest:navigationContributions", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index f12dc96624..d52c1cdea9 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -57,5 +57,5 @@ for both corollaries. | `api` | 25 | 0 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | -| `manifest` | 22 | 0 | 1 | 15 | 0 | 38 | -| **total** | **825** | **5** | **1** | **74** | **10** | **915** | +| `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | +| **total** | **826** | **5** | **1** | **74** | **10** | **916** | From 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 19:29:18 +0000 Subject: [PATCH 3/3] chore(spec): regenerate liveness counts on the merged tree (#14192) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- packages/spec/liveness/state-counts.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index d52c1cdea9..6bad11dca1 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -58,4 +58,8 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | -| **total** | **826** | **5** | **1** | **74** | **10** | **916** | +| `crud_endpoints` | 6 | 0 | 0 | 5 | 0 | 11 | +| `metadata_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | +| `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | +| `route_generation` | 0 | 0 | 0 | 6 | 0 | 6 | +| **total** | **843** | **5** | **1** | **89** | **10** | **948** |