Uh oh!
There was an error while loading. Please reload this page.
fix(rest): serve the producer's derived discovery version, not config.api.version (#11292) - #11548
Conversation
…fig.api.version` (#11292) `registerDiscoveryEndpoints` called `protocol.getDiscovery()` and then unconditionally overwrote the result's `version` with `this.config.api.version` — the API version identifier that `normalizeConfig()` defaults to `'v1'` and that `getApiBasePath()` uses to build the mount. So `GET /api/v1/discovery` answered with the path segment the caller had just typed to get there. `DiscoverySchema` declares `version` under "System Identity" alongside `name` and `environment`; the #10993 ruling settled that as the serving artifact's version and #11235/#11242 reaffirmed it. Dropping the override lets the producer's derivation (`OS_RUNTIME_VERSION` → package version → `'unknown'`) reach the wire, which is the same stamp `/health` and the runtime dispatcher's own `/discovery` read. The API-version fact stays recoverable from the same document: every `routes` entry is prefixed with the mounted base path. No schema change and no new field. The pin in `discovery-schema-conformance.test.ts` drives the real producer through the real handler and asserts PROVENANCE, not a literal — an injected `OS_RUNTIME_VERSION` stamp must reach the wire, and the served value must equal the producer's own answer even on a server mounted at a different `api.version`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45ba760eeccd0f3b5954c7250258ab54343f5e68 && git checkout 45ba760eeccd0f3b5954c7250258ab54343f5e68
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4c9780c7ace66a9ded339c5b261465ad89765b1f e7c103c122f10f50829d6d5b030252e3cb264e91 && git checkout -B drift-repro 4c9780c7ace66a9ded339c5b261465ad89765b1f && git merge --no-ff e7c103c122f10f50829d6d5b030252e3cb264e91
node scripts/docs-audit/affected-docs.mjs --json 4c9780c7ace66a9ded339c5b261465ad89765b1f
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11292
registerDiscoveryEndpointscalled the producer and overwrote its answer one line later:config.api.versionis the API version identifier, not an artifact identity:normalizeConfig()defaults it to'v1',packages/spec/src/api/plugin-rest-api.zod.tsdescribes it as'API version identifier', andgetApiBasePath()builds the mount from it (api.apiPath ?? ${api.basePath}/${api.version}→/api/v1). SoGET /api/v1/discoveryansweredversion: "v1"— the path segment the caller had just typed to reach the endpoint.DiscoverySchemadeclaresversionunder System Identity, grouped withnameandenvironment. The #10993 ruling settled that as the serving artifact's version and #11235/#11242 reaffirmed it. Direction A per triage (5385327999): drop the override, let the producer's derived value through. No schema change, no new field.The API-version fact is not lost — every entry in the same document's
routesis prefixed with the mounted base path, which is built fromapi.version.Clause-②: noA served value pulled back to its declared semantics. The accept set is unchanged (no schema, no parser, no validation touched — the diff is
packages/restonly), and the public surface is unchanged (no field added, removed or renamed;DiscoverySchemais untouched). What changes is the value on one existing field of one endpoint, in the direction the schema already declared.api.versionkeeps its real job of building the mount.Premise-first — both second-hand readings re-measured
Triage flagged two load-bearing readings as unverified (engine seat's note
5385245138says so in as many words). Re-measured on this branch, with the zero-hit discipline — control termgetDiscoveryInforeturns 92 hits across 17 files over the same population and tooling, so the queries were live.1. "
rest-server.tsisgetDiscovery()'s only non-test caller" — CONFIRMED. Scanning the spelling family (not one spelling) across*.ts/tsx/mts/mjs/js, the only non-test call isrest-server.ts:3216. Every other non-test occurrence is one of: the definition (metadata-protocol/src/protocol.ts:4808), the interface declaration (spec/src/api/protocol.zod.ts:2516), prose in comments, or handler-name string literals inspec/src/api/plugin-rest-api.zod.ts. Those strings are not an indirect caller:packages/rest/src/route-manager.ts:69-77explicitly refuses a string handler (Received handler identifier "…"), so the manifest is declarative. No destructured or aliased reference form exists ({ … getDiscovery … } =/getDiscovery as→ zero, under a scan whose substring would have caught either).2. "
DiscoverySchemahas exactly two producers" — CONFIRMED.getDiscovery()in@objectstack/metadata-protocolandHttpDispatcher.getDiscoveryInfo()in@objectstack/runtime;packages/spec/src/api/discovery.zod.ts:421names the same pair.@objectstack/restis not a third producer but a serving seam composing over the first, which is exactly how its own conformance suite frames it. The otherDiscoverySchemamentions are route-ledgerresponseSchemametadata and prose.3. PR #11297 (#11235) is on the ref branched from — CONFIRMED.
376c70f9is an ancestor, andresolveDiscoveryVersion()readsgetEnv('OS_RUNTIME_VERSION') || resolvePackageVersion() || 'unknown'. This matters to what the override was doing: before #11297 it masked two producers that disagreed ('1.0.0'vs'1.0'); after it, it overwrote a value that already agreed.The one real fork did not trigger. No by-value consumer of
'v1'— no version comparison, feature gate or cache key. The'v1'by-value assertions that do exist (packages/spec/src/api/rest-server.test.ts,plugin-rest-api.test.ts,versioning.test.ts) all assert the config default, a different fact this diff does not touch.packages/client/tests/integration/01-discovery.test.tsis a shape assertion (toMatch(/^v?\d+/)), which passes both before and after. As the card itself says, "I found no consumer" is not "there is no consumer" — embedder code outside this repo is invisible from here.The pin: provenance, not a literal
Added to
packages/rest/src/discovery-schema-conformance.test.ts, which drives the real producer through the real handler. Four assertions, none naming a version literal:OS_RUNTIME_VERSIONstamp injected by the test reaches the wire (the sentinel exists nowhere in the REST layer, so it can only have come throughgetDiscovery());api.version: 'v9'— withroutes.data === '/api/v9/data'asserted as anti-vacuity, so the option provably took effect — and the served version is not'v9';A pin spelling
'1.0.0'would rot at the next release and would re-create the class #11295 is filed against.Anti-vacuity: shown failing without the fix
Ablation carried
trap … EXIT INT TERM, and the mutation was proven on disk each leg by grepping the injected and the removed text (an editor exit code proves nothing; a zero-hitsedexits 0):discovery.version = this.config.api.version;1Tests 4 failed | 17 passed (21)— exactly the 4 new testsgit diffemptyTests 21 passed (21)Predicted direction before running was red-by-4; that is what was observed.
No rebuild was needed between legs, and this is measured rather than assumed: the mutated subject is imported relatively (
./rest-server.js→ source), andpackages/resthas nodist/at all in this worktree, yet the suite runs — so it provably reads source. The producer is the dist-resolved half (@objectstack/metadata-protocolis a bare specifier and sits inKNOWN_UNALIASED_TEST_IMPORTSfor@objectstack/rest, so it resolves throughexportsintodist/); its closure was built first and verified to carry the #11235 derivation —version: resolveDiscoveryVersion()present, the'1.0'literal absent fromdist/index.js.Verification, at
e7c103c1Union re-run after the final commit, on that exact tree.
pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build—os-verify-lock: VERDICT command-exit 0pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2—Test Files 140 passed (140),Tests 2228 passed (2228),REST_TEST_EXIT=0pnpm --filter @objectstack/rest typecheck—REST_TSC_EXIT=0Gate set derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(which reportedgate list derived from the tree of 'objectstack-ai/objectstack' at commit e7c103c1), not a hand-built path list. Every exit captured before any pipe. All green (exit=0):check:authz-resolver,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:dispatcher-error-vocabulary,check:objectui-changeset,check:published-files,check:route-envelope,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:nul-bytes,check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher,check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-ci-filter-parity.mjs,check-cross-package-test-inputs.mjs,check-empty-changeset.mjs,check-plugin-teardown-shape.mjs,docs-audit/check-affected-docs.mjs.pnpm check:type-check-debt— NOT MEASURED, not passed. Its two cheap legs printed clean (✓ check:type-check-coverage --self-test — 47 semantic case(s) … hold.andcheck-type-check-coverage: OK — 65/78 workspace packages type-checked …), but the--re-measureratchet leg did not reach a verdict: the first attempt refused outright on an unbuilt closure (--re-measure cannot run: 32 workspace dependenc(ies) … have no built type entry point on disk), and after building the full closure (pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'→Tasks: 70 successful, 70 total) the re-measure was killed by this container's ~10-minute foreground limit before emitting one. Flagging it explicitly because it is genuinely reachable from this diff:@objectstack/restcarries aTEST_DEBTentry (errors: 155, shrink-only) and this PR adds test code. CI runs it on the real tree.Lint — a declared narrowing, with its three evidences.
pnpm lint(eslint . --no-inline-config) is CI's repo-wide run and was not run here. Targeted instead, and the narrowing is a measurement rather than a gap: (1) population membership read from eslint's own config, not guessed —eslint --print-configresolves 6 and 5 rules for the two changed files respectively, so both are in the linted population and neither is ignored; (2) counts read from--format json—files linted: 2,errors: 0,warnings: 0, exit 0; (3) invariance for untouched files — this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file" (eslint.config.mjs:327-328, and everyparserOptionsin the config is{ ecmaVersion, sourceType }), so each file's verdict is a function of that file plus the config alone, and this diff touches no config and therefore cannot move any untouched file's verdict.Out of scope
Filed #11546 (unassigned): the sibling site at
rest-server.ts:3622-3629sets openapi.json'sinfo.versionto the sameconfig.api.versionunder a comment promising "the runtime version" — comment and code disagree there. Deliberately not fixed here: OpenAPI'sinfo.versionlegitimately may be an API version, so the correct shape is a genuine design question rather than a mechanical extension of this card, and it is outside this card's declared file surface behaviour.#11295 is
Blocked-by:this card and unlocks on close; its docs are not touched here.Generated by Claude Code