fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary - #14260

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire
Sep 1, 2026
Merged

fix(cli): read the upgrade advisory off manifest.engines.protocol, retiring specVersion from the stack config's CLI vocabulary#14260
os-justin merged 5 commits into
mainfrom
claude/issue-13860-specversion-retire

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13860

Retires specVersion from the stack config's CLI vocabulary and points the upgrade advisory at manifest.engines.protocol — the axis that is declared, parsed and enforced at boot. Implements the project-director's ruling (maintainer verbatim on the batch: 「同意」), quoted untranslated because rewriting a ruling is re-ruling it:

  1. B:checkSpecVersionGap 改读 manifest.engines.protocol(已声明、已解析、boot 期真强制的那条轴),specVersion 从 stack config 的 CLI 词汇中退役 —— 三个命令(validate / doctor / compile)同笔;
  2. advisory 的意图(「作者升级前该读的破坏性变更指引」)由 engines.protocol 落差继续兑现 —— 实施时钉一个「落差触发 advisory」的正例;

Option A is closed and untouched: zero edits to packages/spec (verified against the merge base, not asserted), and cloud/template-manifest.zod.ts keeps its own specVersion deliberately.

What moved

packages/cli/src/utils/spec-version.ts becomes protocol-version-gap.ts; checkSpecVersionGap becomes checkProtocolVersionGap. All three call sites move in the same stroke — validate.ts, doctor.ts, compile.ts — and doctor's printed rows now name engines.protocol instead of a key nobody can author. None of these symbols is on the package's export surface (exports publishes . = src/index.ts, which re-exports command classes only, plus ./console), so the rename is internal.

Clause 4 is the load-bearing half, and it is pinned with evidence

The advisory has been dead for stack configs its whole life: ManifestSchema declares no specVersion and is not .strict(), so an authored one was accepted and dropped, and the check could only ever fire for a manifest carrying a key the schema does not offer. Relocating the read without demonstrating that it now fires would not discharge this card, so the positive example is pinned through ManifestSchema.parse rather than on a hand-built literal — a literal would only prove the function works on input the platform never produces, which is the state being repaired:

  • engines: { protocol: '^16' } survives the parse and FIRES against installed 17.2.0, pointing at the v17 guide;
  • the shape every scaffold and example actually stamps, engines: { protocol: '^17' }, is silent against 17.2.0;
  • a specVersion written alongside does not survive the parse, and the retired key raises nothing.

No pin was deleted to get green. The old suite's checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0') pin was re-aimed into an assertion that the retired axis is now inert — a deleted pin and a passing one are not the same evidence.

Both directions were then proven non-vacuous by ablation. Each mutation was confirmed on disk before the run (hash moved, injected-text count 1, removed-text count 0) and each restore was proven by git diff HEAD empty plus a byte-identical blob hash, not by an exit code:

mutationresult
axis reverted to specVersion6/16 red — including both clause-4 pins
compatibility guard inverted (!== to ===)13/16 red — including every "is silent" pin

The renamed label owed a repo-wide sweep, and it had two consumers

Renaming doctor's row from Platform spec to Platform protocol broke a consumer that lived outside every suite this change touched — CI caught it, and the lesson is that a renamed user-visible string owes a grep for the old string across the whole tree, not a grep of the suites you expect to care. Swept with a positive control (Platform protocol, 2 hits) so the zero elsewhere is a reading rather than a broken probe. Two consumers found, two fixed:

  • doctor-config-env-overlay.test.ts pinned the old label as a liveness proxy — its own comment reads "The config checks did not merely stop failing — they RAN." Re-aimed at the new label, intent preserved: the row prints on either branch, so its presence still proves the check executed whatever it concluded. Not deleted, not weakened to something that would pass if the config checks never ran. A comment now records that these strings are row labels that must track doctor.ts through a rename.
  • configLoadFailureCheck's operator-visible sentence enumerated the skipped config-aware checks as "spec version" — naming a row that no longer exists under that name. Renamed in the same stroke and re-wrapped to the surrounding gutter width.

