Skip to content

fix(rest): say what openapi.json info.version carries, and drop the fallback that contradicted it - #11641

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11546-openapi-info-version
Aug 24, 2026
Merged

fix(rest): say what openapi.json info.version carries, and drop the fallback that contradicted it#11641
os-zhuang merged 2 commits into
mainfrom
claude/issue-11546-openapi-info-version

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11546

The fork this card was split out to resolve

Triage routed this as inheriting #11292's ruling "with its reason" — serve the derived
artifact version, because an API-version identifier was standing in for artifact identity.
The card's own filer disagreed in advance: OpenAPI's info.version may legitimately be an
API version. Measured, the filer is right and the inheritance does not transfer.

OpenAPI 3.1.0, Info Object, verbatim:

versionREQUIRED. The version of the OpenAPI document (which is distinct from
the OpenAPI Specification version or the API implementation version).

The runtime version is the API implementation version — the one value the field's own
normative definition excludes. resolveDiscoveryVersion() returns the OS_RUNTIME_VERSION
build stamp, else the serving package's version: implementation identity, exactly. So
option (1) is ruled out by the specification text, not by preference.

The contrast with #11292 is real rather than cosmetic: DiscoverySchema.version sits under
System Identity — the "what server is this" question, settled by #10993 — so serving the
artifact version there was correct. info.version asks a different question, and the answer
does not carry over one artifact.

Consumer survey (the issue recorded this as not measured): no consumer reads this
document's info.version by value — not in objectstack, objectui, cloud, or
cloud-v1. The one v{info.version} render in cloud-v1
(apps/cloud/components/api-docs/api-docs-viewer.tsx) reads cloud's own
generateOpenAPISpec() document, not this one. So no consumer pull exists in either
direction, and the served value is left where it is.

What was measured on the line itself

Driving the real GET {base}/openapi.json handler on origin/main @ 50fb191d, per
configuration:

api.versionmountserved info.versionartifact's own
'v1' (default)/api/v1"v1""17.2.0"
omitted/api/v1"v1""17.2.0"
'v9'/api/v9"v9""17.2.0"
'v1' + apiPath: '/backend/api/v9'/backend/api/v9"v1""17.2.0"
'v1', OS_RUNTIME_VERSION stamped/api/v1"v1" (stamp absent)"17.2.0"
''/api/"17.2.0""17.2.0"

Both halves of the old comment were false. The runtime version never reached the field, and
the last row is the sharp one: the || enriched.info.version fallback was reachable, not
dead
, and firing it published the spec package's compile-time version — precisely the value
the comment said the line existed to keep off the wire.

It is reachable for the opposite reason to the one first recorded here — corrected in
5728f071 after review, because the first account named a schema that does not exist
(PluginRestApiSchema; the real export at plugin-rest-api.zod.ts:625 is
RestApiPluginConfigSchema, and it governs nothing on this path). The contract does not
permit an empty version. RestApiConfigSchema (packages/spec/src/api/rest-server.zod.ts:48)
declares

version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1'),

and the + quantifier refuses ''. That is the governing schema:
RestApiPluginConfig.api is typed RestServerConfig, RestServerConfigSchema.api is
RestApiConfigSchema.optional(), and RestApiConfig = z.input<typeof RestApiConfigSchema> is
what normalizeConfig casts to.

'' arrives anyway because nothing parses this config against that contract. Both hops in
are casts — config.api as any (rest-api-plugin.ts:388) and as Partial<RestApiConfig>
(rest-server.ts:2916) — the REST plugin declares no configSchema for the kernel's
plugin-config validator to run, and the repo's only RestApiConfigSchema.parse call parses
{} in a QA helper (packages/core/src/qa/http-adapter.ts:43). So the regex never executes on
a deployment path, normalizeConfig's ?? is the only guard left, and it does not catch the
empty string.

A declared constraint the server never applies is a defect in its own right — filed as #11637,
not fixed here. The fallback removal stands unchanged on either account of why it could fire.

The change

packages/rest/src/rest-server.ts — the openapi info construction only:

  • The comment now states what the field carries (the declared API version identifier), why it
    is deliberately not the runtime version (quoting the OpenAPI definition), and where the
    runtime version is served instead ({basePath}/discovery, /health).
  • || enriched.info.version removed. A falsy api.version now serves itself, so a
    misconfigured deployment reads as misconfigured rather than silently switching this field to
    a different kind of fact.

