From 5ae91a510f37a8ec1863bf89d56ac093d0737d79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 14:37:11 +0000 Subject: [PATCH 1/5] docs(plugin-dev): document the malformed-stack boot posture and pin its division MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev boot tolerates and reports; `os validate` / build / publish refuse. Written in the two places `DevPlugin` is documented — its docblock and `content/docs/plugins/packages.mdx`. The reading that had to come first: the two branches are NOT one defect handled two ways. `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else — `collections` is a lazy getter first touched in `init()` — so a malformed `packages[]` passes the constructor untouched and is refused one branch later, inside the child-`init()` loop. Measured: the two malformations are exact complements, and a lit healthy control is silent on both instruments. The in-file comment reading "a malformed stack throws HERE" overclaims for that reason. Neither branch refuses today; both already degrade. What the posture adds is the written division and the rule that tolerating is never hiding. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/plugins/packages.mdx | 31 ++++ ...dev-plugin-malformed-stack-posture.test.ts | 135 ++++++++++++++++++ packages/plugins/plugin-dev/src/dev-plugin.ts | 45 ++++++ 3 files changed, 211 insertions(+) create mode 100644 packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 8b643fdcf60..a9fe96f4992 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -363,8 +363,39 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern - **Features**: Auto-assembles ObjectQL + in-memory driver + auth + security + Hono server + REST + dispatcher + app metadata, plus optional real services when installed (storage, realtime, i18n); registers no stubs — a slot no plugin fills stays empty, as in production (ADR-0115); refuses to boot with `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN` escape hatch, which brands the override in the boot log and on the ready banner instead of overriding silently) - **When to use**: Zero-config local development and playgrounds +- **Malformed metadata**: Dev boot **tolerates and reports** — it never refuses, and it never stays quiet - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md) +#### Malformed metadata: dev boot tolerates and reports + +**`os dev` keeps booting on a stack the platform would reject; `os validate`, build +and publish refuse it.** The dev server skips only the part it could not read, boots +the rest, and prints an `error` line naming what was malformed and what was skipped — +the shape every mainstream dev server takes, where the error overlay stays on screen +while the server keeps serving. + +This is deliberate, and it is one posture rather than two. Metadata that is incomplete +halfway through an edit is the **normal** state of a project you are actively working +on, so refusing to start would charge the cost to the only people this plugin exists +for. The contract is still enforced — just at the doors where an artifact leaves your +machine. See [Validating metadata](/docs/deployment/validating-metadata) for what +those doors check. + +Tolerating is not hiding. A boot that skipped something is never byte-identical to a +healthy one: if the diagnostic were dropped, an author — or a coding agent — would +read "it started" as "I wrote it correctly", which is exactly the outcome this posture +exists to prevent. If you see one of these lines, the app is running **without** the +metadata it names. + + + Two different malformations reach this through two different branches, and they are + exact complements — each is invisible to the other, so a clean boot past one is no + evidence about the other. An app payload with no `manifest.id` / `manifest.name` is + refused as the app metadata is constructed; a `packages[]` entry that is not a + package entry (ADR-0130 D4) is refused later, when that app's own `init()` reads the + package list, and reports as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). + + ### @objectstack/plugin-approvals **Approvals Plugin** — Contributes the `approval` flow node (ADR-0019): an approval runs on the one automation engine as a durable-pause node, backed by `sys_approval_request` / `sys_approval_action`. diff --git a/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts new file mode 100644 index 00000000000..0638d9f2d70 --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #15292 — the documented boot posture: dev boot TOLERATES a malformed stack +// and REPORTS it; `os validate` / build / publish are the doors that refuse. +// +// What this file pins is the posture and its DIVISION, not any diagnostic's +// wording. The wording of the malformed-metadata diagnostic is the subject of +// a sibling change and is deliberately NOT asserted here — a pin on today's +// text would turn an intended improvement into a failing test, and the posture +// is what the docblock and `content/docs/plugins/packages.mdx` now claim. +// +// ── Why the division needs a pin of its own ──────────────────────────────── +// Two DIFFERENT malformations arrive at this plugin, and the file's own +// comment at the `new AppPlugin(stack)` branch reads as though one branch +// caught both ("a malformed stack throws HERE"). It does not. `AppPlugin`'s +// constructor reads `manifest.id` / `manifest.name` and nothing else; +// `collections` is a lazy getter first touched in `init()`, so a malformed +// `packages[]` walks past the constructor and refuses one branch later, inside +// the child-`init()` loop. The two are exact COMPLEMENTS — each fires in one +// branch and is invisible to the other — which is why a clean boot past one +// says nothing about the other, and why the healthy control below is not +// optional: without it, two instruments that both simply always threw would +// produce the same green. + +import { describe, it, expect } from 'vitest'; +import { AppPlugin } from '@objectstack/runtime'; +import { resolveArtifactPackageOrder } from '@objectstack/core'; +import { DevPlugin } from './dev-plugin'; + +/** A `packages[]` entry with its body inlined instead of wrapped as `{ manifest: … }`. */ +const MALFORMED_PACKAGES = { + manifest: { id: 'com.acme.crm', name: 'crm', label: 'CRM', version: '1.0.0' }, + packages: [{ id: 'com.acme.crm.core', name: 'core', version: '1.0.0', type: 'package' }], +}; + +/** An envelope that plainly carries an app but never says which app it is. */ +const MISSING_IDENTITY = { objects: [{ name: 'task', label: 'Task' }] }; + +/** Neither malformation. The lit control for BOTH instruments below. */ +const HEALTHY = { + manifest: { id: 'com.acme.crm', name: 'crm', label: 'CRM', version: '1.0.0' }, + packages: [{ manifest: { id: 'com.acme.crm.core', name: 'core', version: '1.0.0', type: 'app' } }], +}; + +/** Every slot off but the app-metadata one, which is gated on `stack` alone. */ +const ONLY_APP_METADATA = { + objectql: false, driver: false, auth: false, server: false, rest: false, + dispatcher: false, security: false, i18n: false, storage: false, + 'file-storage': false, realtime: false, +}; + +function mockCtx() { + const lines: { level: string; text: string }[] = []; + const rec = (level: string) => (...a: unknown[]) => lines.push({ level, text: a.join(' ') }); + const services = new Map(); + const ctx = { + logger: { info: rec('info'), debug: rec('debug'), warn: rec('warn'), error: rec('error') }, + getService: (n: string) => { + if (services.has(n)) return services.get(n); + throw new Error(`service '${n}' is not registered`); + }, + getServices: () => new Map(), + registerService: (n: string, s: unknown) => { services.set(n, s); }, + hook: () => {}, trigger: () => {}, getKernel: () => undefined, + }; + return { ctx: ctx as never, lines }; +} + +/** What happened when `fn` ran: the thrown value, or `undefined` for a clean run. */ +function raised(fn: () => unknown): { code?: string; status?: number; message: string } | undefined { + try { fn(); return undefined; } catch (e) { + const err = e as { code?: string; status?: number; message?: string }; + return { code: err?.code, status: err?.status, message: String(err?.message ?? e) }; + } +} + +describe('#15292 — DevPlugin tolerates a malformed stack and reports it', () => { + it('boots past a metadata malformation instead of refusing, and is not silent about it', async () => { + const { ctx, lines } = mockCtx(); + const plugin = new DevPlugin({ + stack: MISSING_IDENTITY as never, + services: ONLY_APP_METADATA, + seedAdminUser: false, + }); + + // TOLERATES — the whole posture in one assertion. `os validate`, build and + // publish are the doors that refuse this same stack. + await expect(plugin.init(ctx)).resolves.toBeUndefined(); + + // REPORTS — the transcript of a degraded boot is never the transcript of a + // healthy one. The level is asserted; the wording deliberately is not. + const errors = lines.filter((l) => l.level === 'error'); + expect(errors.length).toBeGreaterThan(0); + + // …and it really did skip the thing it reported, rather than reporting a + // failure it then went on to recover from. + expect(lines.some((l) => l.text.includes('App metadata loaded from stack definition'))).toBe(false); + }, 60_000); + + it('the two malformations are exact complements — each is invisible to the other branch', () => { + // Branch 1 — `new AppPlugin(stack)`, DevPlugin's §3. Reads the envelope's + // identity, and nothing else. + const ctorMissingId = raised(() => new AppPlugin(MISSING_IDENTITY as never)); + const ctorMalformedPkgs = raised(() => new AppPlugin(MALFORMED_PACKAGES as never)); + const ctorHealthy = raised(() => new AppPlugin(HEALTHY as never)); + + // Branch 2 — the package-list parse, first reached from `AppPlugin.init()` + // and therefore caught by DevPlugin's child-`init()` loop, not by §3. + const parseMissingId = raised(() => resolveArtifactPackageOrder(MISSING_IDENTITY as never)); + const parseMalformedPkgs = raised(() => resolveArtifactPackageOrder(MALFORMED_PACKAGES as never)); + const parseHealthy = raised(() => resolveArtifactPackageOrder(HEALTHY as never)); + + // The control: a stack with neither malformation is silent on BOTH + // instruments. Without this row a pair of always-throwing instruments + // would satisfy every assertion above it. + expect(ctorHealthy).toBeUndefined(); + expect(parseHealthy).toBeUndefined(); + + // A missing identity is the constructor's business, and only its business. + expect(ctorMissingId?.message).toContain('no manifest.id / manifest.name'); + expect(parseMissingId).toBeUndefined(); + + // A malformed `packages[]` is invisible to the constructor and is refused + // by the parse — under ADR-0112, with a code and a status the bare + // constructor `Error` above does not carry. + expect(ctorMalformedPkgs).toBeUndefined(); + expect(parseMalformedPkgs?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(parseMalformedPkgs?.status).toBe(422); + + // The complement, stated as the single fact the docs now claim: no input + // here trips both branches, so neither is a second opinion on the other. + expect(ctorMissingId !== undefined && parseMissingId !== undefined).toBe(false); + expect(ctorMalformedPkgs !== undefined && parseMalformedPkgs !== undefined).toBe(false); + }, 60_000); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 8f242f9ec26..6f4380a7cf2 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -381,6 +381,51 @@ function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: Optio * real service (e.g. `@objectstack/service-analytics` for `/analytics` — it * runs an InMemory strategy). * + * ## Malformed metadata: dev boot tolerates and reports (#15292) + * + * **Dev boot tolerates and reports; `os validate` / build / publish refuse.** + * A stack the platform will reject does not stop `os dev`: `init()` keeps + * booting, skips only the part it could not read, and says so at `error`. + * This is the shape every mainstream dev server takes — the error overlay + * stays on screen while the server keeps serving. The refusal belongs at the + * PRODUCTION doors, and they already have it; charging the inner loop for + * that consistency a second time bills the one user group this plugin exists + * for, whose metadata is routinely incomplete mid-edit. That is the normal + * state here, not an exceptional one. + * + * ⛔ Tolerating is never hiding. A boot that skipped something must never be + * byte-identical to a healthy one — a silent degrade lets an author (or an + * AI) read "it started" as "I wrote it correctly", which is the one outcome + * this posture exists to prevent. + * + * Two DIFFERENT malformations reach this plugin, through two different + * branches. They are exact complements — each fires in one branch and is + * invisible to the other — so neither is a second opinion on the other: + * + * | Malformation | Refuses in | Surfaces as | + * |---|---|---| + * | An app payload with no `manifest.id` / `manifest.name` | `new AppPlugin(stack)` (§3) — a bare `Error`, no ADR-0112 `code`/`status` | {@link reportOptionalLoadFailure}'s failed arm | + * | A `packages[]` entry that is not a package entry (ADR-0130 D4) | `AppPlugin.init()` — `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422` | the child-`init()` loop's `error` line | + * + * ⛔ Do not read `new AppPlugin(stack)` as the stack's parse door. It reads + * `manifest.id` / `manifest.name` and nothing else; `collections` is a lazy + * getter first touched in `init()`, so a malformed `packages[]` passes the + * constructor untouched and refuses one branch later. + * + * §3b's i18n detector is the reference text for the diagnostic this posture + * wants: it reaches the SAME ADR-0130 D4 refusal and names the metadata + * defect and its remedy, never a package (#15232). + * + * ⛔ {@link reportOptionalLoadFailure} is not the vehicle for a + * metadata-shape defect: its text says the PACKAGE is installed but failed to + * initialize, which is precisely the mis-attribution #7926 removed from this + * file. + * + * One deliberate exception, and it is not about malformed metadata: a PRESENT + * `OrganizationsPlugin` that declines leaves the organization wall INACTIVE, + * and ADR-0093 D5 forbids serving traffic in that state, so the child-`init()` + * loop rethrows for that one plugin (#5301). + * * ## Production guard (ADR-0115 D6) * * `init()` refuses to run when `NODE_ENV === 'production'`: the assembly is From 7db891a2dc429651b210501d2e7f56e6022d35ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:01:30 +0000 Subject: [PATCH 2/5] chore(changeset): patch for the plugin-dev malformed-stack posture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docblock ships: measured, its text reaches `dist/index.d.ts` and `dist/index.d.mts`, both under the package's `files[]` — with a positive control (pre-existing docblock prose lands there too). Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../15292-dev-plugin-malformed-stack-posture.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/15292-dev-plugin-malformed-stack-posture.md diff --git a/.changeset/15292-dev-plugin-malformed-stack-posture.md b/.changeset/15292-dev-plugin-malformed-stack-posture.md new file mode 100644 index 00000000000..8ec00607079 --- /dev/null +++ b/.changeset/15292-dev-plugin-malformed-stack-posture.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-dev": patch +--- + +`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; `os validate` / build / publish refuse (#15292). + +Clause-②: no + +No behaviour changes. `DevPlugin` already degraded a stack the platform would reject, and the posture — ruled, not invented here — is that it should: the contract refuses at the production door, while the developer's inner loop tolerates incomplete input and never hides it. Metadata that is incomplete halfway through an edit is the normal state of a project under active development, so refusing at dev boot would charge the cost to the only user group this plugin exists for, for a consistency the production doors already provide. What was missing was the written posture and one load-bearing correction to it. + +- **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else; `collections` is a lazy getter first touched in `init()`. A malformed `packages[]` therefore passes the constructor untouched and is refused one branch later, from `AppPlugin.init()`, inside `DevPlugin`'s child-`init()` loop. The in-file comment reading *"a malformed stack throws HERE"* overclaims for that reason, and is corrected. +- **The two malformations are exact complements, measured with a lit control.** An app payload with no `manifest.id` / `manifest.name` throws from the constructor (a bare `Error`, no ADR-0112 `code` / `status`) and is invisible to the package-list parse; a `packages[]` entry that is not a package entry (ADR-0130 D4) is invisible to the constructor and refused by the parse as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`. A stack carrying neither is silent on both. So a clean boot past one branch is no evidence about the other — which is why the division is now documented rather than left to be re-derived. +- **Tolerating is not hiding.** The posture's second half is that a boot which skipped something is never byte-identical to a healthy one: a silent degrade lets an author, or a coding agent, read "it started" as "I wrote it correctly". +- **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`) and `content/docs/plugins/packages.mdx`, plus a test pinning the posture and its division. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. From 5f82990ad69f4e69d95321a634e7fdfbaa9b4d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 15:55:35 +0000 Subject: [PATCH 3/5] docs(plugin-dev): name the mechanism the malformed-`packages[]` refusal actually takes The superseded sentence ("`collections` is a lazy getter first touched in `init()`") was false about the mechanism while right about the conclusion. Measured on this tree, with a falsifier and a lit control: - `AppPlugin.init()` spans app-plugin.ts 319-395 and its LAST statement is `ctx.getService('manifest').register(servicePayload)`, built from `this.bundle` -- `packages[]` intact. - The `manifest` service is registered by `ObjectQLPlugin.init` (objectql/src/plugin.ts:430) and its `register()` calls `resolveArtifactPackageOrder` unguarded as its first statement (:448), which is what raises INVALID_ARTIFACT_PACKAGE_ENTRY / 422. - FALSIFIER: the same `init()` on the same malformed bundle, with `register()` replaced by a no-op, resolves clean. So nothing else `init()` runs touches `packages[]` -- the `collections` getter included. - Every `this.collections` read in app-plugin.ts is at line 668 or later, i.e. inside `start()`; its first read raises the SAME refusal there. - LIT CONTROL: a healthy stack is silent on both instruments. Rewritten in every carrier that shipped it: the `DevPlugin` docblock, the changeset body, the new test file's header, and the sibling dev-i18n-packages-reader.test.ts comment that named the constructor too. The two in-file comments the PR body claimed to correct are now actually corrected (D2 option (a)) -- section 3's catch and section 3b's "inversion" paragraph -- so the file no longer asserts the old mechanism 150 lines from the new one. Docs: `os validate` does not uniformly refuse. A malformed `packages[]` fails `ObjectStackDefinitionSchema` (measured, with the wrapped-entry control passing), but a stack with no `manifest` block parses green and yields the advisory "Missing manifest.id - required for deployment", which only fails under `--strict` (validate.ts structural-warnings block; exit is `flags.strict && warnings.length > 0 ? 1 : 0`). The page now says so. Tests: two new cases pin the init-time path -- the manifest-registration refusal with its falsifier and lit control, and a whole DevPlugin boot that tolerates the malformation and reports it on the child-`init()` loop's error line, with a healthy boot as the silent control. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...5292-dev-plugin-malformed-stack-posture.md | 7 +- content/docs/plugins/packages.mdx | 20 ++- .../src/dev-i18n-packages-reader.test.ts | 8 +- ...dev-plugin-malformed-stack-posture.test.ts | 126 ++++++++++++++++-- packages/plugins/plugin-dev/src/dev-plugin.ts | 39 ++++-- 5 files changed, 169 insertions(+), 31 deletions(-) diff --git a/.changeset/15292-dev-plugin-malformed-stack-posture.md b/.changeset/15292-dev-plugin-malformed-stack-posture.md index 8ec00607079..dcc39f9a032 100644 --- a/.changeset/15292-dev-plugin-malformed-stack-posture.md +++ b/.changeset/15292-dev-plugin-malformed-stack-posture.md @@ -2,13 +2,14 @@ "@objectstack/plugin-dev": patch --- -`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; `os validate` / build / publish refuse (#15292). +`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; the production doors refuse (#15292). Clause-②: no No behaviour changes. `DevPlugin` already degraded a stack the platform would reject, and the posture — ruled, not invented here — is that it should: the contract refuses at the production door, while the developer's inner loop tolerates incomplete input and never hides it. Metadata that is incomplete halfway through an edit is the normal state of a project under active development, so refusing at dev boot would charge the cost to the only user group this plugin exists for, for a consistency the production doors already provide. What was missing was the written posture and one load-bearing correction to it. -- **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else; `collections` is a lazy getter first touched in `init()`. A malformed `packages[]` therefore passes the constructor untouched and is refused one branch later, from `AppPlugin.init()`, inside `DevPlugin`'s child-`init()` loop. The in-file comment reading *"a malformed stack throws HERE"* overclaims for that reason, and is corrected. +- **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else, so a malformed `packages[]` passes the constructor untouched and is refused one branch later: `AppPlugin.init()`'s LAST statement hands the bundle to the `manifest` service, whose `register()` calls `resolveArtifactPackageOrder` unguarded, and `DevPlugin`'s child-`init()` loop degrades that refusal to an `error` line. The lazy `collections` getter is not on that path at all — it is not read during `init()`, and its first read is in `AppPlugin.start()`, where it reaches the same refusal on the same bytes. Both in-file comments that named the constructor as the stack's parse door (*"a malformed stack throws HERE"*, and §3b's *"twenty lines above, `new AppPlugin(...)` parses the SAME object"*) overclaim for that reason, and both are corrected in this PR. - **The two malformations are exact complements, measured with a lit control.** An app payload with no `manifest.id` / `manifest.name` throws from the constructor (a bare `Error`, no ADR-0112 `code` / `status`) and is invisible to the package-list parse; a `packages[]` entry that is not a package entry (ADR-0130 D4) is invisible to the constructor and refused by the parse as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`. A stack carrying neither is silent on both. So a clean boot past one branch is no evidence about the other — which is why the division is now documented rather than left to be re-derived. - **Tolerating is not hiding.** The posture's second half is that a boot which skipped something is never byte-identical to a healthy one: a silent degrade lets an author, or a coding agent, read "it started" as "I wrote it correctly". -- **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`) and `content/docs/plugins/packages.mdx`, plus a test pinning the posture and its division. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. +- **The `os validate` door is not uniform, and the docs page now says so.** A malformed `packages[]` fails `ObjectStackDefinitionSchema` and exits 1; an app payload with no `manifest.id` parses green and is reported as the advisory *"Missing manifest.id — required for deployment"*, which only fails under `--strict` (`packages/cli/src/commands/validate.ts` — the advisory is pushed at the structural-warnings block and the exit is `flags.strict && warnings.length > 0 ? 1 : 0`). The page's flat "`os validate` … refuses it" overstated the second half. +- **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`), the two in-file comments named above, and `content/docs/plugins/packages.mdx`, plus a test pinning the posture, its division and the init-time path the refusal actually takes. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index a9fe96f4992..bda97cdc43a 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -368,8 +368,10 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern #### Malformed metadata: dev boot tolerates and reports -**`os dev` keeps booting on a stack the platform would reject; `os validate`, build -and publish refuse it.** The dev server skips only the part it could not read, boots +**`os dev` keeps booting on a stack the platform would reject; build and publish +refuse it, and `os validate` reports it — as a failure for anything the stack schema +refuses, and as an advisory that `--strict` promotes to one for the rest.** The dev +server skips only the part it could not read, boots the rest, and prints an `error` line naming what was malformed and what was skipped — the shape every mainstream dev server takes, where the error overlay stays on screen while the server keeps serving. @@ -391,9 +393,17 @@ metadata it names. Two different malformations reach this through two different branches, and they are exact complements — each is invisible to the other, so a clean boot past one is no evidence about the other. An app payload with no `manifest.id` / `manifest.name` is - refused as the app metadata is constructed; a `packages[]` entry that is not a - package entry (ADR-0130 D4) is refused later, when that app's own `init()` reads the - package list, and reports as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422). + refused as the app metadata is constructed. A `packages[]` entry that is not a + package entry (ADR-0130 D4) walks past that construction untouched and is refused + one step later, when the app's own `init()` hands the stack to the kernel's + `manifest` service and its package list is parsed; it reports as + `INVALID_ARTIFACT_PACKAGE_ENTRY` (422) on the `error` line naming the app plugin. + + The two differ at the `os validate` door as well. The malformed `packages[]` fails + the stack schema, so `os validate` exits 1 on it. The missing `manifest.id` parses + green and comes back as the advisory *"Missing manifest.id — required for + deployment"*, which exits 0 unless you pass `--strict`. Use `os validate --strict` + if you want that half to fail too. ### @objectstack/plugin-approvals diff --git a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts index e43552d105e..e426b8bf4f9 100644 --- a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts +++ b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts @@ -365,8 +365,12 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', () // (packages/spec/src/assembled-package-body.test.ts). That project boots // today; a reader that threw here would have stopped it booting — and from // the block whose only job is deciding whether to register a translation - // service, while `new AppPlugin(...)` twenty lines above degrades the very - // same refusal to a log line. + // service, while the app-metadata branch degrades the very same refusal to + // a log line — `AppPlugin.init()` hands the stack to the `manifest` + // service, whose `register()` reaches the SAME `resolveArtifactPackageOrder` + // parse, and DevPlugin's child-`init()` loop logs it instead of rethrowing. + // ⛔ Not `new AppPlugin(...)`: the constructor reads `manifest.id` / + // `manifest.name` only and never sees this malformation (#15292). const refused = additiveNoI18nProject(); (refused.packages as Array<{ manifest: Record }>)[0] .manifest.objects = ['./src/objects/*.object.ts']; diff --git a/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts index 0638d9f2d70..9858813be49 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts @@ -11,16 +11,27 @@ // // ── Why the division needs a pin of its own ──────────────────────────────── // Two DIFFERENT malformations arrive at this plugin, and the file's own -// comment at the `new AppPlugin(stack)` branch reads as though one branch -// caught both ("a malformed stack throws HERE"). It does not. `AppPlugin`'s -// constructor reads `manifest.id` / `manifest.name` and nothing else; -// `collections` is a lazy getter first touched in `init()`, so a malformed -// `packages[]` walks past the constructor and refuses one branch later, inside -// the child-`init()` loop. The two are exact COMPLEMENTS — each fires in one -// branch and is invisible to the other — which is why a clean boot past one -// says nothing about the other, and why the healthy control below is not -// optional: without it, two instruments that both simply always threw would -// produce the same green. +// comment at the `new AppPlugin(stack)` branch used to read as though one +// branch caught both ("a malformed stack throws HERE"). It does not. +// `AppPlugin`'s constructor reads `manifest.id` / `manifest.name` and nothing +// else, so a malformed `packages[]` walks past it and is refused one branch +// later: `AppPlugin.init()`'s LAST statement hands the bundle to the kernel's +// `manifest` service, whose `register()` calls `resolveArtifactPackageOrder` +// unguarded, and DevPlugin's child-`init()` loop degrades that refusal to an +// `error` line. +// +// ⛔ The lazy `collections` getter is NOT on that path. It is not read during +// `AppPlugin.init()` at all — its first read is in `AppPlugin.start()`, where +// it reaches the same refusal on the same bytes. The third case below is the +// falsifier for the superseded wording: with the `manifest` service's +// `register()` replaced by a no-op, the same malformed-`packages[]` `init()` +// resolves clean. +// +// The two malformations are exact COMPLEMENTS — each fires in one branch and +// is invisible to the other — which is why a clean boot past one says nothing +// about the other, and why the healthy control below is not optional: without +// it, two instruments that both simply always threw would produce the same +// green. import { describe, it, expect } from 'vitest'; import { AppPlugin } from '@objectstack/runtime'; @@ -74,6 +85,35 @@ function raised(fn: () => unknown): { code?: string; status?: number; message: s } } +/** {@link raised}, for an awaited call. */ +async function raisedAsync( + fn: () => Promise, +): Promise<{ code?: string; status?: number; message: string } | undefined> { + try { await fn(); return undefined; } catch (e) { + const err = e as { code?: string; status?: number; message?: string }; + return { code: err?.code, status: err?.status, message: String(err?.message ?? e) }; + } +} + +/** + * A kernel context carrying exactly one service: `manifest`. `register` is the + * injection point — the real parse, or a no-op — which is what makes the + * falsifier below a measurement rather than a restatement. + */ +function appCtx(register: (artifact: unknown) => void) { + const noop = () => {}; + return { + logger: { info: noop, debug: noop, warn: noop, error: noop }, + getService: (n: string) => { + if (n === 'manifest') return { register }; + throw new Error(`service '${n}' is not registered`); + }, + getServices: () => new Map(), + registerService: noop, + hook: noop, trigger: noop, getKernel: () => undefined, + }; +} + describe('#15292 — DevPlugin tolerates a malformed stack and reports it', () => { it('boots past a metadata malformation instead of refusing, and is not silent about it', async () => { const { ctx, lines } = mockCtx(); @@ -104,8 +144,9 @@ describe('#15292 — DevPlugin tolerates a malformed stack and reports it', () = const ctorMalformedPkgs = raised(() => new AppPlugin(MALFORMED_PACKAGES as never)); const ctorHealthy = raised(() => new AppPlugin(HEALTHY as never)); - // Branch 2 — the package-list parse, first reached from `AppPlugin.init()` - // and therefore caught by DevPlugin's child-`init()` loop, not by §3. + // Branch 2 — the package-list parse. On a real boot it is reached from + // `AppPlugin.init()`'s manifest registration (pinned below) and therefore + // caught by DevPlugin's child-`init()` loop, not by §3. const parseMissingId = raised(() => resolveArtifactPackageOrder(MISSING_IDENTITY as never)); const parseMalformedPkgs = raised(() => resolveArtifactPackageOrder(MALFORMED_PACKAGES as never)); const parseHealthy = raised(() => resolveArtifactPackageOrder(HEALTHY as never)); @@ -132,4 +173,65 @@ describe('#15292 — DevPlugin tolerates a malformed stack and reports it', () = expect(ctorMissingId !== undefined && parseMissingId !== undefined).toBe(false); expect(ctorMalformedPkgs !== undefined && parseMalformedPkgs !== undefined).toBe(false); }, 60_000); + + it('the `packages[]` refusal surfaces from `AppPlugin.init()`\'s manifest registration, not from `collections`', async () => { + // What the real `manifest` service's `register()` does first, and + // unguarded: `ObjectQLPlugin.init` registers exactly this parse. + const realRegister = (artifact: unknown) => { resolveArtifactPackageOrder(artifact); }; + + // The measured path. `AppPlugin.init()`'s LAST statement is + // `getService('manifest').register(payload)`, so the ADR-0112 refusal + // arrives from there — with a code and a status, unlike the constructor's + // bare `Error`. + const viaManifest = await raisedAsync( + () => new AppPlugin(MALFORMED_PACKAGES as never).init(appCtx(realRegister) as never), + ); + expect(viaManifest?.code).toBe('INVALID_ARTIFACT_PACKAGE_ENTRY'); + expect(viaManifest?.status).toBe(422); + + // THE FALSIFIER for "a lazy getter first touched in `init()`". Same bundle, + // same `init()`, `register()` replaced by a no-op: nothing else `init()` + // runs — the `collections` getter included — touches `packages[]`, so this + // resolves clean. Were `collections` read in `init()`, this row would throw. + expect(await raisedAsync( + () => new AppPlugin(MALFORMED_PACKAGES as never).init(appCtx(() => {}) as never), + )).toBeUndefined(); + + // THE LIT CONTROL for the first row: the real `register()` is not an + // instrument that simply always throws. + expect(await raisedAsync( + () => new AppPlugin(HEALTHY as never).init(appCtx(realRegister) as never), + )).toBeUndefined(); + }, 60_000); + + it('a whole DevPlugin boot tolerates a malformed `packages[]` and reports it on the child-`init()` loop\'s error line', async () => { + // The end-to-end shape the docblock and the docs page claim, on the branch + // §3's catch never sees. `objectql` is ON here — without it there is no + // `manifest` service to register into, and the refusal under test cannot be + // reached at all. + const boot = async (stack: unknown) => { + const { ctx, lines } = mockCtx(); + const plugin = new DevPlugin({ + stack: stack as never, + services: { ...ONLY_APP_METADATA, objectql: true, driver: true }, + seedAdminUser: false, + }); + await expect(plugin.init(ctx)).resolves.toBeUndefined(); // TOLERATES + return lines; + }; + + const degraded = await boot(MALFORMED_PACKAGES); + // The constructor ACCEPTED it — §3 logged its success line — which is the + // whole point: this malformation is invisible to that branch. + expect(degraded.some((l) => l.text.includes('App metadata loaded from stack definition'))).toBe(true); + // REPORTS — on the child-`init()` loop's error line, carrying the refusal + // verbatim. The refusal is asserted; the loop's own phrasing is not. + const errors = degraded.filter((l) => l.level === 'error'); + expect(errors.some((l) => l.text.includes('is not a package entry'))).toBe(true); + + // THE LIT CONTROL. A boot that skipped something is never byte-identical to + // a healthy one — so the healthy stack produces no error line at all. + const healthy = await boot(HEALTHY); + expect(healthy.filter((l) => l.level === 'error')).toEqual([]); + }, 60_000); }); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 6f4380a7cf2..1a12541bb4a 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -405,12 +405,21 @@ function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: Optio * | Malformation | Refuses in | Surfaces as | * |---|---|---| * | An app payload with no `manifest.id` / `manifest.name` | `new AppPlugin(stack)` (§3) — a bare `Error`, no ADR-0112 `code`/`status` | {@link reportOptionalLoadFailure}'s failed arm | - * | A `packages[]` entry that is not a package entry (ADR-0130 D4) | `AppPlugin.init()` — `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422` | the child-`init()` loop's `error` line | + * | A `packages[]` entry that is not a package entry (ADR-0130 D4) | `AppPlugin.init()`'s LAST statement — the `manifest` service's `register()`, which calls `resolveArtifactPackageOrder` unguarded — `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422` | the child-`init()` loop's `error` line | * * ⛔ Do not read `new AppPlugin(stack)` as the stack's parse door. It reads - * `manifest.id` / `manifest.name` and nothing else; `collections` is a lazy - * getter first touched in `init()`, so a malformed `packages[]` passes the - * constructor untouched and refuses one branch later. + * `manifest.id` / `manifest.name` and nothing else, so a malformed + * `packages[]` passes the constructor untouched and is refused one branch + * later: `AppPlugin.init()`'s LAST statement hands the bundle to the + * `manifest` service — registered by `ObjectQLPlugin.init` — whose + * `register()` calls `resolveArtifactPackageOrder` unguarded, and the + * child-`init()` loop below degrades that refusal to an `error` line. + * + * ⛔ Nor is the lazy `collections` getter that door. Measured on this tree, + * it is not read during `AppPlugin.init()` at all — its first read is in + * `AppPlugin.start()`, where it reaches the SAME refusal on the same bytes. + * The falsifier: replace the `manifest` service's `register()` with a no-op + * and the same malformed-`packages[]` `init()` resolves clean. * * §3b's i18n detector is the reference text for the diagnostic this posture * wants: it reaches the SAME ADR-0130 D4 refusal and names the metadata @@ -551,9 +560,18 @@ export class DevPlugin implements Plugin { this.childPlugins.push(appPlugin); ctx.logger.info(' ✔ App metadata loaded from stack definition'); } catch (err) { - // `new AppPlugin(stack)` parses the stack definition, so a malformed - // stack throws HERE — a construction failure with a real diagnosis, - // previously reported as an absent @objectstack/runtime. + // [#15292] `new AppPlugin(stack)` reads the envelope's IDENTITY — + // `manifest.id` / `manifest.name` — and nothing else, so what is + // refused HERE is an app payload that never says which app it is: a + // bare `Error` carrying no ADR-0112 `code`/`status`, previously + // reported as an absent @objectstack/runtime. + // + // ⛔ This is NOT the stack's parse door. A malformed `packages[]` + // (ADR-0130 D4) passes this constructor untouched and is refused one + // branch later, from `AppPlugin.init()`'s last statement — the + // `manifest` service's `register()`, which calls + // `resolveArtifactPackageOrder` unguarded — where the child-`init()` + // loop degrades it to an `error` line. See the class docblock. reportOptionalLoadFailure(ctx, err, { packages: ['@objectstack/runtime'], absent: ' ✘ @objectstack/runtime not installed — skipping app metadata', @@ -589,8 +607,11 @@ export class DevPlugin implements Plugin { // so an artifact the ADR-0130 D4 gate refuses — a package body still // carrying authoring-time glob `objects`, for instance, which // `ArtifactPackageSchema` rejects by design — arrives here on the - // ordinary path. The inversion: twenty lines above, `new AppPlugin(...)` - // parses the SAME object and its refusal is degraded to a log line, so + // ordinary path. The inversion: the app-metadata branch above reaches + // the SAME parse of the SAME object — `AppPlugin.init()`'s last + // statement hands the bundle to the `manifest` service, whose + // `register()` calls `resolveArtifactPackageOrder` — and the + // child-`init()` loop degrades that refusal to a log line, so // refusing here would make "should I register a translation service?" // a harder gate than "should I register this app's metadata at all?". // A project like that boots today; it must keep booting. From a3f52cfc6d716ead146f86e69e4a10d8e410ec13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 16:24:56 +0000 Subject: [PATCH 4/5] docs(plugins): re-wrap the tolerate-and-report paragraph Cosmetic only -- the previous commit left a short line mid-paragraph. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/plugins/packages.mdx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index bda97cdc43a..1f36d6f08f7 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -371,10 +371,9 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern **`os dev` keeps booting on a stack the platform would reject; build and publish refuse it, and `os validate` reports it — as a failure for anything the stack schema refuses, and as an advisory that `--strict` promotes to one for the rest.** The dev -server skips only the part it could not read, boots -the rest, and prints an `error` line naming what was malformed and what was skipped — -the shape every mainstream dev server takes, where the error overlay stays on screen -while the server keeps serving. +server skips only the part it could not read, boots the rest, and prints an `error` +line naming what was malformed and what was skipped — the shape every mainstream dev +server takes, where the error overlay stays on screen while the server keeps serving. This is deliberate, and it is one posture rather than two. Metadata that is incomplete halfway through an edit is the **normal** state of a project you are actively working From e895dda8b9747b052a54ea2a7fa1155599e45a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 17:12:05 +0000 Subject: [PATCH 5/5] docs(plugin-dev): the production doors are not uniform, in every carrier The at-tier review's fail basis: this PR tightened the `os validate` clause in two carriers and left it flat in three, while its own changeset called the flat wording an overstatement. Three named sites, all comment-only: - dev-plugin.ts:386 -- the docblock sentence, which ships to dist/*.d.ts and was a flat universal claim over BOTH rows of its own table. - dev-plugin-malformed-stack-posture.test.ts:4 -- the file header. - same file, the comment on the MISSING_IDENTITY case, which asserted the false half about the very fixture it is attached to. Extended to two more carriers so the PR does not ship a THIRD posture in 3 of 7 places -- content/docs/plugins/packages.mdx and the changeset -- because the reading below falsifies their `build` half too, not only `os validate`. Measured, first-hand, beyond what the review covered: - `os build` is `compile.ts` (build.ts is `class Build extends Compile`), and at compile.ts:347 it runs the SAME `ObjectStackDefinitionSchema.safeParse` validate runs, exiting 1 at :352/:368. So both doors refuse a malformed `packages[]`. - compile.ts contains NO `manifest.id` requirement, and its own comment at :967-971 says the structural advisories are absent because "os compile never computes them at all (this file has no 'No objects defined' / 'may not do much' string, in any face)". => `os build` is SILENT on a stack with no `manifest.id`, so "build refuses it" was overstated exactly as `os validate` was. - validate.ts text face re-read at :760-774: `this.exit(1)` fires only inside `if (flags.strict)`, confirming the `--json` ternary at :740 is not the only exit and both read one `warnings` list. - lower-callables.ts:315-319 passes a non-`{ manifest: object }` entry through untouched, so the schema probe's verdict transfers to what the CLI actually parses. - `publish` is not a door this card measured; the claim is dropped rather than restated. Zero non-comment changed lines in both source files (classifier lit on a planted code line). No behaviour change. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...5292-dev-plugin-malformed-stack-posture.md | 4 +-- content/docs/plugins/packages.mdx | 26 +++++++++++-------- ...dev-plugin-malformed-stack-posture.test.ts | 18 ++++++++++--- packages/plugins/plugin-dev/src/dev-plugin.ts | 12 ++++++++- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/.changeset/15292-dev-plugin-malformed-stack-posture.md b/.changeset/15292-dev-plugin-malformed-stack-posture.md index dcc39f9a032..5a4a3475ac5 100644 --- a/.changeset/15292-dev-plugin-malformed-stack-posture.md +++ b/.changeset/15292-dev-plugin-malformed-stack-posture.md @@ -2,7 +2,7 @@ "@objectstack/plugin-dev": patch --- -`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; the production doors refuse (#15292). +`DevPlugin`'s boot posture on a malformed stack is now written down: dev boot tolerates and reports; refusing belongs to the production doors, which are not uniform about it (#15292). Clause-②: no @@ -11,5 +11,5 @@ No behaviour changes. `DevPlugin` already degraded a stack the platform would re - **The two branches are not one defect handled two ways.** `new AppPlugin(stack)` reads `manifest.id` / `manifest.name` and nothing else, so a malformed `packages[]` passes the constructor untouched and is refused one branch later: `AppPlugin.init()`'s LAST statement hands the bundle to the `manifest` service, whose `register()` calls `resolveArtifactPackageOrder` unguarded, and `DevPlugin`'s child-`init()` loop degrades that refusal to an `error` line. The lazy `collections` getter is not on that path at all — it is not read during `init()`, and its first read is in `AppPlugin.start()`, where it reaches the same refusal on the same bytes. Both in-file comments that named the constructor as the stack's parse door (*"a malformed stack throws HERE"*, and §3b's *"twenty lines above, `new AppPlugin(...)` parses the SAME object"*) overclaim for that reason, and both are corrected in this PR. - **The two malformations are exact complements, measured with a lit control.** An app payload with no `manifest.id` / `manifest.name` throws from the constructor (a bare `Error`, no ADR-0112 `code` / `status`) and is invisible to the package-list parse; a `packages[]` entry that is not a package entry (ADR-0130 D4) is invisible to the constructor and refused by the parse as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`. A stack carrying neither is silent on both. So a clean boot past one branch is no evidence about the other — which is why the division is now documented rather than left to be re-derived. - **Tolerating is not hiding.** The posture's second half is that a boot which skipped something is never byte-identical to a healthy one: a silent degrade lets an author, or a coding agent, read "it started" as "I wrote it correctly". -- **The `os validate` door is not uniform, and the docs page now says so.** A malformed `packages[]` fails `ObjectStackDefinitionSchema` and exits 1; an app payload with no `manifest.id` parses green and is reported as the advisory *"Missing manifest.id — required for deployment"*, which only fails under `--strict` (`packages/cli/src/commands/validate.ts` — the advisory is pushed at the structural-warnings block and the exit is `flags.strict && warnings.length > 0 ? 1 : 0`). The page's flat "`os validate` … refuses it" overstated the second half. +- **The production doors are not uniform, and every carrier now says so.** A malformed `packages[]` fails `ObjectStackDefinitionSchema` — `packages: z.array(ArtifactPackageSchema)`, the SAME entry schema the runtime parse uses — and both `os validate` and `os build` parse the lowered stack against it and exit 1 (`validate.ts` step 2; `compile.ts` step 3). `lowerCallables` passes a non-`{ manifest: object }` entry through untouched, so the verdict transfers to what the CLI actually parses. An app payload with no `manifest.id` parses green at BOTH: `os validate` reports it only as the structural advisory *"Missing manifest.id — required for deployment"*, which fails only under `--strict` (both exit faces read one `warnings` list — the `--json` ternary and the text face's `if (flags.strict)` block), and `os compile` "never computes them at all" in its own words, so `os build` is silent on it. The flat "`os validate` / build / publish refuse" overstated BOTH doors for that half, and `publish` is simply not a door this card measured, so it is no longer claimed. - **What ships**: the `DevPlugin` docblock (which reaches the published `dist/*.d.ts`), the two in-file comments named above, and `content/docs/plugins/packages.mdx`, plus a test pinning the posture, its division and the init-time path the refusal actually takes. The wording of the malformed-metadata diagnostic itself is deliberately not pinned — that text is a sibling change. diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 1f36d6f08f7..76ebfcaac01 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -368,12 +368,15 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern #### Malformed metadata: dev boot tolerates and reports -**`os dev` keeps booting on a stack the platform would reject; build and publish -refuse it, and `os validate` reports it — as a failure for anything the stack schema -refuses, and as an advisory that `--strict` promotes to one for the rest.** The dev -server skips only the part it could not read, boots the rest, and prints an `error` -line naming what was malformed and what was skipped — the shape every mainstream dev -server takes, where the error overlay stays on screen while the server keeps serving. +**`os dev` keeps booting on a stack the platform would reject; refusing belongs to +the doors an artifact leaves your machine through — and those doors are not uniform +about it.** `os validate` and `os build` both parse your stack against the same +protocol schema and exit 1 when it fails; what that schema accepts but a deployment +still needs comes back from `os validate` as an advisory instead, which `--strict` +promotes to a failure. The dev server skips only the part it could not read, boots the +rest, and prints an `error` line naming what was malformed and what was skipped — the +shape every mainstream dev server takes, where the error overlay stays on screen while +the server keeps serving. This is deliberate, and it is one posture rather than two. Metadata that is incomplete halfway through an edit is the **normal** state of a project you are actively working @@ -398,11 +401,12 @@ metadata it names. `manifest` service and its package list is parsed; it reports as `INVALID_ARTIFACT_PACKAGE_ENTRY` (422) on the `error` line naming the app plugin. - The two differ at the `os validate` door as well. The malformed `packages[]` fails - the stack schema, so `os validate` exits 1 on it. The missing `manifest.id` parses - green and comes back as the advisory *"Missing manifest.id — required for - deployment"*, which exits 0 unless you pass `--strict`. Use `os validate --strict` - if you want that half to fail too. + The two differ at the production doors as well. The malformed `packages[]` fails + the protocol schema, so both `os validate` and `os build` exit 1 on it. The missing + `manifest.id` parses green for both: `os validate` reports it as the advisory + *"Missing manifest.id — required for deployment"*, which exits 0 unless you pass + `--strict`, and `os build` does not report it at all. So run `os validate --strict` + if you want that half to fail too — a green `os build` is not evidence about it. ### @objectstack/plugin-approvals diff --git a/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts index 9858813be49..9146ab416f5 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts @@ -1,7 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // #15292 — the documented boot posture: dev boot TOLERATES a malformed stack -// and REPORTS it; `os validate` / build / publish are the doors that refuse. +// and REPORTS it; refusing belongs to the production doors — which are NOT +// uniform about it. `os validate` and `os build` both parse the stack against +// `ObjectStackDefinitionSchema` and exit 1 on a malformed `packages[]`; an app +// payload with no `manifest.id` parses green for both, surfacing only as +// `os validate`'s structural advisory, which exits 0 unless `--strict`. // // What this file pins is the posture and its DIVISION, not any diagnostic's // wording. The wording of the malformed-metadata diagnostic is the subject of @@ -123,8 +127,16 @@ describe('#15292 — DevPlugin tolerates a malformed stack and reports it', () = seedAdminUser: false, }); - // TOLERATES — the whole posture in one assertion. `os validate`, build and - // publish are the doors that refuse this same stack. + // TOLERATES — the whole posture in one assertion. + // + // ⛔ And mind WHICH HALF this fixture is. `MISSING_IDENTITY` carries no + // `manifest.id`, and that is the half the production doors do NOT refuse: + // `ObjectStackDefinitionSchema` accepts a stack with no `manifest` block, + // so plain `os validate` exits 0 on it and reports only the structural + // advisory "Missing manifest.id — required for deployment" (`--strict` + // promotes it), while `os build` never computes that advisory at all. The + // half those doors really do refuse is `MALFORMED_PACKAGES` — see the last + // case in this file. await expect(plugin.init(ctx)).resolves.toBeUndefined(); // REPORTS — the transcript of a degraded boot is never the transcript of a diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 1a12541bb4a..b132ad3f467 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -383,7 +383,17 @@ function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: Optio * * ## Malformed metadata: dev boot tolerates and reports (#15292) * - * **Dev boot tolerates and reports; `os validate` / build / publish refuse.** + * **Dev boot tolerates and reports; refusing belongs to the PRODUCTION + * doors — and they are not uniform about it.** Measured: `os validate` and + * `os build` both parse the stack against `ObjectStackDefinitionSchema` + * (`packages: z.array(ArtifactPackageSchema)`) and exit 1 when it fails, so + * both refuse row 2 of the table below. Row 1 parses GREEN at both: a stack + * carrying no `manifest.id` is reported only as `os validate`'s structural + * advisory "Missing manifest.id — required for deployment", which exits 0 + * unless `--strict`, and `os build` never computes that advisory at all. + * ⛔ So do not write "the production doors refuse it" flat — that is a claim + * over BOTH rows, and it is false of row 1. + * * A stack the platform will reject does not stop `os dev`: `init()` keeps * booting, skips only the part it could not read, and says so at `error`. * This is the shape every mainstream dev server takes — the error overlay