content/docs/** was checked for the same strings and has none, so no documentation describes a row that no longer exists.

Why the verdict is delegated, not re-implemented

The range is judged by checkProtocolCompat from @objectstack/metadata-core (added as a direct dependency; it was already in the install graph). That module is the platform's single reader of this axis and owns both the source priority (engines.protocol then engines.platform then legacy engine.objectstack) and the range grammar. Its own header records why resolveDeclaredRange was exported: two readers with two priority orders would be the "two opinions" defect. A private leading-integer parse would have been the third, and it would disagree exactly where it matters — >=15 <18 targets 15 but admits 17, so a naive reading advises an upgrade against a range that already covers the installed platform. Both that case and its excluding twin (>=15 <17) are pinned. The advisory names the key it actually read, so an author is never told to bump a key they did not write.

Comparing a protocol range against the @objectstack/spec resolved from the app's node_modules is sound because PROTOCOL_VERSION is held in lockstep with that package's major — protocol-version.test.ts fails on drift — so the two are the same integer and the docs/releases/vMAJOR link stays correct. That lockstep is what makes this relocation safe at all; the mismatch it prevents is what #9264 was about.

One narrowing guard is deliberate and is not a tolerant fallback: doctor hands over an unvalidated normalizeStackInput result, so engines.protocol can be any JSON value at that point, and resolveDeclaredRange calls .trim() on it. The manifest slice is narrowed to string | undefined before the handshake sees it, so the failure mode stays "no advisory" rather than a throw. Types only — the range itself is still interpreted exclusively by the handshake.

No command's rejection behaviour changes (clause ② stays no)

Verified in code rather than assumed, because a rejection change would be a clause-② flip:

  • validate.ts prints the advisory after the --strict exit and keeps it out of both faces' warnings list — it is explicitly never gated by --strict, unchanged;
  • doctor.ts's hasWarnings never exits non-zero; only hasErrors does;
  • compile.ts prints it after the artifact is already written.

Accept sets and exit codes are identical. What changes is that the advisory now fires, and os doctor will summarise such a run as "functional but has some warnings" rather than "healthy". The --json payload keeps the published key specVersionGap with its value shape unchanged: the axis behind it moved, but that is a machine face with pinned consumers in three e2e suites, and renaming it is a break nobody asked for and no ruling covers. That rename went to the decision box as #14261; A stands for this card. The three suites that pin the key are green.

Verification

Everything below ran at 9e3745deda, the final commit — the union was re-derived after the last edit and again after merging origin/main, with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on a non-stale tree, and reconciled against the earlier derivations with comm -23 / comm -13: no additions, no removals, across all three. Exit codes captured before any pipe; verdicts quoted from each gate's own line.

24 gates green:changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, docs-image-tag, dual-build-cjs-loads, engine-double-contract, i18n, i18n-coverage, logger-receiver-detach, objectql-double-limit, objectui-changeset, override-consistency, page-declaration-shape, pm-half-states, published-files, query-options-erasure, service-providers, slot-lookup, test-source-alias, turbo-task-graph, type-check-coverage, type-check-debt, type-source-resolution, where-matcher. The ratchet's own line: 27 ledger entries re-measured, none above its recorded number.

Gates that read built output (i18n, i18n-coverage, dual-build-cjs-loads, type-check-debt) first returned PREREQUISITE NOT MET — each states outright that nothing was measured. Those were read as NOT MEASURED, never as passes; their prerequisite closures were built (dual-build names its missing dists one round at a time, so it took three) and all four then ran green. check:test-source-alias was a genuine red earlier in the branch — the new test imported the handshake through exports to dist, which would have made it a verdict about build state, green against a stale artifact with nothing saying so. Fixed with an anchored resolve.alias entry, not by widening the shrink-only registry.

Tests:tsc --noEmit clean over @objectstack/cli, and --listFiles confirms both the new source and the new test file are inside the program (this package's tsconfig includes src, so the typecheck really does cover them — counted, not assumed). The whole doctor family plus the advisory unit suite: 12 files, 173 tests, all passing, including the exact test CI failed on. The three specVersionGap parity e2e suites are green too.

Repo-wide pnpm lint is left to CI; the local run is a declared narrowing, with all three pieces of evidence rather than a claim:

  1. the population comes from eslint's own config, not from a guess — fed every surviving changed path, it reports the non-source ones as File ignored because no matching configuration was supplied and lints the rest;
  2. the count comes from --format json: 7 files linted, 0 errors, 0 warnings;
  3. invariance for untouched files: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules) — stated and positive-control-measured in that config's own header — so this diff cannot move the verdict on a file it does not touch.

Findings

The residual hazard — an authored manifest.specVersion is still accepted and dropped in silence — is already covered by open #14192 ("ManifestSchema strips unknown keys silently"), which is the general form of it (a namespace typo inside manifest: parses green). No duplicate filed. Whether specVersion specifically warrants a retiredKey() tombstone is a rejection change and therefore not this card's to make.

os-justinand others added 3 commits September 1, 2026 15:03
…3860)
Retire `specVersion` from the stack config's CLI vocabulary. The three
commands that print the migration-guide advisory (validate / doctor /
compile) read `manifest.specVersion`, a key `ManifestSchema` does not
declare and silently drops, so the advisory has been dead for stack
configs its whole life. It now reads `manifest.engines.protocol` — the
declared, parsed and boot-enforced axis — and delegates the range verdict
to `checkProtocolCompat`, the platform's single reader of that axis,
rather than opening a second opinion on the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…13860)
check:test-source-alias flagged the new advisory test as importing the
protocol handshake through `exports` -> dist. The advisory is a thin
direction check over `checkProtocolCompat`, so without the alias the range
grammar it pins would be whatever was last compiled — green against a stale
artifact with nothing in the output saying so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 14 documentable anchor(s). ⚠️2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/cli/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 43 pages)
  • 6 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 — 23 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 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from b6527c22033bc751029e863081eee6abfd1c9948 — the merge of head 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd into base 035951faf99c42c258d470102da7346f309f7346, 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 b6527c22033bc751029e863081eee6abfd1c9948 && git checkout b6527c22033bc751029e863081eee6abfd1c9948
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 9e3745deda9c5b4bc3155be641cfa5b28d19b7dd
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

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

os-justinand others added 2 commits September 1, 2026 17:28
…#13860)
Renaming doctor's row from `Platform spec` to `Platform protocol` left two
consumers behind, and only a repo-wide grep for the old string found them —
the suites the rename touched were the wrong population to search.
- `doctor-config-env-overlay.test.ts` pinned the old label as a LIVENESS
proxy ("the config checks did not merely stop failing — they RAN"), not as
a label check. Re-aimed at the new label, intent preserved: the row prints
on either branch, so its presence still proves the check executed whatever
it concluded. A comment now records that these strings are row labels that
must track `doctor.ts` through a rename rather than be relaxed.
- `configLoadFailureCheck`'s operator-visible sentence enumerated the skipped
config-aware checks as "spec version", naming a row that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
@os-justin
os-justin marked this pull request as ready for review September 1, 2026 18:21
@os-justin
os-justin added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a0de28eSep 1, 2026
38 checks passed
@os-justin
os-justin deleted the claude/issue-13860-specversion-retire branch September 1, 2026 18:44
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-justin@claude