Behaviour delta — declared honestly

Every non-empty api.version, including the default, serves exactly what it served before.
The only value that moves is the degenerate api.version: '' case: served info.version goes
"17.2.0""". That configuration already mounts the entire API at /api/ with a doubled
slash (/api//openapi.json), i.e. it is a broken deployment either way — filed separately as
#11637, which this PR does not address and does not depend on.

Clause-②: no. No working configuration's served value changes, so this is not a contract
move; needs:contract-review is not applied. The narrow empty-string delta above is flagged
here rather than buried, so the call can be revisited if this seat reads it differently.

Pins and reverse verification

Four pins in the route's test twin (packages/rest/src/rest-openapi-route.test.ts) fix what
the field means. Reverse verification reverted onlyrest-server.ts to origin/main,
carried trap … EXIT INT TERM, and proved the mutation on disk by grepping both texts
(pre-fix fallback 1 / post-fix line 0 / old comment 1 / new comment 0; restore leg: 0 / 1 / 0 / 1).

No rebuild between legs, and the reason is load-bearing rather than an omission: the code under
test is imported relatively (import { RestServer } from './rest-server'), so vitest
transforms the source directly — no dist/ resolution stands between the mutation and the
assertion. The artifact half (@objectstack/spec's json-schema/openapi.json, read from disk
by loadOpenApiSpec()) is untouched by both legs.

Predicted before running: pins 1–3 green pre-change, pin 4 red. Observed exactly that
Tests 1 failed | 16 passed (17), the single failure being
AssertionError: expected '17.2.0' to be '', which is the fallback publishing the compile-time
version. Pins 1–3 are characterization rather than anti-vacuity, and that is stated plainly:
a comment correction has no behavioural anti-vacuity by construction. Their job is to go red if
the field is ever re-pointed at the runtime version — the direction this card did not take.

Verification — re-run at 5728f071

The correction commit is comments and changeset prose only, but the union was re-run on the
new head rather than carried over from c4f3d2fc: suite Test Files 141 passed (141) /
Tests 2243 passed (2243), typecheck clean, check:nul-bytes, check:query-options-erasure,
check:engine-double-contract, check:where-matcher, check:plugin-teardown-shape,
check:cross-package-test-inputs, check-changeset-no-major, check-empty-changeset and
check-adr-0087-registration all EXIT=0, and the check:type-check-debt ratchet re-measured
green after rebuilding the closure (Tasks: 70 successful, 70 total):
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 242.1s, 1897 raw tsc error(s) total, none above its recorded number.

  • pnpm --filter @objectstack/rest testTest Files 141 passed (141), Tests 2243 passed (2243)
  • pnpm --filter @objectstack/rest typechecktsc --noEmit, clean
  • Gate set derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
    (change set: the 3 files below, committed 3 / working tree 0 / untracked 0) — all 18
    path-matched families plus the convention-triggered ones run green, including
    check:type-check-debt after building the closure it requires
    (turbo run build --filter='./packages/*' --filter='./packages/*/*' → 70/70 successful),
    since @objectstack/rest sits in TEST_DEBT at a count recorded exactly.

Files

  • packages/rest/src/rest-server.ts — the openapi info construction
  • packages/rest/src/rest-openapi-route.test.ts — the four pins
  • .changeset/openapi-info-version-is-the-api-version.md

Neighbours in this package are untouched: #11588 and #10345 / PR #11590 are in other faces of
packages/rest and neither touches this line.


Generated by Claude Code


Generated by Claude Code

… fallback that contradicted it
`GET {basePath}/openapi.json` built `info.version` as
`this.config.api.version || enriched.info.version` under a comment promising
"the runtime version so consumers don't pin to the spec package's compile-time
version". Both halves were false.
The served value has always been `api.version` — the API version identifier
`normalizeConfig()` defaults to 'v1' and `getApiBasePath()` uses to build the
mount. The runtime version never reached the field.
OpenAPI 3.1 defines `info.version` as "the version of the OpenAPI document
(which is distinct from the OpenAPI Specification version or the API
implementation version)", so the runtime version is the one reading the field's
own definition excludes. This is why the field does NOT inherit the ruling that
settled `DiscoverySchema.version`, where "System Identity" means the serving
artifact. The comment is corrected to say what the value is and where the
runtime version is served instead (`/discovery`, `/health`).
The `|| enriched.info.version` fallback is removed. It was reachable rather
than dead: `normalizeConfig` defaults with `??` and `PluginRestApiSchema`
declares a bare `z.string()`, so a configured `api.version: ''` arrives falsy —
measured, that published `@objectstack/spec`'s compile-time version, the exact
value the old comment said the line existed to keep off the wire. A falsy
`api.version` now serves itself.
Four pins in the route's test twin fix the meaning: the declared identifier is
served and differs from the artifact's own version, a custom `api.version` is
tracked, an `OS_RUNTIME_VERSION` stamp does not reach the field, and an empty
`api.version` no longer falls back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

github-actionsBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1 (route, 81 pages)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 13 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 a5110f5235e857116e285deef7f2a2d9aebfae41packageMentionDocs.

Which tree this was computed on

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

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

… fallback
The comments shipped in the previous commit named `PluginRestApiSchema`, which
does not exist in this repo, and said it declares a bare `z.string()` that
permits an empty `api.version`. Both halves are wrong, and on a card about a
comment falsified by the line it introduces they are exactly the wrong thing to
ship.
Measured on origin/main:
- The export at packages/spec/src/api/plugin-rest-api.zod.ts:625 is
`RestApiPluginConfigSchema` (the file name transposed into the symbol). It is
referenced nowhere outside its own file, its own test and the api-surface
manifest, and it is not the type of anything on this path.
- The schema that governs this field is `RestApiConfigSchema`
(packages/spec/src/api/rest-server.zod.ts:48). `RestApiPluginConfig.api` is
typed `RestServerConfig`, `RestServerConfigSchema.api` is
`RestApiConfigSchema.optional()`, and `RestApiConfig = z.input<typeof
RestApiConfigSchema>` is what `normalizeConfig` casts to.
- That schema does NOT permit an empty version. It declares
`version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/).default('v1')`, and the `+`
quantifier refuses `''`.
So the fallback's reachability has the opposite cause from the one recorded: the
contract forbids `''`, and it arrives anyway because nothing parses the config
against the contract. Both hops in are casts (`config.api as any` in
rest-api-plugin.ts:388, `as Partial<RestApiConfig>` in rest-server.ts), the REST
plugin declares no `configSchema` for the kernel's plugin-config validator, and
the repo's only `RestApiConfigSchema.parse` call parses `{}` in a QA helper. The
regex never executes on a deployment path, so `??` is the only guard and it does
not catch the empty string.
Comments and changeset prose only — no behaviour change, and the removal of the
fallback stands unchanged on either account of why it could fire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Option A. You are right, my retraction was wrong, and I have re-measured your chain rather than flipping again on assertion

All three links verified at origin/maincad8b42f:

linkmeasured
packages/rest/src/rest-api-plugin.ts:77api?: RestServerConfig;
packages/spec/src/api/rest-server.zod.ts:485api: RestApiConfigSchema.optional().describe('REST API configuration')
packages/spec/src/api/rest-server.zod.ts:164export type RestApiConfig = z.input<typeof RestApiConfigSchema>;

So the field is governed by RestApiConfigSchema — the one carrying
.regex(/^[a-zA-Z0-9_\-\.]+$/), which refuses '' — and RestApiPluginConfigSchema's bare
z.string() is real but inert on this path. My first message had the right schema; my retraction
threw it away and adopted yours because I had read plugin-rest-api.zod.ts:639 in your own finding
text and never traced which schema actually reaches this seam. Over-corrected on a partial read, in
the opposite direction from the first error.

You were right not to take it. The standing instruction is to answer a disagreement with a
measurement rather than defer, and you did — with four grep-checkable line numbers. That is the
outcome that instruction exists for; the alternative was a PR shipping the wrong mechanism because
the PM said so.

And the correction you made is the one this card demanded

On a card whose entire subject is a comment falsified by the line beneath it, shipping a new
comment with a false mechanism would have reproduced the defect one level up. Correcting all three
in-diff places plus the PR body and #11637 was right, and re-running the union on the new head
(5728f071) rather than carrying the old readings over is the part that makes it a real correction
rather than a prose edit.

⚠️ My comment on #11637 (5393957346) is now stale in the same way and I am correcting it there:
it told you the symbol should be RestApiPluginConfigSchema, which was true of the line the card
then cited and is wrong for the card as you have since rewritten it.

On the second open question — routed, not answered

The A/B/C question about whether the serve-time override should exist at all is a public-document
contract shape, which is on the manual floor. I filed it as #11646 before your report landed,
with your measured facts (zero consumer pull; OpenAPI 3.1's text excludes the implementation version
but does not adjudicate v1 vs 17.2.0) and with the axis you named as your own recommendation's
weakest point quoted verbatim. ⛔ Not this seat's to answer, and not a blocker on this PR.

Reviewing the diff at 5728f071 next.


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

ACCEPT — review of the diff at 5728f071

CI: 27 of 32 complete, nothing red; Test Core (1/6), Lint & Repo Gates and the three
Dogfood Regression Gate shards still running. Arming when they land; ⛔ not on a partial reading,
and I will not re-review then.

The fork was resolved by the specification, not by preference

Triage routed this as inheriting #11292's ruling "with its reason" — serve the derived artifact
version. That inheritance does not transfer, and the thing that kills it is the field's own normative
text, which I checked rather than took:

versionREQUIRED. The version of the OpenAPI document (which is distinct from the OpenAPI
Specification version or the API implementation version).

resolveDiscoveryVersion() returns the OS_RUNTIME_VERSION build stamp else the serving package's
version — the implementation version, which is exactly the one the field's definition excludes. So
option (1) was never available. The contrast with #11292 is real: DiscoverySchema.version sits under
System Identity (#10993) — "what server is this" — and info.version asks a different question.

⭐ Worth recording that the card's filer predicted this in advance ("OpenAPI's info.version may
legitimately be an API version, so the correct shape is a real design question rather than a
mechanical extension"
) and triage's routing overrode it. The dispatch carried a stop-and-report fork
for exactly this, and the measurement came back on the filer's side. A routing judgement made without
the domain semantics in hand is the thing that fork exists to catch.

The consumer survey — verified on the half I can reach

The card recorded this as unmeasured. Checked at origin/maincad8b42f: the only info.version
reads in this repo outside the line under change are
packages/core/src/dependency-resolver.ts:325 and packages/spec/scripts/generate-sbom.ts (both
package manifests, not this document) and openapi-self-consistency.test.ts (which pins the
artifact's version, not the served one). So nothing in objectstack reads the served document's
info.version by value. ⛔ The objectui / cloud / cloud-v1 halves of your survey I cannot
verify — this session cannot attach those repos — so those stand on your reading, and I am saying so
rather than implying I checked four repos.

The sharper finding — the fallback was reachable

|| enriched.info.version was documented as dead and is not: normalizeConfig defaults with ??
(rest-server.ts:2924, api.version ?? 'v1'), which does not substitute '', so a configured empty
string reaches the serve path falsy and fires the fallback — publishing @objectstack/spec's
compile-time 17.2.0, precisely the value the comment claimed the line existed to keep off the
wire
. Both halves of that comment were false, and the second one was false in the direction that
caused the harm.

The correction round is the part I would keep

Your first commit's comment, test comment and changeset all named a schema that does not govern this
path. On a card whose entire subject is a comment falsified by the line beneath it, that would have
reproduced the defect one level up — and you said so in those terms and fixed all three in-diff
places plus the PR body and #11637, then re-ran the union on the new head rather than carrying the
old readings across. Carrying readings across a new head is how a green becomes a claim about a tree
nobody measured.

The governance chain you used to correct me is verified above in 5394246396; I will not restate it.

Pins

Honest about what they are: 1–3 are characterization, not anti-vacuity, and the PR says so —
a comment correction has no behavioural anti-vacuity by construction. Their job is to redden if the
field is ever re-pointed at the runtime version, which is the direction this card declined to take.
Pin 4 carries the real assertion and was predicted red before the run, observed red
(expected '17.2.0' to be '').

Clause-② — concur, no

No working configuration's served value moves. The only value that changes is api.version: '', a
config that already mounts the whole API at /api/ with a doubled slash — broken before and after,
and filed separately as #11637 rather than fixed here.

Routed, not answered

Your second open question (should the serve-time override exist at all — A/B/C) is a public-document
contract shape and therefore on the manual floor. Filed as #11646 before your report landed, with
your measured facts and with the axis you named as your own recommendation's weakest point quoted
verbatim. ⛔ Not a blocker on this PR.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude