Skip to content

docs(plugin-dev): document the malformed-stack boot posture and pin its division - #19602

Merged
os-warren merged 5 commits into
mainfrom
claude/issue-15292-dev-plugin-degrade-posture
Sep 22, 2026
Merged

os-warren merged 5 commits into
mainfrom
claude/issue-15292-dev-plugin-degrade-posture

Conversation

@os-warren

@os-warren os-warren commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

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:

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


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 ⚠️ silentmanifest.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.idlocal. + slug of manifest.namelocal. + 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/objectstack90 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 test8 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 test8 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 (5ae91a517db891a25f82990aa3f52cfce895dda8) 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

…ts division

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-dev, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/services-checklist.mdx (via DevPlugin (symbol, a top-level class))
What this run could not see

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5c5b67fc4140f76ca3158acea9e0845d9eebfad8packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6f9f9f910f717bd8a5daa024f16f008941befd11 — the merge of head e895dda8b9747b052a54ea2a7fa1155599e45a43 into base 5c5b67fc4140f76ca3158acea9e0845d9eebfad8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f9f9f910f717bd8a5daa024f16f008941befd11 && git checkout 6f9f9f910f717bd8a5daa024f16f008941befd11
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5c5b67fc4140f76ca3158acea9e0845d9eebfad8 e895dda8b9747b052a54ea2a7fa1155599e45a43 && git checkout -B drift-repro 5c5b67fc4140f76ca3158acea9e0845d9eebfad8 && git merge --no-ff e895dda8b9747b052a54ea2a7fa1155599e45a43

node scripts/docs-audit/affected-docs.mjs --json 5c5b67fc4140f76ca3158acea9e0845d9eebfad8

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5c5b67fc4140f76ca3158acea9e0845d9eebfad8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…al 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 <noreply@anthropic.com>
Cosmetic only -- the previous commit left a short line mid-paragraph.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Ready, green, reviewed — and the last step is blocked with no channel

All three landing preconditions are met and recorded on the card:

  • ① at-tier contract review: PASS, with the served tier measured from the reviewer's transcript per assistant row, and the tier constant re-read from origin/main at 2026-09-21T18:06Z before the round was dispatched.
  • check-clause2-carriers --pair: EXIT=0, exit code captured before any pipe.
  • ③ CI by job conclusion, latest run per check NAME: 35 distinct names, 0 failure, 0 cancelled; every skip is on the EXPECTED_SKIPS roster. ⛔ No aggregate roll-up was read as the verdict.
  • Governance: ungoverned, 0 of 6 — predicate executed (GOVERNED_SURFACES + governedPathsIn imported from origin/main) rather than recalled, with a lit control (6/6) and a near-miss control (0/8). ⛔ .github/CODEOWNERS was not consulted; it is not a governed surface.

This PR has been flipped draft → ready, confirmed by GET /pulls/{n} returning draft: false — ⛔ not by the POST's status code.

auto_merge could NOT be enabled. The seat's session permission classifier refused the call, and there is no second channel: the MCP enable_pr_auto_merge tool is on this session's deny roster, and ⛔ working around a classifier refusal is not a channel. Recorded rather than retried, per the standing rule.

The seat will not merge this PR, enqueue it by hand, or submit an approving review on it. The only remaining act is enabling auto-merge with merge_method: SQUASH so the merge queue lands it — ⛔ never a direct merge, never a queue bypass.

Action needed from the maintainer or a seat with the channel: enable auto-merge (SQUASH). Everything else here is finished.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

✅ Correction — this PR is NOT blocked any more. It is in the merge queue.

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T00:3xZ.

The maintainer re-granted the permission and the call was retried. ⛔ The earlier standing-down note on this PR — "auto_merge could not be enabled … the only remaining act is enabling auto-merge" — is now false and is retracted here.

⚠️ And the seat's first read-back of that retry was WRONG. Recording it, because it is the more useful half.

PUT .../ccr/auto_merge returned 200 {"enabled":true,"merge_method":"squash"}. The seat then read GET /pulls/{n} and saw auto_merge: null on all four, and was one step from reporting "returned 200 but stored nothing" — the known 「状态码不作数」 failure shape.

That reading was the wrong instrument. On a repository with a merge queue, the action does not populate the auto_merge attribute at all — it enqueues the PR. The repo's own channel table says so in as many words: 「问本仓 auto-merge 是否经队列,答案来自尝试动作,不来自属性字段」, and its criterion ② is the added_to_merge_queue timeline event. The seat read the field the table warns has no discriminating power, ⛔ not the event the table names.

The evidence, on two independent instruments:

  1. Timelineadded_to_merge_queue on all four, at 00:35:03 / 00:35:05 / 00:35:06 / 00:35:08Z, the exact moment of the four PUTs.
  2. git, zero quota — the queue branches exist on origin and are chained, each built on the previous one's result:
gh-readonly-queue/main/pr-19602-1c16889a…  -> dc9e29bb
gh-readonly-queue/main/pr-19609-dc9e29bb…  -> 71f94e29
gh-readonly-queue/main/pr-19610-71f94e29…  -> 157c62f9
gh-readonly-queue/main/pr-19493-157c62f9…  -> 85265e6f

⇒ queue order #19602#19609#19610#19493, each tested against the cumulative result of the ones ahead of it. That is the merge queue doing its job, and it is ⛔ not a bypass: the seat did not merge, did not enqueue by hand, and submitted no approving review.

What happens next

Each PR merges as its queue branch goes green. ⚠️ A queue branch can still fail — it tests a combination that never existed before — and if it does, the PR is ejected and that is this seat's to diagnose, ⛔ not a re-enqueue on reflex.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-dev): DevPlugin degrades a malformed stack at the metadata door but the option-B readers want to refuse it — which posture is it?

2 participants