Skip to content

feat(spec)!: plugins / devPlugins are artifact envelope keys — excluded from the assembled package body, refused inside packages[] (#15219) - #15289

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-15219-plugins-envelope-keys
Sep 4, 2026
Merged

os-justin merged 4 commits into
mainfrom
claude/issue-15219-plugins-envelope-keys

Conversation

@os-justin

@os-justin os-justin commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15219

needs:contract-review (Clause ② yes — the published artifact format's accept set narrows: packages[i].manifest.plugins / .devPlugins go from accepted to refused). Draft; the domain:spec seat reviews at tier and flips ready. Head pinned in the measurements table: 749729185 (patch round on top of merge b6ada92c1 of origin/main; earlier heads d5ff0b3e6 / 703acb0af; worktree base 460134af). Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk

Patch round (seat ruling, option A on the report's open question 1)

The claim surface was extended by exactly two mechanical edits in packages/cli/test/: option-b-reader-acceptance.pin.test.ts:188 — the literal envelope list gains devPlugins and plugins (nine keys, in the file's own sort order); fixtures/option-b-collection-zoo.ts:26 and :67 — the prose "seven" artifact-envelope keys becomes "nine". Nothing else in either file moves. The cli pin is now 6/6 (was 1 failed / 5 passed).

Ruling executed (not re-decidable)

Maintainer ruling A for both keys (director relay 5537162356, 2026-09-04, verbatim 「同意」): plugins and devPlugins are artifact envelope keys — top level only, never inside packages[]; the emitter half never touches them. Operative sentences, quoted from 5537162356:

  1. "keep plugins / devPlugins at concat for in-memory composition (live stacks still concatenate their plugins to the top level) but exclude both from the assembled package body — an explicit envelope exclusion beside the existing packages skip in assembledPackageBodyShape(), with the collections-shape docblock's envelope list updated to name them and say why (runtime assembly instructions, not metadata)"
  2. "Pins: assembled-package-body.test.ts asserts the two keys are absent from the body key set and that a package body carrying plugins is refused (strict object); the AssembledPackageBodyKey derivation and the #15210 37-key list move to 35 — restate the count where it is printed"
  3. "Measure in the PR: readers of devPlugins on any artifact path (expect none; if one exists, stop and report — that is the fallback the maintainer was offered)"
  4. "Changeset minor with a BREAKING banner per the launch-window convention, stating the migration: an existing multi-package artifact that carries packages[i].manifest.plugins (if os build ever wrote one — not directly measured) is refused on load after this change and must be rebuilt"
  5. "Not this card's: serve.ts / schema-migration-plugins.ts (they stay top-level readers, now correct by construction); the #15004 probe row for plugins belongs to the emitter half; A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's Blocked-by: #15219 clears when this card's PR merges."

Options B (readers resolve JSON descriptions into live plugins) and C (emitter carve-out) are not in this diff: no resolver, no emitter special case, no CLI source edit.

What changed

packages/spec/src/stack.zod.ts

  • One constant, ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS = ['packages', 'plugins', 'devPlugins'], declared as const satisfies readonly StackDefinitionKey[], now feeds both halves: the AssembledPackageBodyKey type excludes its member union, and assembledPackageBodyShape() skips its members in the loop where the packages skip used to be a bare string compare. This is the "explicit envelope exclusion beside the existing packages skip" of ruling (1), written so the static and runtime halves cannot name different sets (the file's own derive-never-transcribe principle).
  • The body-key docblock's envelope list now names all three keys and says why per key (packages: ADR-0130 D1; plugins / devPlugins: runtime assembly instructions a host hands to kernel.use() / the os dev load list — inert JSON inside a body that no loader could construct). The collections-shape docblock gains the same sentence, since the two keys live in that shape (they compose by concat) while being envelope keys for the body.
  • plugins (:718) and devPlugins (:781) declarations unchanged; both disposition rows stay concat; assemblePackageBody() needed no edit — it iterates the derived shape, so manifest: 'preserve' stops folding the two keys into bodies by derivation.

packages/spec/src/assembled-package-body.test.tsARTIFACT_ENVELOPE_KEYS gains the two keys; a new #15219 block pins: both keys absent from the body key set while still on the stack schema; a body carrying plugins refused with unrecognized_keys at path [] keys ['plugins']; same for devPlugins; through ArtifactPackageSchema the refusal sits at path ['manifest'] (the artifact-packages.ts seam); and a two-stack manifest: 'preserve' composition keeps plugins / devPlugins concatenated at the top level in stack order, puts neither into any packages[i].manifest, and still parses through ObjectStackDefinitionSchema.

packages/spec/src/compose-stacks-manifest-preserve.test.ts / compose-stacks-key-loss.test.ts — read-and-report: neither enumerates the body key set (:273 names the packages disposition only; the key-loss structural pin walks the stack schema's shape, not the body's). Untouched, both green.

packages/spec/src/migrations/entries/semantic/18.assembled-package-body-plugins-envelope.ts + regenerated registry.ts (151 semantic after merging #15227's entry) — ADR-0087 D3 semantic entry. Not retired-keys: nothing is retired — both keys stay declared on the stack schema at the top level, and retired-keys ids are def:key tombstones for keys that left a schema. Not D2: no lossless target — plugins may hold live instances that never survived JSON, and hoisting a body-level entry to the envelope changes who loads it.

.changeset/assembled-package-body-plugins-envelope.md@objectstack/spec minor, BREAKING banner, FROM → TO block, the migration sentence of ruling (4) verbatim in substance, and the adr-0087: registered assembled-package-body-plugins-envelope disposition marker (an HTML comment, as the gate requires).

packages/cli/test/option-b-reader-acceptance.pin.test.ts, packages/cli/test/fixtures/option-b-collection-zoo.ts — the patch round above (the printed envelope count restated, per ruling 2).

Not touched: packages/cli/src/commands/serve.ts, packages/cli/src/utils/schema-migration-plugins.ts, packages/core/src/artifact-packages.ts.

Premise re-verified at the worktree base 460134af (and again at head)

stack.zod.ts:718 plugins: z.array(z.unknown()); :781 devPlugins: z.array(z.union([ManifestSchema, z.string()])); :930 / :933 both concat; :961-976 the body docblock; :977-980 the Exclude derivation over 'packages' only; :999-1006 assembledPackageBodyShape() with if (key === 'packages') continue;; assembled-package-body.test.ts:64-72 the seven-key ARTIFACT_ENVELOPE_KEYS, :125 the set-difference pin, :190-201 the unrecognized_keys pin. Behaviour at base, measured by the reverse run below (the mutated tree is the base behaviour): a wrapper carrying manifest.plugins parses green, the body accepts devPlugins, package-owned keys = 37, envelope = 7. Premise holds.

Ruling (3): readers of devPlugins on any artifact path — none

git grep -n devPlugins HEAD -- packages ':!packages/spec' (identical at 460134af, d5ff0b3e6, 749729185): 23 hits in 4 files — packages/cli/README.md:196 (the --dev flag text); packages/cli/src/commands/serve.ts:792, 2625-2627 (the live config under --dev: if (flags.dev && config.devPlugins) plugins = [...plugins, ...config.devPlugins] — top level of the in-memory config); packages/cli/src/utils/format.ts:492, 530, 1040-1131 (a stats counter over config.devPlugins, top level); packages/cli/test/print-metadata-stats-zero-row.test.ts (that counter's test, 10 hits). A second, broader grep for packages[…].devPlugins, manifest.devPlugins or artifact … devPlugins across all of packages/: 4 hits, all four being this PR's own migration-entry prose (18.assembled-package-body-plugins-envelope.ts and its regenerated copy in registry.ts); 0 outside packages/spec. No reader resolves devPlugins from packages[] or from an artifact, so the fallback offered to the maintainer is not triggered.

Ruling (2): the count — 35, printed here because no code or doc site prints it

No 37 appears in stack.zod.ts, the two spec test files, or the docs for this surface; the 37-key list lives in #15210's body. The one printed site of the complement — the cli pin's seven-key envelope list — is restated to nine by the patch round. Measured with a tsx script over src/stack.zod.ts that sorts Object.keys(schema.shape) for ObjectStackDefinitionSchema and AssembledPackageBodySchema and intersects them (OS_EAGER_SCHEMAS=1 pnpm --filter @objectstack/spec exec tsx ...; spec src is byte-identical between d5ff0b3e6 and 749729185, build input hash 2fa354834bab37c2):

stack keys: 44
body keys (manifest fields + collections): 55
package-owned collection keys (stack ∩ body): 35 => actions,agents,analyticsCubes,apis,apps,books,capabilities,connectors,dashboards,data,datasets,datasourceMapping,datasources,docs,emailTemplates,flows,functions,hooks,jobs,mappings,objectExtensions,objects,pages,permissions,positions,reports,requires,sharingRules,skills,tiers,tools,translations,viewItems,views,webhooks
envelope keys (stack − body): 9 => api,devPlugins,i18n,manifest,onEnable,packages,plugins,runtimeModule,server

The refusal text a body-level plugins now gets, through the wrapper (path manifest): "Unrecognized key(s) on this package manifest: plugins. Until this surface was closed, an unknown key inside manifest: parsed green and its value was silently dropped …" — the manifest's strict-close message, inherited through .extend().

Reverse verification (on the committed tree, restore proven by blob hash)

Mutation: the runtime exclusion only — the includes(key) line in assembledPackageBodyShape() replaced by the old if (key === 'packages') continue;, type untouched. On disk: injected marker count 1, removed line count 0, mutated blob b0cb9bcb… differs from HEAD blob f600508f…. The test file resolves ./stack.zod from src, so no dist is on this path and no rebuild is needed for either leg. Observed: 6 tests red — the #14242 set-difference pin (lacks exactly the artifact-envelope keys) plus all five #15219 pins; the count returned to 37 and the envelope to 7; both refusals flipped to success= true. Restore via git checkout HEAD -- on the absolute path, under an EXIT/INT/TERM trap: restored blob f600508fcd481af2d4b53e055eb063a12a7794d8 == HEAD:packages/spec/src/stack.zod.ts, git diff HEAD --stat empty, git status --porcelain empty, marker count 0. Direction observed: red (as expected), not a reversal. (stack.zod.ts is unchanged since; the same blob is at 749729185.)

Consumers (direction: DOWNSTREAM of @objectstack/spec; closure built)

Built: @objectstack/spec with declarations, then each consumer's own dependency closure through turbo — '@objectstack/metadata...' + '@objectstack/objectql...' (15 tasks) and '@objectstack/cli...' (56 tasks) — with OS_SKIP_DTS=1 (listed in turbo.json globalEnv, so the cache key differs from a full build). ⚠ Measured: --filter='!@objectstack/spec' does not stop turbo 2.10.10 from executing @objectstack/spec:build as a ^build dependency, so a closure run under OS_SKIP_DTS=1 replaces spec's dist with a declarations-less build; every dist-reading gate below was run after a fresh pnpm --filter @objectstack/spec build (34/34 declarations) that followed the closure runs. Tests reaching resolveArtifactPackageOrder / ArtifactPackageSchema (no test file in packages/core imports artifact-packages.ts by path; its readers are the three below):

consumer (downstream) test result
@objectstack/metadata src/plugin-artifact-packages-attribution.test.ts 7 passed / 7 (at d5ff0b3e6)
@objectstack/objectql src/artifact-load-path.test.ts 14 passed / 14 (at d5ff0b3e6)
@objectstack/cli test/option-b-reader-acceptance.pin.test.ts (+ its zoo importer fixtures/option-b-reader-probe.ts) 6 passed / 6 at 749729185 (1 failed / 5 passed at d5ff0b3e6, before the patch round)

The metadata and objectql rows were not re-run at 749729185: the patch-round diff touches only the two cli test files, and spec src is byte-identical between the two heads (build input hash 2fa354834bab37c2).

Mechanism assumptions — which held

  • (a) held: the door is ManifestSchema's strict close carried through .extend(); removing a key from assembledPackageBodyShape() is exactly what makes the body refuse it (under the mutation both refusals became accepts). Pins assert issue code + path + keys.
  • (b) half held: the Pick return type turns a derived key missing from the collections shape into a compile error, but it cannot see a runtime-only mismatch — the shape is cast to that type. What went red in reverse was the pin (6 tests), not typecheck (not re-measured in the mutated state; the cast hides it by construction). Closed structurally instead: both halves now read one constant.
  • (c) held: semantic D3 entry, registered accepted by check-adr-0087-registration ("new here"); gen:migration-registry + check:migration-registry green; feat(spec)!: ExecutionStepLog.iteration is single-valued (the enclosing loop iteration); the parallel branch index moves to a new optional branch key (#14414) #15227 merged and regenerated (the text merge was already the byte-identical union).
  • (d) held, nothing moved: check:generated --fix regenerated nothing before and after the merges (all 15 artifacts current, clean second run) — the body is structurally annotated and sits inside packages[], so no authorable-surface shard, docs shard, spec-changes.json or upgrade-guide line changed.
  • (e) half held, then closed by the patch round: the zoo derives and adapts; artifact-packages.ts needs no edit and refuses at manifest.plugins; the cli pin transcribed the seven keys and went red until the seat extended the claim.
  • (f) held: no body-key enumeration in either compose-stacks test; plugins concatenation intact (pinned).
  • (g) held: check:liveness and check:strictness-ledger green; no new z.object; both keys still on the stack schema.

Measurements at head 749729185 (exit captured before any pipe; verdict lines are the gates' own)

Rows marked † were measured at d5ff0b3e6; between the two heads the tree differs only by main's merge b6ada92c1 (no spec src change: same build input hash 2fa354834bab37c2) and the two cli test files, which none of the † gates read.

gate / test exit verdict line
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/option-b-reader-acceptance.pin.test.ts 0 Test Files 1 passed (1) · Tests 6 passed (6)
pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/assembled-package-body.test.ts src/compose-stacks-key-loss.test.ts src/compose-stacks-manifest-preserve.test.ts 0 Test Files 3 passed (3) · Tests 53 passed (53)
pnpm --filter @objectstack/spec build (with declarations) 0 check-dts-emitted: 34/34 · .build-input-hash 2fa354834bab37c2…
check:generated (after --fix + clean second run at d5ff0b3e6, nothing regenerated) 0 ✓ All 15 generated artifacts are up to date.
check:migration-registry 0 ✓ src/migrations/registry.ts is current (151 semantic, 86 retired-key, 94 retired-def)
node scripts/check-adr-0087-registration.mjs --base origin/main 0 ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition — registered assembled-package-body-plugins-envelope (new here)
node scripts/check-changeset-no-major.mjs --base origin/main 0 ✓ This diff introduces no major bump.
check:api-surface (inside check:generated) 0 @objectstack/spec public API surface + factory signatures unchanged ✓
check:browser-reachable-entries / check:dual-source-exports / check:entry-nameability / check:exported-any 0 ×4 ✅ 44 bundle(s) scanned … / ✅ no new dual-source exports: 5050 names across 17 entry points / ✅ 434 call probes across 17 public entries, 0 new unnameable structural mentions / ✅ no exported type resolves to any: 2457 types + 1524 schemas
pnpm check:nul-bytes 0 check-nul-bytes: OK (scanned 8285 text file(s) … no raw ASCII control bytes)
eslint --no-inline-config on the two cli files 0 clean
pnpm --filter @objectstack/spec typecheck 0 check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s) (ledger unchanged); 0 error TS
check:authorable-surface / check:docs / check:spec-changes / check:upgrade-guide 0 ✅ 1603 schemas / ✅ 230 generated files in sync / spec-changes.json is up to date. / protocol-upgrade-guide.md is up to date.
check:liveness / check:strictness-ledger 0 ✓ state-counts.md is current — the same 35 row(s) / ✓ 440 site(s) measured, 1 authorable strip site(s) left.
pnpm check:doc-authoring / node scripts/check-system-context-census.mjs 0 ✓ 831 pinned site(s) across 231 file(s), no growth / OK — 106 elevation read sites in 20 packages across 45 files, all anchored
pnpm lint (repo-wide eslint . --no-inline-config, full run, not narrowed) 0 84 s wall on the shared box
† derived gate set (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 5 paths vs merge base) 61 of 63 exit 0 root batch 44/46, spec batch 17/17
pnpm check:dual-build-cjs-loads 3 NOT MEASURED — PREREQUISITE NOT MET (reads built output of every package; ~85 without dist/) — CI's farm
pnpm check:type-check-debt 3 NOT MEASURED — PREREQUISITE NOT MET (32 workspace dependencies without built type entry points) — CI's farm
† consumers @objectstack/metadata / @objectstack/objectql (table above) 0 7/7 · 14/14

Lock discipline: every build/test ran through scripts/pm/os-verify-lock.sh (slot issue-15219); wall-clock figures are shared-box readings.

Related, not in this diff

🤖 Generated with Claude Code

https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk


Generated by Claude Code

…uded from the assembled package body

Maintainer ruling A for both keys (2026-09-04): the two keys are runtime
assembly instructions, not metadata. They stay `concat` for in-memory
composition and at the artifact top level; `assembledPackageBodyShape()` and
the `AssembledPackageBodyKey` derivation now exclude them through one
`ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS` constant beside `packages`, so a body
carrying either is refused at the manifest's strict close. Pins, an ADR-0087
semantic entry under protocol 18, and a BREAKING minor changeset ride along.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 5 documentable anchor(s).

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

  • content/docs/deployment/cli.mdx (via devPlugins (literal, a string literal in ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS))
  • content/docs/getting-started/quick-start.mdx (via devPlugins (literal, a string literal in ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS))
  • content/docs/plugins/index.mdx (via devPlugins (literal, a string literal in ASSEMBLED_PACKAGE_BODY_ENVELOPE_KEYS))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 128 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 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3347fdc06ce9c226c22d8392bb3b7c4c6a7d9763 — the merge of head 7497291853d1d01b26ad641b0588f672e2a68c0a into base 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafa, 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 3347fdc06ce9c226c22d8392bb3b7c4c6a7d9763 && git checkout 3347fdc06ce9c226c22d8392bb3b7c4c6a7d9763
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafa 7497291853d1d01b26ad641b0588f672e2a68c0a && git checkout -B drift-repro 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafa && git merge --no-ff 7497291853d1d01b26ad641b0588f672e2a68c0a

node scripts/docs-audit/affected-docs.mjs --json 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafa

⚠️ 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 1226f2e2ec1a46b26ab8dc0abe138f87cdfbfafa → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ceptance pin and zoo prose

Patch round on the seat's ruling: the literal envelope list at the pin's
shape assertion gains `devPlugins` and `plugins` (nine keys, in the file's
own sort order), and the zoo header/const prose that said "seven" says
"nine". Mechanical restatement of the count where it is printed; nothing
else in either file moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk

Copy link
Copy Markdown
Collaborator Author

Provenance (seat landing stroke, 2026-09-04T11:33Z): Clause ② PASS · ACCEPT recorded on #15219 (comment 5539806437) by the domain:spec seat at CONTRACT_REVIEW_TIER; needs:contract-review cleared on both carriers in that stroke (2026-08-31 ruling); all 34 check runs success on head 74972918; mergeable_state: clean; not a governed surface. Flipped ready and auto-merge (squash) armed by the seat — the merge queue lands it. packages/spec/src/migrations/registry.ts is a second in-flight writer's file (PR #15299): that PR merges main and regenerates after this one lands.


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

2 participants