Skip to content

fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key - #17067

Draft
os-bill wants to merge 5 commits into
mainfrom
claude/issue-16365-plugin-version-semver-grammar
Draft

fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key#17067
os-bill wants to merge 5 commits into
mainfrom
claude/issue-16365-plugin-version-semver-grammar

Conversation

@os-bill

@os-bill os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16365

PluginSchema.version was /^\d+\.\d+\.\d+$/ while its own describe() said "Semantic Version" with no qualifier — and SemVer 2.0.0 defines prerelease and build metadata as parts of a semantic version. The declaration refused part of what it declared. PluginLoader.isValidSemanticVersion, the check the boot path has always run, implemented the whole grammar.

Direction per the triage ruling on the card (comment 5579487348), quoted as it was written:

:177 那一行自相矛盾。 describe('Semantic Version') —— 无任何限定词 —— 而 SemVer 2.0.0 把 prerelease 与 build metadata 定义为语义版本的组成部分。⇒ 这个 key 的声明(describe)说的是全 SemVer,它的实现(regex)只收其中一个子集。

What changed

  • packages/spec/src/kernel/plugin.zod.tsversion takes the loader's grammar, character for character, not a third spelling. The two declarations now converge exactly.
  • packages/core/src/plugin-contract.ts — the stopgap version filter in assertPluginContract is gone; version is the ninth enforced key. The module comment's enumeration and its version section are re-settled.
  • packages/core/src/plugin-loader.ts — the docblock that explained the divergence now states the convergence, and that the structural checks stay the loader's own because they cover name/init, not because they judge version differently.
  • Pins in packages/spec/src/kernel/plugin.test.ts and re-settled groups E and G in packages/core/src/plugin-contract-enforcement.test.ts.
  • Changeset: .changeset/plugin-version-semver-grammar.md.

The one behaviour this narrows, stated plainly

On ObjectKernel nothing moves: validatePluginStructure already judged version with this exact grammar and still runs first, so a malformed version is still Invalid semantic version, never PLUGIN_CONTRACT_VIOLATION. On LiteKernel a plugin with a malformed version was registered before and is refused now. LiteKernel never ran the structural checks, so version was the one declared key it did not judge at all — green in vitest, refused by ObjectKernel at boot. That is the split the LiteKernel convergence closed for the other eight keys; this closes it for the ninth. Pinned as a named test.

Measurements, not assertions

Reverse verification (two ablations, both on the committed tree, both restored and re-proved).

  • A — revert the spec widening. Mutation proved on disk (wide 1 -> 0, narrow 0 -> 1), rebuilt, and proved to have reached dist via ablation-dist-preflight --absent (marker absent from all 218 built files). Result: spec plugin.test.ts 15 failed | 21 passed (from 36 passed) and core plugin-contract-enforcement.test.ts 7 failed | 29 passed (from 36 passed). Restore leg: rebuilt, preflight confirmed the marker present in 4 built files, suite back to 36 passed, whole-tree git status --porcelain empty and the source blob equal to its HEAD blob.
  • B — put the version filter back. Mutation proved on disk. Result: exactly 1 failed | 35 passed — only the new LiteKernel pin. That isolation is the point: the widening's own pins do not depend on the filter, and the filter's removal is what the new pin measures.

