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..5a4a3475ac5
--- /dev/null
+++ b/.changeset/15292-dev-plugin-malformed-stack-posture.md
@@ -0,0 +1,15 @@
+---
+"@objectstack/plugin-dev": patch
+---
+
+`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
+
+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, 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 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 8b643fdcf60..76ebfcaac01 100644
--- a/content/docs/plugins/packages.mdx
+++ b/content/docs/plugins/packages.mdx
@@ -363,8 +363,52 @@ 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; 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
+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) 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 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
**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-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
new file mode 100644
index 00000000000..9146ab416f5
--- /dev/null
+++ b/packages/plugins/plugin-dev/src/dev-plugin-malformed-stack-posture.test.ts
@@ -0,0 +1,249 @@
+// 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; 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
+// 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 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';
+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) };
+ }
+}
+
+/** {@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();
+ const plugin = new DevPlugin({
+ stack: MISSING_IDENTITY as never,
+ services: ONLY_APP_METADATA,
+ seedAdminUser: false,
+ });
+
+ // 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
+ // 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. 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));
+
+ // 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);
+
+ 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 8f242f9ec26..b132ad3f467 100644
--- a/packages/plugins/plugin-dev/src/dev-plugin.ts
+++ b/packages/plugins/plugin-dev/src/dev-plugin.ts
@@ -381,6 +381,70 @@ 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; 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
+ * 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()`'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, 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
+ * 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
@@ -506,9 +570,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',
@@ -544,8 +617,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.