Skip to content

Commit dc9e29b

Browse files
os-warrenclaude
andauthored
docs(plugin-dev): document the malformed-stack boot posture and pin its division (#19602)
Fixes #15292 Clause-②: no Ruling C (`5644710907`, director seat, decision batch #123 item 4, maintainer 「同意」 2026-09-12) settled this card and returned it to `pm:queue`. This PR is **item 2** (the ordered reading, which came first) and **item 3** (the posture text). ⛔ It is deliberately **not item 1**. Making `DevPlugin.init` emit the malformed-metadata diagnostic, and adding the skipped app to the CLI startup summary, is the cli seat's sibling PR — the ruling's own State line: 「posture text is the spec seat's; the diagnostic implementation is the cli seat's sibling PR」. --- ## Item 2 — the reading, and which arm it lands on Ruling C, verbatim: > read the two branches twenty lines apart (`dev-plugin.ts:505` vs `:856+`) and write down their triggering conditions. If they are two different malformations with an unwritten division (structural refusal vs. missing-field degrade), the division is DOCUMENTED as the posture, and the loud diagnostic applies to the degrade branch; if they really are the same defect handled two ways, the degrade+loud posture wins on both. ### Verdict: **arm 1 — two different malformations, and the division is now documented.** With one correction the ruling could not have made from the card: **there is no refusal half.** Neither branch refuses today. Both already degrade. ### Finding the branches Coordinates were resolved by shape, then checked against the numbers rather than trusted: - `dev-plugin.ts` last changed on **2026-09-10** (`50bc9c73b5`, a 9-insert / 9-delete in-place edit), *before* the ruling — so `:505` (`new AppPlugin(this.options.stack)`) and `:512` (`reportOptionalLoadFailure(`) still land exactly where the card cites them. - `:856+` does **not**. The card's prose attaches it to *"Every child `init()` failure is likewise caught ... with exactly one deliberate exception (#5301, organizations)"* — that is the **child-`init()` loop**, which sits at `:891` in the post-#15232 tree the `:505`/`:512` numbers come from. At literal `:856` in that same tree sits the unrelated `@objectstack/rest` optional-load catch. The card mixed coordinates from two trees: in the pre-#15232 tree (main on the card's filing date, 2026-09-04) `:856` **is** `await plugin.init(ctx)`, but there `:505` is not `new AppPlugin`. Resolved in favour of the prose, which is unambiguous. ### Triggering conditions — measured, with a lit control | input | `new AppPlugin(bundle)` — branch `:505` | the package-list parse — reached from `AppPlugin.init()`, i.e. the child-`init()` loop | |---|---|---| | app payload, no `manifest.id` / `manifest.name` | **THREW** `[AppPlugin] bundle has app payload but no manifest.id / manifest.name` — a bare `Error`, no ADR-0112 `code` / `status` | no throw | | `packages[]` entry with its body inlined instead of wrapped | **no throw** | **THREW** `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422` | | healthy control (neither malformation) | no throw | no throw | The two defect rows are **exact complements**, and the control row is silent on both — so neither instrument is stuck-on-throw and neither branch is a second opinion on the other. The control earned its place twice: two earlier probe designs produced a green that the control exposed as meaningless (mocking `@objectstack/objectql` breaks `@objectstack/runtime`'s own import, collapsing every case onto "runtime not installed"; and a mock context too thin for `AppPlugin.init` killed it before the parse, so the malformed case and the healthy case emitted the same line). ### The load-bearing correction Before this PR, the in-file comment then at `:509` read — ⚠️ **past tense on purpose**: at head that catch block is `dev-plugin.ts:573` and it says the opposite, because this PR rewrote it: > `new AppPlugin(stack)` parses the stack definition, so a malformed stack throws HERE **It overclaimed.** `AppPlugin`’s constructor reads `manifest.id` / `manifest.name` and nothing else, so the malformation the card actually measured — `INVALID_ARTIFACT_PACKAGE_ENTRY` — never reaches section 3 at all. It is refused one branch later, from `AppPlugin.init()`’s LAST statement: `ctx.getService(manifest).register(servicePayload)` (`app-plugin.ts:394`) hands the bundle, `packages[]` intact, to the `manifest` service that `ObjectQLPlugin.init` registers (`objectql/src/plugin.ts:430`), and that service’s `register()` calls `resolveArtifactPackageOrder` unguarded as its first statement (`:448`). The lazy `collections` getter is **NOT** on that path: `init` spans `app-plugin.ts:319-395` and every `this.collections` read in the file is at `:668` or later, i.e. in `start()`. Falsifier: the same `init()` on the same malformed bundle, with `register()` replaced by a no-op, resolves clean. Lit control: a healthy stack through the real `register()` does not throw. **Both comment blocks are corrected in this PR**, and the file diff is no longer zero-deletion. ### Consequences for the ruling's framing - The ruling's hypothesised division was "structural refusal vs. missing-field degrade". The real division is **inverted and has no refusal in it**: the *missing-field* malformation is the one at `:505`, and the *structural* one is the one that lands in the child-`init()` loop. Both degrade. - So "the loud diagnostic applies to the degrade branch" applies to **both** branches — the same operational outcome arm 2 would have produced, reached from arm 1's premise. - The only refusal in the file is #5301's organizations rethrow, and it is not about malformed metadata at all (ADR-0093 D5, an INACTIVE organization wall). - §3b's i18n detector (landed by #15232) is already the **reference implementation** of the ruled diagnostic: it reaches the *same* ADR-0130 D4 refusal and names the metadata defect and its remedy, never a package. Recommended as the model for the cli seat's item 1. --- ## Item 3 — the posture text **Located page, declared before editing: `content/docs/plugins/packages.mdx`** (the `### @objectstack/plugin-dev` entry — the only place in `content/docs` that documents the plugin itself rather than mentioning it in passing). Checked against the pages open PRs currently hold (`content/docs/automation/flows.mdx`, `content/docs/references/api/automation-api.mdx`, `content/docs/references/api/package-api.mdx`, `content/docs/references/api/protocol.mdx`, `content/docs/references/automation/flow-function.mdx`, `content/docs/references/data/object.mdx`) — **no collision**. Note `plugins/packages.mdx` is a different file from the held `references/api/package-api.mdx`. The posture, stated as the `.mdx` and the changeset now state it and ⛔ not flat: **dev boot tolerates and reports; the doors that refuse are NOT uniform, and they differ by MALFORMATION.** | malformation | `os validate` | `os build` | `os package publish` | dev boot | |:--|:--|:--|:--|:--| | malformed `packages[]` | **exits 1** (fails `ObjectStackDefinitionSchema`) | **exits 1** — the SAME parse; `compile.ts` runs it too | — | tolerates + reports | | no `manifest` block + app payload | **advisory only**, exits 0 unless `--strict` | ⚠️ **silent** — `manifest.id` / `manifest.name` appear **0** times in `compile.ts` (LIT CONTROL: plain `manifest` = 7 hits, so the zero is a reading) | ⚠️ **substitutes — does NOT refuse.** `deriveManifestId` (`package/publish.ts:153`, used at `:454`) falls through `manifest.id` → `local.` + slug of `manifest.name` → `local.` + slug of the **artifact filename** (`source: 'artifact-filename'`, `:170-172`); `run()` then applies `explainManifestId` to the **derived** string (`:456`), so it refuses only a derived id that is itself invalid. With no `manifest` block the command mints a permanent, immutable identifier out of a filename and proceeds | tolerates + reports | ⭐ The `build` column is the round-3 correction and it was **not** charged by the at-tier review — the dev re-read that door on its own and found the `.mdx` bold sentence and the changeset headline flat in their own `build` half. ⚠️ **Arithmetic corrected from an earlier draft:** fixing only the three named carriers would not have produced a *third* posture — it would have left all five door carriers uniformly **flat on `build`**, which is this PR's own fail basis recreated on the other half of the same sentence. The substance stands; the count did not. ⛔ The `publish` claim is **dropped from the tree carriers rather than restated**: this card never measured that door as part of its deliverable, and an unmeasured claim is the thing this round exists to stop shipping. The cell above is in this body only, it is the at-tier reviewer's reading rather than the card's, and it says the door **substitutes** — ⛔ so nothing here should be read as a fourth door that refuses. ⚠️ **Acceptance note, ⛔ not this card's to fix.** The advisory half is already **ruled and closed**: #11896 was decided **B by the maintainer** (2026-08-25, verbatim 「同意」) — `os build` does **not** compute the four structural advisories, deliberately, with `build-json-advisory-parity.e2e.test.ts` pinning the gap so a fifth dropped list cannot hide in it. What that ruling does NOT reach is narrower, and it is now recorded on its own card: the boot-time identity refusal is a **bare `Error` with no ADR-0112 `code` / `status`**. Named here because this PR touches that file. ⚠️ **Correction to an earlier draft of this body:** #11896 is ⛔ **not** prior art for it. #11896 owns exactly one question — whether `os build --json` computes the four structural advisories — and its ruling says nothing about the boot-time refusal's **envelope shape**. Citing it as the reason to leave this unfiled was a non-sequitur, and this body's own preceding sentence concedes the gap is narrower than what #11896 covers. The envelope gap is filed as **#19617**, ⛔ without citing #11896. Plus the division above, and the half that is easy to lose — *tolerating is never hiding*: a boot that skipped something must never be byte-identical to a healthy one, because a silent degrade is exactly what lets an author or an AI read "it started" as "I wrote it correctly". --- ## Verification - Gate union derived from the real change set, not guessed: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → **90 families**, all run at head `e895dda8b9`, exit codes captured before any pipe: **90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN**. - ⚠️ **Honest detail, because the at-tier review recorded the previous round's figure as an unverified hole.** On the FIRST pass two of the 90 came back `exit 3 = PREREQUISITE NOT MET`, ⛔ not green — `check:skill-examples` and `check:dual-build-cjs-loads`, the latter naming 33 packages with no `dist/` (「Run pnpm build first. This is NOT a pass: nothing was measured」). A repo-wide `pnpm build` (73/73 tasks) then made both measurable, and exactly those two were re-run: skill-examples **exit 0** (258 prose examples type-check across 3 surfaces), dual-build-cjs-loads **exit 0** (104/67/620/1 against floors 90/58/520/1). ⛔ Without that build they would stand as NOT MEASURED. - `pnpm --filter @objectstack/plugin-dev test` → **8 files / 76 tests pass**; `typecheck` clean, `check:test-typecheck` 0 files / 0 errors. - `pnpm lint` repo-wide (`eslint . --no-inline-config`): **exit 0**, run whole, so no narrowing is claimed. - `pnpm lint` (repo-wide, `eslint . --no-inline-config`): **exit 0**, run whole, so no narrowing is claimed. - `pnpm --filter @objectstack/plugin-dev test` → **8 files / 76 tests pass** (was 74; +2 cases pinning the measured mechanism). `typecheck` → clean, and `check:test-typecheck` confirms the new test file compiles under `tsconfig.test.json` (0 errors). - ⚠️ **Seat note on this block's history**, kept because it records a real defect rather than tidying it away: an earlier draft of these bullets cited a re-run 「at `7db891a2dc`」, which is commit **2 of 4** on this branch (`5ae91a51` → `7db891a2` → `5f82990a` → `a3f52cfc` → `e895dda8`) and the head whose review was **voided on tier**. It contradicted the bullet beside it. Every figure in this block now reads at head `e895dda8b9` and nowhere else. - ⚠️ One axis is **not** covered locally: `check-changeset-no-major.mjs` prints `LEVEL AXIS: NOT APPLICABLE` outside a PR run — its green here is real on the *bump-level* axis (it read the changeset and found no `major`) and vacuous on the *clause-②* axis, which it reads from the PR body. That is why `Clause-②: no` is on this body. - Changeset owed and written, **measured rather than assumed**: the docblock 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). `@objectstack/plugin-dev: patch`. ## Acceptance notes Observed while reading, **not** filed and **not** fixed here — none is a reproducible defect, a contract violation, or a metadata-authoring trap: - `packages/plugins/plugin-dev/README.md` is what the docs page links to as the plugin's own reference, and it does not carry the posture. Out of the declared file surface for this claim; worth a follow-up by whoever takes item 1, since that PR is already editing this plugin's operator-facing text. - `AppPlugin`'s `securityMetadataRegistrar` guard is a third way the `:505` constructor can throw, but it is unreachable from `DevPlugin`, which passes one argument. Recorded so the next reader does not count it as a branch. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c16889 commit dc9e29b

5 files changed

Lines changed: 395 additions & 7 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-dev": patch
3+
---
4+
5+
`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).
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **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.
12+
- **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.
13+
- **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".
14+
- **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.
15+
- **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.

‎content/docs/plugins/packages.mdx‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,52 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
363363

364364
- **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)
365365
- **When to use**: Zero-config local development and playgrounds
366+
- **Malformed metadata**: Dev boot **tolerates and reports** — it never refuses, and it never stays quiet
366367
- **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md)
367368

369+
#### Malformed metadata: dev boot tolerates and reports
370+
371+
**`os dev` keeps booting on a stack the platform would reject; refusing belongs to
372+
the doors an artifact leaves your machine through — and those doors are not uniform
373+
about it.** `os validate` and `os build` both parse your stack against the same
374+
protocol schema and exit 1 when it fails; what that schema accepts but a deployment
375+
still needs comes back from `os validate` as an advisory instead, which `--strict`
376+
promotes to a failure. The dev server skips only the part it could not read, boots the
377+
rest, and prints an `error` line naming what was malformed and what was skipped — the
378+
shape every mainstream dev server takes, where the error overlay stays on screen while
379+
the server keeps serving.
380+
381+
This is deliberate, and it is one posture rather than two. Metadata that is incomplete
382+
halfway through an edit is the **normal** state of a project you are actively working
383+
on, so refusing to start would charge the cost to the only people this plugin exists
384+
for. The contract is still enforced — just at the doors where an artifact leaves your
385+
machine. See [Validating metadata](/docs/deployment/validating-metadata) for what
386+
those doors check.
387+
388+
Tolerating is not hiding. A boot that skipped something is never byte-identical to a
389+
healthy one: if the diagnostic were dropped, an author — or a coding agent — would
390+
read "it started" as "I wrote it correctly", which is exactly the outcome this posture
391+
exists to prevent. If you see one of these lines, the app is running **without** the
392+
metadata it names.
393+
394+
<Callout type="warn">
395+
Two different malformations reach this through two different branches, and they are
396+
exact complements — each is invisible to the other, so a clean boot past one is no
397+
evidence about the other. An app payload with no `manifest.id` / `manifest.name` is
398+
refused as the app metadata is constructed. A `packages[]` entry that is not a
399+
package entry (ADR-0130 D4) walks past that construction untouched and is refused
400+
one step later, when the app's own `init()` hands the stack to the kernel's
401+
`manifest` service and its package list is parsed; it reports as
402+
`INVALID_ARTIFACT_PACKAGE_ENTRY` (422) on the `error` line naming the app plugin.
403+
404+
The two differ at the production doors as well. The malformed `packages[]` fails
405+
the protocol schema, so both `os validate` and `os build` exit 1 on it. The missing
406+
`manifest.id` parses green for both: `os validate` reports it as the advisory
407+
*"Missing manifest.id — required for deployment"*, which exits 0 unless you pass
408+
`--strict`, and `os build` does not report it at all. So run `os validate --strict`
409+
if you want that half to fail too — a green `os build` is not evidence about it.
410+
</Callout>
411+
368412
### @objectstack/plugin-approvals
369413

370414
**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`.

‎packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,12 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', ()
365365
// (packages/spec/src/assembled-package-body.test.ts). That project boots
366366
// today; a reader that threw here would have stopped it booting — and from
367367
// the block whose only job is deciding whether to register a translation
368-
// service, while `new AppPlugin(...)` twenty lines above degrades the very
369-
// same refusal to a log line.
368+
// service, while the app-metadata branch degrades the very same refusal to
369+
// a log line — `AppPlugin.init()` hands the stack to the `manifest`
370+
// service, whose `register()` reaches the SAME `resolveArtifactPackageOrder`
371+
// parse, and DevPlugin's child-`init()` loop logs it instead of rethrowing.
372+
// ⛔ Not `new AppPlugin(...)`: the constructor reads `manifest.id` /
373+
// `manifest.name` only and never sees this malformation (#15292).
370374
const refused = additiveNoI18nProject();
371375
(refused.packages as Array<{ manifest: Record<string, unknown> }>)[0]
372376
.manifest.objects = ['./src/objects/*.object.ts'];

0 commit comments

Comments
 (0)