A PM mechanism assumption, falsified — reported because it is information. The expectation was that authorable-surface/kernel.json would move while api-surface/kernel.json would not. Neither moves, and pnpm --filter @objectstack/spec check:generated reports all 15 generated artifacts up to date, before and after merging main. Measured reason: authorable-surface/kernel.json is a ratchet of key nameskernel/Plugin:version is already on it — and records no grammar; api-surface records exports. The widening is real but lands in packages/spec/json-schema/, which is gitignored yet shipped (it is in the package's files[]), so no checked-in baseline moves. Direct reading of the published artifact:

kernel/Plugin.json  version.pattern
  before  ^\d+\.\d+\.\d+$
  after   ^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$

⚠️ Qualifier, measured, so the "invisible in baselines" reading is not overstated: the mechanical clause-② tell does fire on this diff — check-widening-tells --declaration no exits 4 with T1 plugin.zod.ts:201 — a new key on a Zod object schema. But it fires by diff shape, not by reading the grammar: version is not a new key, it is an edited line. The control settles it — feeding the gate a strict narrowing of that same line (/^[1-9]\d*\.\d+\.\d+$/) produces the identical T1 with the identical "the accept set gains a spelling" message. The gate's own header already states a tell is "never a proof", so this is it behaving as documented rather than a defect — but it does mean nothing mechanical in this repo reads the direction of a regex change. The baselines are blind to this whole class either way.

The loader's grammar is not SemVer 2.0.0 conformant — checked against the official regex over the spec's own example corpus, because it changes what "converge" means. It accepts every SemVer-valid string (no gaps), and additionally accepts 8 forms SemVer forbids: 01.1.1, 1.01.1, 1.1.01, 1.0.0-alpha..1, 1.0.0-alpha.., 1.0.0-0123, 1.0.0+., 1.0.0-.. ⭐ The decisive part: the spec's own pre-change regex already accepted the three leading-zero forms. So adopting the official SemVer regex would have narrowed this key — refusing 01.1.1, which it accepts today — which is the one thing the ruling forbids. Adopting the loader's grammar is therefore the only spelling that is a pure widening. The residual fringe is carried in a comment and in a test, and is filed separately as #17070 — out of scope here, and deliberately so: every repair for it narrows this key, which this card's ruling forbids. That card is unassigned and unlabelled, for triage.

Confidence-gap search — the one thing that would have overturned the ruling. Triage required stopping and reporting if an ADR or maintainer ruling makes plugin version deliberately narrower than SemVer. None exists. The search also turned up evidence in the opposite direction: packages/spec/src/kernel/plugin-versioning.zod.ts declares SemanticVersionSchema as "Standard SemVer format with optional pre-release and build metadata", with preRelease and build keys — the same domain, the same subject. And the control case: ManifestSchema.version keeps the narrow regex, but its prose says (major.minor.patch) explicitly and a test pins 1.0.0-beta invalid. When this repo means three-segment-only it says so and pins it; PluginSchema.version did neither.

Verification

Run under the shared verify lock; verdicts read from each run's own VERDICT line, exit codes landed to disk before reading.

Run Result
@objectstack/spec full suite 467 files / 13127 passed
@objectstack/spec typecheck pass
@objectstack/core full suite 49 files / 1213 passed
@objectstack/core typecheck pass
@objectstack/verify harness.host-resolution 7 passed — the class-based version = '0.0.0-fixture' plugin still boots a real kernel
pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at the final head 30ed01ef8 (and at fbe46e3e pre-merge); 6398 files linted, 0 findings
Derived gate families dispatch-gates --ran, re-derived on 30ed01ef8: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN

The card's evidence base was re-verified rather than trusted: both loader pins (plugin-loader.test.ts:91, :102) and both class-based fixtures (packages/cli/test/serve-organizations-host-resolution.e2e.test.ts:67, packages/verify/src/harness.host-resolution.test.ts:46) exist as described. Triage's line numbers were stale and are not reused; every number here is re-derived on this tree.

Declared to CI, not run here. pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both exit 3 = PREREQUISITE NOT MET in this container and say so themselves ("⛔ This is NOT a pass: nothing was measured") — both need a full 80-package build. Their --self-test halves pass. @objectstack/cli's suite is likewise NOT MEASURED: 52 files fail to collect on unbuilt-dependency module resolution (@objectstack/client, @objectstack/plugin-email, create-objectstack), none of it related to this diff, which touches no packages/cli file; the integration tier is CI's by the standing tier rule.

Clause-②: yes

Re-derived from this diff, not inherited: a published JSON Schema's accept set widens (pattern above), and LiteKernel's accept set narrows. No new error code, so packages/spec/src/api/error-code-ledger.zod.ts — contended by two open PRs — is untouched.

Patch round — contract review FAIL, repaired

Binding finding: the @objectstack/core paragraph of the changeset carried no BREAKING banner. The prose said NARROWS in as many words, but scripts/check-adr-0087-registration.mjs:572 detects breaking-ness with /\*\*BREAKING/i — so the changeset scored non-breaking and the ADR-0087 disposition marker was never judged at all. Measured before the repair: grep -c BREAKING on the changeset was 0, against 23 in packages/core/CHANGELOG.md on origin/main as the lit control.

Repaired by adding the banner in the phrasing the two 17.4.0 precedents for this exact class use, read from origin/main rather than paraphrased — "BREAKING accept-set narrowing on a published runtime entry point, shipped as minor under the repo's launch-window convention for breaking changes" — with the same not-required (no-migration-prescription) category. Neither package's level was raised (both stay minor) and the spec paragraph is untouched.

Now genuinely judged: check-adr-0087-registration --base 513c4955 --head 30ed01ef8 exit 0, listing this changeset as [BREAKING] not-required (no-migration-prescription) with its reason printed. The category was not reshaped to make it pass.

Also in this round: the version-less case in plugin-contract-enforcement.test.ts no longer counts eight keys — it now states the plugin loads because version is .optional(), which is the actual reason, and carries a comment saying so.

⚠️ Naming caveat this PR cannot fully repair. The first commit's subject says "the SemVer 2.0.0 grammar". The precise truth — which the body, the changeset, the code comment and the tests all state correctly — is that this adopts the loader's grammar, a strict superset of SemVer 2.0.0, wider than it in eight measured forms (#17070). The PR title is corrected; the commit subject stands, because changing it would need a force-push.

Re-verified on the final head 30ed01ef8 (merged origin/main once): @objectstack/core 49 files / 1213 passed, @objectstack/core typecheck pass, spec plugin.test.ts 36 passed, check:generated all 15 artifacts up to date, pnpm lint exit 0, and the 78-family gate set re-derived and re-run — identical family set, 76 green. The two that are not green are check:dual-build-cjs-loads and check:type-check-debt, both exit 3 = PREREQUISITE NOT MET in their own words, both needing a full 80-package build; their --self-test halves pass. (check:doc-formula-expressions and check:lean-entry-closure also exited 3 on first sweep for the same reason; their prerequisites were built and both then returned exit 0.)

Authored by Claude Code in session session_01MkQhmuuJAVDjmeWNixwDDH — recorded here in prose because the edit path appends its own footer.


Generated by Claude Code

os-bill and others added 3 commits September 9, 2026 04:11
…ts describe declares

`PluginSchema.version` was `/^\d+\.\d+\.\d+$/` while its `describe()` said
"Semantic Version" without qualification — and SemVer 2.0.0 defines prerelease
and build metadata as parts of a semantic version. The declaration refused part
of what it declared, and `PluginLoader.isValidSemanticVersion`, the check the
boot path has always run, accepted the whole grammar.

The spec adopts the loader's spelling character for character rather than a
third grammar, so the two declarations converge exactly. Measured: it is a
strict superset of the regex it replaces, so nothing that parsed stops parsing.

With the spellings converged, `assertPluginContract` drops the `version`
exclusion it carried as a stopgap; `version` becomes the ninth enforced key.
On `ObjectKernel` nothing moves (`validatePluginStructure` still runs first and
still owns that refusal); on `LiteKernel`, which never ran the structural
checks, a malformed `version` is refused for the first time — the last key on
which the two kernels disagreed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…nding

A release landed between writing the changeset and merging main: the two
enforcement entries that enumerate eight keys and declare the `version`
exclusion shipped in @objectstack/core@17.4.0 rather than sitting unreleased.
They describe what that release did and stay as written; the supersession is
now stated by version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 3 documentable anchor(s).

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

  • content/docs/getting-started/quick-reference.mdx (via PluginSchema (symbol, a top-level const))
  • content/docs/plugins/anatomy.mdx (via PluginSchema (symbol, a top-level const))
What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 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; 100 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 — 136 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 c43bac704819ecb4ae7ecc92b7b3fa2681befe6bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 62c40ddaab6e580413eb979de398ea7cf08a7f07 — the merge of head 30ed01ef85fb5d7624013bcec5f7c991d48d1992 into base c43bac704819ecb4ae7ecc92b7b3fa2681befe6b, 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 62c40ddaab6e580413eb979de398ea7cf08a7f07 && git checkout 62c40ddaab6e580413eb979de398ea7cf08a7f07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c43bac704819ecb4ae7ecc92b7b3fa2681befe6b 30ed01ef85fb5d7624013bcec5f7c991d48d1992 && git checkout -B drift-repro c43bac704819ecb4ae7ecc92b7b3fa2681befe6b && git merge --no-ff 30ed01ef85fb5d7624013bcec5f7c991d48d1992

node scripts/docs-audit/affected-docs.mjs --json c43bac704819ecb4ae7ecc92b7b3fa2681befe6b

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

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Director seat adoption record — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T06:59Z. The verdict below is adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (75 stamps, no other value). Head re-read at posting time = fbe46e3ef7, unchanged since the review. ⛔ This seat takes no release action on this carrier (no ready flip, no auto-merge, no enqueue, no label write): the owning seat (domain:spec (seat post #6017 — vacant at last reading; the PR's claim 5595425005 names the owning session)) adopts this verdict verbatim or discards it, and acts per the state machine.


Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #17067 @ fbe46e3ef7b048417da36da7e1e0236127ac55f7

Verdict: CHANGES REQUIRED

Read-only seat. Increment = git diff origin/main...refs/pr-review/17067 (merge-base 513c4955, 6 files, +254/−63). Nothing posted, nothing edited; no test suite run. Every number below was re-derived on the PR tree, not inherited from the card, the dispatch, or the dev report.

Ruling conformance

The triage ruling (card comment 5579487348) chose widen the spec on one reading — describe('Semantic Version') is the declaration and /^\d+\.\d+\.\d+$/ a defective implementation of it — and attached two obligations: remove the version exclusion from the contract check once the spec lands, and stop-and-report if any ADR or maintainer text makes plugin version deliberately narrower than SemVer.

Derived judgments

(a) The regex. Exercised with node against the adopted regex, the official SemVer 2.0.0 regex, and the old regex:

input PR regex SemVer 2.0.0 old /^\d+\.\d+\.\d+$/
1.0.0 accept accept accept
1.0.0-alpha.1 accept accept refuse
1.0.0+build.5 accept accept refuse
1.0.0-rc.1+build accept accept refuse
01.0.0 accept refuse accept
1.0 refuse refuse refuse
v1.0.0 refuse refuse refuse
1.0.0- refuse refuse refuse
1.0.0-01 accept refuse refuse
1.0.0-alpha..1 accept refuse refuse
^1.0.0, >=1.0.0, 1.x, 1.0.0+ refuse refuse refuse

No v prefix, no ranges, no whitespace. It is a strict superset of the old regex (the test at plugin.test.ts:149-157 asserts the containment). It is not SemVer 2.0.0 and nothing wider: it over-accepts leading zeroes in the numeric core (pre-existing at this key) and degenerate prerelease/build identifiers (1.0.0-01, 1.0.0-alpha..1, 1.0.0+.) — new at the spec key, pre-existing at the boot path. I checked the dev's claim that no SemVer-exact spelling is a pure widening: the official regex refuses 01.0.0, which PluginSchema accepts today (narrows the spec door); a hybrid (\d+ core + SemVer-exact suffixes) would make assertPluginContract refuse 1.0.0-alpha..1 after validatePluginStructure accepted it, so a plugin that boots on ObjectKernel today would stop booting (narrows the boot door). Under "nothing that loads today stops loading", the loader's grammar is the unique answer. The fringe is declared in a source comment (plugin.zod.ts:177-200), pinned (plugin.test.ts:159-170), and filed as #17070 (verified: open, unlabelled, unassigned).

(b) Doors and comparators. PluginSchema has exactly one non-test caller on this tree: packages/core/src/plugin-contract.ts:162. Doors that widen: (1) PluginSchema.safeParse as a published spec export; (2) assertPluginContractLiteKernel.use() (lite-kernel.ts:73) and ObjectKernel.use() via PluginLoader.loadPlugin (plugin-loader.ts:175 structure → :178 contract → :181 compat); (3) the shipped JSON Schema packages/spec/json-schema/kernel/Plugin.json version.pattern — root .gitignore:63 ignores the tree and packages/spec/package.json files[] ships it, so the pattern change is real, shipped, and invisible in-tree; I re-derived it from the source regex, not from a build. Not doors: os validate parses ObjectStackDefinitionSchema, whose plugins is z.array(z.unknown()) (packages/spec/src/stack.zod.ts:724); os plugin build / os package publish run ManifestSchema (plugin/build.ts:28,111), whose version keeps the narrow regex (manifest.zod.ts:314) — unchanged, out of scope; authorable-surface/kernel.json:435 records only the name kernel/Plugin:version, so no baseline moves.

Comparators: the only version comparator in packages/core|cli|runtime is SemanticVersionManager (packages/core/src/dependency-resolver.ts): parse :20-40 admits the same grammar plus a v prefix; compare :57-72 orders prerelease by localeCompare (:68), which is not SemVer §11 precedence (alpha.10 sorts below alpha.2; numeric-vs-alphanumeric rule absent); findBestVersion :354-357 sorts with it. It is only export * from './dependency-resolver.js' (core/src/index.ts:112) — no boot-path consumer in core, runtime or cli. checkVersionCompatibility (plugin-loader.ts:452-467) only re-runs validity; plugin-registration.ts:56-57,97-98 only renders versions in a supersede warning; cli hits (doctor.ts:1838, migrate/meta.ts:334-360) are Node and protocol versions. No consumer compares or sorts plugin version on any boot path, so prerelease ordering is not exercised by this widening. The localeCompare defect is pre-existing and unwired — boundary flag, not this PR's.

(c) @objectstack/core. One code change: plugin-contract.ts drops the version filter and takes result.error.issues[0] (:167). Reason: with the spellings converged the filter had nothing to filter. Effect on ObjectKernel: none observable — validatePluginStructure runs first and still refuses v1.0.0 as Invalid semantic version (pinned, group E). Effect on LiteKernel: a malformed version was registered before and is refused now with PLUGIN_CONTRACT_VIOLATION at 'version' (pinned, group G new test). One second-order effect not stated anywhere: on LiteKernel a plugin with a malformed version and another bad key is now reported at 'version' where before it was reported at the next key — message text only; not a finding. plugin-loader.ts:420-447 is docblock only.

(d) Exports / keys / codes / ledgers. No new exports (grep of +export in the diff: none). No new error code; PLUGIN_CONTRACT_VIOLATION is reused for a new key, and its ledger row (error-code-ledger.zod.ts:863-870) describes it generically with no key enumeration, so nothing there went stale; error-code-ledger.zod.ts untouched (contended by #17015/#16783 — correctly avoided). ADR-0087: the changeset carries <!-- adr-0087: not-required (no-migration-prescription) ... --> — but see F1 for why the gate never evaluates it. Liveness ledger: packages/spec/liveness/ has no kernel-plugin type; nothing owed. Fixture claims verified: plugin-loader.test.ts:91,:102; packages/cli/test/serve-organizations-host-resolution.e2e.test.ts:67; packages/verify/src/harness.host-resolution.test.ts:46. No in-repo non-SemVer plugin version literal in examples/templates/docs that the LiteKernel narrowing would newly refuse.

Semver / changeset

  • Clause-②: yes — mandatory for a published accept-set widening; the card claim (comment 5595425005) carries Clause-②: yes in the exact CLAUSE2_KEY_LINE shape (scripts/pm/check-clause2-carriers.mjs:521, tested). Carriers: needs:contract-review present on both the card and the PR (read from the label lists). The PR body's ## Clause-②: yes heading does not match CLAUSE2_KEY_LINE (no # prefix admitted) — only CLAUSE2_NEAR_MISS_LINE (:529); the designated declaration carrier is the card claim (:593-601), and the level-axis gate reads the label and/or the line, so no gate is affected (F4).
  • Levels: @objectstack/spec: minor, @objectstack/core: minor — not patch. Level axis (check-changeset-no-major.mjs:740-830): clause-② yes ⇒ at least one src/**-moved package graded minor+ — both are. Spec: additive widening ⇒ at least minor per the 2026-09-04 maintainer ruling — correct.
  • Core grades minor correctly under the launch window, but the changeset omits the window's mandatory breaking-ness carrier. The same script's header: "The mandatory information carriers for breaking-ness in the meantime are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition ... during the window they are the only signal there is." check-adr-0087-registration.mjs breakingDeclaration() (:569-572) recognises only a major bump, a **BREAKING marker, or a ! summary — this changeset has none, so it is classified non-breaking (G1) and its ADR-0087 marker is never judged as a breaking disposition. Precedent in the same file: both 17.4.0 entries for this exact class — kernel.use() enforcement (packages/core/CHANGELOG.md:75) and LiteKernel.use() enforcement (:112) — open with **BREAKING** accept-set narrowing on a published runtime entry point, shipped as minor under the repo's launch-window convention. This changeset says "NARROWS LiteKernel" (line 21) and carries a Migration paragraph, but no banner. That is F1.
  • Governed paths in the diff: none (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** all untouched). CHANGELOG.md deliberately not rewritten — correct per Documentation Guardrails.

Boundary flags

  • Baseline-based review is blind to this class (director asked for a plain statement). Verified: authorable-surface is a name ratchet (kernel.json:435), api-surface records exports, and the artifact that actually widens (json-schema/kernel/Plugin.json) is gitignored yet shipped. A +0/−0 on every checked-in baseline is therefore consistent with a published-contract widening. For any .regex() / .min() / .enum change on a .zod.ts key, the reviewable evidence is the source diff and the rebuilt JSON Schema, not the baselines — a reviewer reading baselines alone would have passed this as "no contract change". Same family as [finding] @objectstack/spec pins 5309 exports by name-and-kind but only 27 by signature — 99.5% of the ratified surface passes green through a shape change #16045.
  • Three sibling spec keys keep /^\d+\.\d+\.\d+$/ under SemVer-flavoured prose and now disagree with PluginSchema.version: plugin-validator.zod.ts:144 (PluginMetadataSchema.version, describe 'Semantic version (e.g., 1.0.0)', and :126 says it "aligns with and extends the existing PluginSchema" — on this key it no longer does), plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649. No runtime consumer in core/runtime/cli. Same defect class as this card; not covered by [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070. A card is owed (F6).
  • The dev's "control case" claim that ManifestSchema.version's declaration, implementation and pin all agree is inaccurate: manifest.zod.ts:312 carries @example "2.1.0-beta.1", which :314's regex refuses. The prose qualifier at :309 and the pin at manifest.test.ts:29 still stand, so the ruling here is unaffected, but [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070 cites it as the precedent for the "qualify the prose" option and should know the example contradicts (F5).
  • SemanticVersionManager.compare prerelease ordering (dependency-resolver.ts:68) is not SemVer precedence. Pre-existing, unwired; if a future card wires DependencyResolver into a boot path, prerelease inputs — which the loader has always admitted — will sort wrongly. Not this PR's.

Findings

  • F1 — blocking. .changeset/plugin-version-semver-grammar.md:21 declares an accept-set narrowing on a published runtime entry point (LiteKernel.use()) without the **BREAKING** banner that scripts/check-changeset-no-major.mjs (header, "CHOOSING BETWEEN THE TWO LEVELS") names as the window's mandatory carrier and that check-adr-0087-registration.mjs:569-572 keys on. Both 17.4.0 entries for this class (packages/core/CHANGELOG.md:75, :112) carry it. Fix: prefix the @objectstack/core paragraph with **BREAKING** accept-set narrowing on a published runtime entry point (\LiteKernel.use()`), shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`).and re-run the ADR-0087 gate so the existingnot-required` marker is actually judged.
  • F2 — non-blocking. packages/core/src/plugin-contract-enforcement.test.ts:524 — test name 'a version-less plugin loads — \version` is not among the eight keys'is now false:versionis among the nine; the plugin loads because the key is.optional()`. Rename.
  • F3 — non-blocking. Changeset line 41 and plugin.zod.ts:192-193 say this change "neither introduced nor widened that fringe". True for leading zeroes; not true for the @objectstack/spec accept set on 1.0.0-0123, 1.0.0-alpha..1, 1.0.0+. — the old regex admitted no suffix at all, so those are newly accepted by the spec key. What is true is that the boot path already accepted them. Reword to "not new to the boot path".
  • F4 — non-blocking. PR body line ## Clause-②: yes is a near-miss for CLAUSE2_KEY_LINE (check-clause2-carriers.mjs:521). No gate depends on the body line (the card claim is the carrier, and the label is present), but the machine-readable form is a bare Clause-②: yes line.
  • F5 — non-blocking (dev report accuracy). See boundary flag on manifest.zod.ts:312.
  • F6 — non-blocking (out of scope, card owed). See boundary flag on plugin-validator.zod.ts:144, plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649.

Dev report open_questions: none listed. deviations, each answered: (1) baseline falsification — verified and agreed; consequence stated above. (2) Stopgap location — verified at plugin-contract.ts:157 on origin/main; the correction is right. (3) 17.4.0 release mid-task — verified; superseding by version and leaving CHANGELOG.md untouched is the correct discipline. (4) Rejecting the official regex — re-derived (table above); the loader's grammar is the unique pure widening. (5) Lock timeouts — process only; no effect on the increment. (6) Footer — PR body carries exactly one _Generated by_ footer; first line Fixes #16365. (7) #17070 not numbered in source comments — acceptable; the card exists and the prose is accurate.

CI at read time

Head fbe46e3ef7b048417da36da7e1e0236127ac55f7: 50 check runs, 34 latest-per-name — 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, 0 pending. Check Changeset, Spec property liveness, Governed Surface Queue Guard, Type Check · debt ledger, all six Test Core shards green. mergeable_state: clean; PR is a draft with needs:contract-review. Note that Check Changeset green is consistent with F1: a changeset with no breaking declaration is "not the ADR-0087 gate's business", which is exactly why the missing banner is invisible to CI.

Implemented-by: branch claude/issue-16365-plugin-version-semver-grammar
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":17067,"head":"fbe46e3ef7b048417da36da7e1e0236127ac55f7","verdict":"CHANGES REQUIRED","blocking":["F1: changeset declares a LiteKernel.use() accept-set narrowing without the BREAKING banner the launch-window convention and check-adr-0087-registration.mjs key on (.changeset/plugin-version-semver-grammar.md:21)"],"clause2":"yes","semver_ok":false,"governed":false,"ci":"34 latest-per-name: 29 success, 5 skipped, 0 failed, 0 pending"}


Generated by Claude Code

os-bill and others added 2 commits September 9, 2026 07:21
…the gate reads

check-adr-0087-registration detects breaking-ness by matching /\*\*BREAKING/,
so a changeset that says "NARROWS" only in prose is scored non-breaking and its
ADR-0087 disposition marker is never judged at all. The core paragraph now
carries the banner in the phrasing the two 17.4.0 precedents for this exact
class use; both package levels stay minor and the spec paragraph is untouched.

Also corrects a test title that still counted eight keys: a version-less plugin
loads because `version` is `.optional()`, not because it is unenforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@os-bill os-bill changed the title fix(spec, core): give PluginSchema.version the SemVer 2.0.0 grammar its describe declares fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key Sep 9, 2026
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] PluginSchema.version refuses the prerelease and build-metadata forms SemVer defines, while the loader that actually runs accepts them

2 participants