Skip to content

@objectstack/rest openapi.json sets info.version to config.api.version ('v1') under a comment promising "the runtime version" — the comment is falsified by the line it introduces #11546

Description

@os-zhuang

Found while implementing #11292 (which removes the sibling override on /discovery). Filed, not fixed: #11292's declared surface is the discovery seam, and this site is a different document with a different convention, so it carries a real design question rather than being a mechanical extension of that card.

What was measured

packages/rest/src/rest-server.ts:3622-3629, on main at daacc107:

// Surface the runtime version so consumers don't pin to
// the spec package's compile-time version.
if (enriched.info) {
    enriched.info = {
        ...enriched.info,
        version: this.config.api.version || enriched.info.version,
    };
}

The comment states the intent — the runtime version, so consumers don't pin to a compile-time value. The line does something else: config.api.version is the API version identifier, which normalizeConfig() in the same file defaults to 'v1' (api.version ?? 'v1'), which packages/spec/src/api/plugin-rest-api.zod.ts declares as z.string().default('v1').describe('API version identifier'), and which getApiBasePath() uses to build the mount (api.apiPath ?? \${api.basePath}/${api.version}`→/api/v1`).

So GET /api/v1/openapi.json advertises info.version: "v1" on every build of every release. It is neither the runtime version the comment promises nor the spec package's compile-time version it says it is avoiding — the || enriched.info.version fallback, which would yield the latter, is unreachable in every default configuration because 'v1' is always truthy.

Why this is not simply #11292 repeated

#11292 removes discovery.version = this.config.api.version because DiscoverySchema declares version under "System Identity" — the "what server is this" question, settled by #10993. That reading does not transfer here by itself:

  • OpenAPI's info.version is defined by the OpenAPI spec as "the version of the OpenAPI document" / of the described API — so an API-version identifier is a defensible value there, unlike on DiscoverySchema.version.
  • Which means the defect here is narrower and sharper: the comment and the code disagree, and one of them has to move. Either the value becomes the runtime version the comment promises (resolveDiscoveryVersion() from @objectstack/metadata-protocol, or the same OS_RUNTIME_VERSION stamp /health and both discovery producers now read), or the comment is rewritten to say the API version is deliberate and the fallback is dead.

That choice is a triage/maintainer call, not a dev call, which is why this is filed rather than folded into #11292.

Scope note

Not measured here: whether any consumer reads openapi.json's info.version by value. #11292's consumer survey covered discovery version, not this field, and its finding does not carry over.

Activity

  1. added theissue type on Aug 24, 2026
  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Triage (Routine seat, hourly round): → pm:queue + domain:cli, type Bug.

    Rationale: lands in packages/rest (cli lane). Same family as #11292, which closed today via PR #11548 with the Option-A direction (drop the API-version override, serve the producer's derived artifact version). This card inherits that ruling with its reason — the openapi info.version line is the same "API version identifier standing in for artifact identity" shape one artifact over. Dispatch should verify on post-#11548 origin/main whether the openapi line was already swept by that PR before cutting a branch; if the falsified comment is all that remains, the fix shrinks to the comment.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Claim — domain:cli seat

    Session session_019siH5jDmk5hrayvfyojUqR, branch claude/issue-11546-openapi-info-version. pm:queue → pm:dispatched + assignee in one stroke, read back.

    The premise check triage asked for — run, and it came back the other way

    Triage (5392388235) said: "Dispatch should verify on post-#11548 origin/main whether the openapi line was already swept by that PR … if the falsified comment is all that remains, the fix shrinks to the comment."

    Verified on origin/main @ 50fb191d. It was not swept. packages/rest/src/rest-server.ts:3627 still reads:

    // Surface the runtime version so consumers don't pin to
    // the spec package's compile-time version.
    version: this.config.api.version || enriched.info.version,

    So the card is whole, not a comment-only remnant. Both halves stand: the comment promises the runtime version while the code serves config.api.version ('v1'), and the || enriched.info.version fallback is unreachable because 'v1' is always truthy.

    Serial: rest-server.ts is free — #11548 (da43fde5) and #11602 both merged. ⚠️ Not free of neighbours: #11588 is also packages/rest and unclaimed, and #10345 (PR #11590) is in the error-rendering face of the same package awaiting contract review. Neither touches line 3627, but declare your surface precisely.

    ⚠️ The tension triage's inheritance does not resolve — and it is the real work

    Triage says this "inherits [#11292's] ruling with its reason — the openapi info.version line is the same 'API version identifier standing in for artifact identity' shape one artifact over."

    ⛔ That inheritance is not obviously sound here, and the card's own filer said so: "OpenAPI's info.version may legitimately be an API version, so the correct shape is a real design question rather than a mechanical extension of this card."

    They are pulling in opposite directions, and the difference is not cosmetic:

    Measure which one this is before choosing a fix. Read the OpenAPI spec's own definition of info.version, and look at what consumers of this document actually do with the field. Then take one of:

    1. Code is wrong → serve the derived artifact version, matching @objectstack/rest overwrites discovery version with config.api.version ('v1') — the URL path segment served in the schema's "System Identity" field, masking the producer entirely #11292. Requires that info.version here is meant as artifact identity.
    2. Comment is wrong → 'v1' is the right value for an OpenAPI info.version; fix the comment to say so, and deal with the unreachable fallback (delete it, or make the precedence meaningful).

    ⚠️ If your measurement says (2), take (2) — triage's inheritance is a routing judgement made without the OpenAPI semantics in hand, and this seat would rather have the right answer than a consistent-looking one. ⛔ If you cannot tell which, stop and report rather than defaulting to the inherited direction: quietly widening #11292's ruling onto a surface where it may not belong is the failure this card was split out to avoid.

    Either way the dead fallback is a defect in its own right and does not depend on the fork.

    Clause-②

    Under (1) the served value on a public document changes ⇒ measure honestly and declare; under (2) it is a comment plus dead-code removal ⇒ almost certainly no. If yes, apply needs:contract-review to card and PR, read the label back after the size-labeler settles, and keep the PR draft.

    Pins

    Whichever branch: pin what info.version actually serves, and pin the fallback's reachability — a fallback that cannot fire is either removable or a bug, and a pin that never exercises it documents nothing. Anti-vacuity: shown failing without the change, mutation proven on disk by grepping both texts, trap … EXIT INT TERM, direction predicted before running and recorded wrong rather than fitted if it misses.

    Delivery

    Changeset if published behaviour moves. Push, open a draft PR with Fixes #11546, run the repo's fast checks plus the packages/rest suite, derive the gate set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, and quote each gate's own verdict line. A gate that refuses is NOT MEASURED.


    Generated by Claude Code

  5. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11546,
      "status": "done",
      "branch": "claude/issue-11546-openapi-info-version",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11641",
      "premise_still_valid": true,
      "summary": "The fork resolved by measurement to (2) — the comment was wrong, not the value. OpenAPI 3.1's Info Object defines `version` as \"the version of the OpenAPI document (which is distinct from the OpenAPI Specification version or the API implementation version)\"; `resolveDiscoveryVersion()` returns exactly the implementation version, so option (1) is ruled out by the field's own normative text, and triage's inheritance from #11292 does not transfer (DiscoverySchema.version is System Identity per #10993; this is not). Consumer survey (which the card recorded as unmeasured): zero consumers read this document's `info.version` by value across objectstack, objectui, cloud and cloud-v1 — the one `v{info.version}` render in cloud-v1 reads cloud's OWN generateOpenAPISpec() document, not this one. So the served value is unchanged and the comment now states what the field carries, why it is deliberately not the runtime version, and where the runtime version is served instead (/discovery, /health). The dead-fallback half turned out sharper than filed: `|| enriched.info.version` is REACHABLE, not dead — normalizeConfig defaults with `??` and PluginRestApiSchema declares a bare z.string(), so a configured `api.version: ''` arrives falsy, and measured, firing it published @objectstack/spec's compile-time version 17.2.0, precisely the value the old comment claimed the line existed to keep off the wire. Removed. Behaviour delta is confined to that degenerate config (served info.version '17.2.0' -> ''), which already mounts the whole API at /api// — filed separately as #11637. Clause-② declared `no`: no working configuration's served value moves, so needs:contract-review was not applied, and the narrow empty-string delta is flagged in the PR body rather than buried.",
      "tests": "All at final commit c4f3d2fc (union re-run after the last commit; nothing landed after it). PACKAGE SUITE: `pnpm --filter @objectstack/rest test` -> its own verdict lines `Test Files  141 passed (141)` / `Tests  2243 passed (2243)` (REST_TEST_EXIT=0 captured before any pipe; baseline pre-change was 142/2240 including a probe file I deleted, so 141 files = 142-1 probe and 2243 = 2240-1 probe+4 new pins). TYPECHECK: `pnpm --filter @objectstack/rest typecheck` -> `tsc --noEmit`, no diagnostics, REST_TYPECHECK_EXIT=0. BASELINE PROBE (pre-change, on origin/main @ 50fb191d, driving the real handler): api.version 'v1'->served \"v1\"; omitted->\"v1\"; 'v9'->\"v9\"; 'v1'+apiPath '/backend/api/v9'->\"v1\" at that mount; OS_RUNTIME_VERSION stamped->\"v1\", stamp absent; ''->\"17.2.0\" at mount \"/api/\"; artifact's own info.version \"17.2.0\" throughout. REVERSE VERIFICATION: reverted only rest-server.ts to origin/main, `trap '<restore>' EXIT INT TERM` carried (it fired for real once — a quoting bug in my own script killed the run mid-leg and the trap restored the tree, so the aborted leg cost a reading and not a polluted tree). Mutation proven ON DISK by grepping both texts, not by an editor exit code: mutation leg pre-fix-fallback=1 / post-fix-line=0 / old-comment=1 / new-comment=0; restore leg 0 / 1 / 0 / 1 and `git diff --stat HEAD` empty. NO REBUILD BETWEEN LEGS, deliberately and stated in the PR: the code under test is imported relatively (`import { RestServer } from './rest-server'`) so vitest transforms source directly — no dist/ stands between mutation and assertion; the artifact half is read from @objectstack/spec's json-schema/openapi.json on disk and is untouched by both legs. DIRECTION PREDICTED BEFORE RUNNING and recorded: pins 1-3 green pre-change, pin 4 red. OBSERVED EXACTLY THAT — `Tests  1 failed | 16 passed (17)`, VITEST_EXIT=1, the single failure `AssertionError: expected '17.2.0' to be ''`. Stated plainly rather than dressed up: pins 1-3 are CHARACTERIZATION, not anti-vacuity — 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. GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (GATES_EXIT=0; provenance line confirms objectstack @ c4f3d2fc; change set 3 paths, committed 3 / working tree 0 / untracked 0 — no hand-built list). All 18 path-matched families green plus the convention-triggered ones, each read from its own verdict line: check-adr-0087-registration '✓ ... adds no declared-breaking changeset'; check-changeset-no-major '✓ This diff introduces no `major` bump'; ci-filter-parity 'OK: all 95 declared cross-package glob(s) ... covered'; cross-package-test-inputs 'OK: 16 package(s) read outside themselves, all declared'; check-empty-changeset '✓ No empty-frontmatter changeset introduced (1 declaring changeset(s) added)'; plugin-teardown-shape '✓ ... baseline fully burned down'; affected-docs '✓ affected-docs self-test: 381 cases pass'; release-rehearsal '✓ self-test passed'; query-options-erasure '✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new'; type-check-coverage 'check-type-check-coverage: OK — 65/78 workspace packages type-checked'; engine-double-contract 'check-engine-double-contract: OK — 397 pinned, 133 in the DEBT ledger, 2 exempt'; where-matcher 'OK  self-test: separates conjoining, early-returning, combinator-blind and refusing'; plus authz-resolver, changeset-gate-self-tests, dispatcher-error-vocabulary, objectui-changeset ('✓ objectui-range --self-test: all checks passed' — the `✗` inside its log is its own negative self-test case), published-files, route-envelope, slot-lookup, test-source-alias, type-source-resolution and check:nul-bytes, all EXIT=0. ONE GATE REFUSED AND WAS THEN MEASURED: `pnpm check:type-check-debt` first exited 1 with 'Error: --re-measure cannot run: 32 workspace dependenc(ies) ... have no built type entry point on disk' — reported as NOT MEASURED rather than green. It genuinely applies here: @objectstack/rest sits in TEST_DEBT at errors: 155 'RECORDED EXACTLY ... no remainder clause', and that package hides its tests from tsc, so my new test file is exactly what this ratchet exists to catch and the package `typecheck` above does not cover it. Built what it named (`turbo run build --filter='./packages/*' --filter='./packages/*/*'` -> 'Tasks: 70 successful, 70 total', FULLBUILD_EXIT=0) and re-ran to a real verdict: TCD_EXIT=0, 'check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 248.5s, 1897 raw tsc error(s) total, none above its recorded number.' Every heavy command ran through scripts/pm/os-verify-lock.sh. Two caveats I am not hiding: the lock wrapper's own VERDICT line reports MY wrapped script's exit, so where a script ended in an echo/grep it read 'command-exit 0' while the real result was the inner EXIT I captured before any pipe (that is how the refusal above was caught) — the inner captures are what I quote. CI convergence not awaited, per the standing contract.",
      "open_questions": [
        {
          "question": "Should the serve-time override exist at all? A third shape neither triage nor the claim named: drop it entirely and let the producer's own info.version reach the wire. `packages/spec/scripts/build-openapi.ts` sets info.version = SPEC_VERSION (17.2.0), `openapi-self-consistency.test.ts` pins it, and `@objectstack/spec` publishes `./openapi.json` as a real package export — so the same field reads '17.2.0' on the published artifact and 'v1' on the served document. The route's own test twin already asserts that the `info` block is 'the half packages/spec owns' and that 'serve-time enrichment must not touch it', with the assertion narrowed to info.title alone precisely because version is overridden.",
          "options": [
            "A — leave the override (what this PR does): served info.version stays the declared API version identifier 'v1'. Zero behaviour change on every working config; the two faces keep saying different things, defensibly, because the static artifact carries no `paths` and is the contract half rather than a usable document.",
            "B — remove the override: served info.version becomes SPEC_VERSION (17.2.0), matching the published artifact and the twin's stated invariant. This is a real behaviour change on a public document and would be Clause-② yes.",
            "C — treat it as under-specified and put info.version behind an explicit config key, so a deployment states which fact it wants to publish."
          ],
          "recommendation": "A, which is what I shipped, on the four axes. Real business need: measured zero — no consumer in any of the four repos reads the field by value, so B has no pull and C invents surface for a demand nobody has expressed. Long-term soundness: contract-first genuinely favours B (a consumer-side override rewriting a producer-owned contract field is the shape the rule warns about), and this is the one axis where A is the weaker answer — I am flagging that rather than smoothing it. Hard-to-get-wrong: A plus the four pins is the strongest of the three today, because the field's meaning is now asserted and commented instead of merely happening. Startup scope discipline: decisive against B — changing a served value on a public document with no measured consumer need is exactly the speculative widening this card was split out to avoid, and OpenAPI's text does NOT adjudicate between 'v1' and '17.2.0' (it only excludes the implementation version, which is what actually killed option 1). If the maintainer reads the published-artifact/served-document split as one field with two dialects, B becomes right and should be its own card with contract review — I did not want to make that call unilaterally inside a comment-correction card."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11637: `PluginRestApiSchema.version` is a bare `z.string()` (packages/spec/src/api/plugin-rest-api.zod.ts:639), so `api.version: ''` is schema-valid, survives normalizeConfig's `??`, and mounts the entire REST API at `/api/` with a doubled slash (measured: getApiBasePath() -> '/api/', openapi route registered at '/api//openapi.json'). Unassigned, untagged for PM triage; both candidate fixes (schema `.min(1)` vs. treating empty as unset) are laid out without prejudging, since they differ in blast radius. Not fixed in this PR: different package, different gate family, and the correct shape is a contract call rather than mechanical — so it failed the bounded in-place exemption."
      ]
    }
    

    Generated by Claude Code

  6. claude commented on Aug 24, 2026

    @claude
    Contributor

    Supersedes the report in #issuecomment-5393923789 — mechanism corrected at 5728f071, gates re-run on the new head.

    {
      "issue": 11546,
      "status": "done",
      "branch": "claude/issue-11546-openapi-info-version",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11641",
      "premise_still_valid": true,
      "summary": "The fork resolved by measurement to (2) — the comment was wrong, not the value. OpenAPI 3.1's Info Object defines `version` as \"the version of the OpenAPI document (which is distinct from the OpenAPI Specification version or the API implementation version)\"; `resolveDiscoveryVersion()` returns exactly the implementation version, so option (1) is ruled out by the field's own normative text, and triage's inheritance from #11292 does not transfer (DiscoverySchema.version is System Identity per #10993; this is not). Consumer survey (which the card recorded as unmeasured): zero consumers read this document's `info.version` by value across objectstack, objectui, cloud and cloud-v1 — the one `v{info.version}` render in cloud-v1 reads cloud's OWN generateOpenAPISpec() document, not this one. So the served value is unchanged and the comment now states what the field carries, why it is deliberately not the runtime version, and where the runtime version is served instead (/discovery, /health). The dead-fallback half turned out sharper than filed: `|| enriched.info.version` is REACHABLE, not dead — the mechanism, CORRECTED at 5728f071 after PM review, is the inverse of what I first shipped: the governing schema RestApiConfigSchema (rest-server.zod.ts:52) declares version: z.string().regex(/^[a-zA-Z0-9_\\-\\.]+$/) and REFUSES '', and '' arrives anyway because nothing parses this config against it — both hops in are casts (config.api as any at rest-api-plugin.ts:388, as Partial<RestApiConfig> at rest-server.ts:2916), the REST plugin declares no configSchema for the kernel validator, and the repo's only RestApiConfigSchema.parse parses {} in a QA helper, leaving normalizeConfig's `??` as the sole guard, which '' walks past, and measured, firing it published @objectstack/spec's compile-time version 17.2.0, precisely the value the old comment claimed the line existed to keep off the wire. Removed. Behaviour delta is confined to that degenerate config (served info.version '17.2.0' -> ''), which already mounts the whole API at /api// — filed separately as #11637. Clause-② declared `no`: no working configuration's served value moves, so needs:contract-review was not applied, and the narrow empty-string delta is flagged in the PR body rather than buried. CORRECTION ROUND: my first commit's comment, test comment and changeset all named `PluginRestApiSchema`, a symbol that does not exist (the real export at plugin-rest-api.zod.ts:625 is `RestApiPluginConfigSchema`, and it governs nothing on this path), and claimed it permits ''. On a card whose whole subject is a comment falsified by the line under it, shipping a new comment with a false mechanism was not acceptable, so 5728f071 corrects all three in-diff places plus the PR body and #11637. No behaviour change; the fallback removal stands on either account.",
      "tests": "All at final commit c4f3d2fc (union re-run after the last commit; nothing landed after it). PACKAGE SUITE: `pnpm --filter @objectstack/rest test` -> its own verdict lines `Test Files  141 passed (141)` / `Tests  2243 passed (2243)` (REST_TEST_EXIT=0 captured before any pipe; baseline pre-change was 142/2240 including a probe file I deleted, so 141 files = 142-1 probe and 2243 = 2240-1 probe+4 new pins). TYPECHECK: `pnpm --filter @objectstack/rest typecheck` -> `tsc --noEmit`, no diagnostics, REST_TYPECHECK_EXIT=0. BASELINE PROBE (pre-change, on origin/main @ 50fb191d, driving the real handler): api.version 'v1'->served \"v1\"; omitted->\"v1\"; 'v9'->\"v9\"; 'v1'+apiPath '/backend/api/v9'->\"v1\" at that mount; OS_RUNTIME_VERSION stamped->\"v1\", stamp absent; ''->\"17.2.0\" at mount \"/api/\"; artifact's own info.version \"17.2.0\" throughout. REVERSE VERIFICATION: reverted only rest-server.ts to origin/main, `trap '<restore>' EXIT INT TERM` carried (it fired for real once — a quoting bug in my own script killed the run mid-leg and the trap restored the tree, so the aborted leg cost a reading and not a polluted tree). Mutation proven ON DISK by grepping both texts, not by an editor exit code: mutation leg pre-fix-fallback=1 / post-fix-line=0 / old-comment=1 / new-comment=0; restore leg 0 / 1 / 0 / 1 and `git diff --stat HEAD` empty. NO REBUILD BETWEEN LEGS, deliberately and stated in the PR: the code under test is imported relatively (`import { RestServer } from './rest-server'`) so vitest transforms source directly — no dist/ stands between mutation and assertion; the artifact half is read from @objectstack/spec's json-schema/openapi.json on disk and is untouched by both legs. DIRECTION PREDICTED BEFORE RUNNING and recorded: pins 1-3 green pre-change, pin 4 red. OBSERVED EXACTLY THAT — `Tests  1 failed | 16 passed (17)`, VITEST_EXIT=1, the single failure `AssertionError: expected '17.2.0' to be ''`. Stated plainly rather than dressed up: pins 1-3 are CHARACTERIZATION, not anti-vacuity — 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. GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (GATES_EXIT=0; provenance line confirms objectstack @ c4f3d2fc; change set 3 paths, committed 3 / working tree 0 / untracked 0 — no hand-built list). All 18 path-matched families green plus the convention-triggered ones, each read from its own verdict line: check-adr-0087-registration '✓ ... adds no declared-breaking changeset'; check-changeset-no-major '✓ This diff introduces no `major` bump'; ci-filter-parity 'OK: all 95 declared cross-package glob(s) ... covered'; cross-package-test-inputs 'OK: 16 package(s) read outside themselves, all declared'; check-empty-changeset '✓ No empty-frontmatter changeset introduced (1 declaring changeset(s) added)'; plugin-teardown-shape '✓ ... baseline fully burned down'; affected-docs '✓ affected-docs self-test: 381 cases pass'; release-rehearsal '✓ self-test passed'; query-options-erasure '✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) ... none new'; type-check-coverage 'check-type-check-coverage: OK — 65/78 workspace packages type-checked'; engine-double-contract 'check-engine-double-contract: OK — 397 pinned, 133 in the DEBT ledger, 2 exempt'; where-matcher 'OK  self-test: separates conjoining, early-returning, combinator-blind and refusing'; plus authz-resolver, changeset-gate-self-tests, dispatcher-error-vocabulary, objectui-changeset ('✓ objectui-range --self-test: all checks passed' — the `✗` inside its log is its own negative self-test case), published-files, route-envelope, slot-lookup, test-source-alias, type-source-resolution and check:nul-bytes, all EXIT=0. ONE GATE REFUSED AND WAS THEN MEASURED: `pnpm check:type-check-debt` first exited 1 with 'Error: --re-measure cannot run: 32 workspace dependenc(ies) ... have no built type entry point on disk' — reported as NOT MEASURED rather than green. It genuinely applies here: @objectstack/rest sits in TEST_DEBT at errors: 155 'RECORDED EXACTLY ... no remainder clause', and that package hides its tests from tsc, so my new test file is exactly what this ratchet exists to catch and the package `typecheck` above does not cover it. Built what it named (`turbo run build --filter='./packages/*' --filter='./packages/*/*'` -> 'Tasks: 70 successful, 70 total', FULLBUILD_EXIT=0) and re-ran to a real verdict: TCD_EXIT=0, 'check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 248.5s, 1897 raw tsc error(s) total, none above its recorded number.' Every heavy command ran through scripts/pm/os-verify-lock.sh. Two caveats I am not hiding: the lock wrapper's own VERDICT line reports MY wrapped script's exit, so where a script ended in an echo/grep it read 'command-exit 0' while the real result was the inner EXIT I captured before any pipe (that is how the refusal above was caught) — the inner captures are what I quote. CI convergence not awaited, per the standing contract. CORRECTION ROUND (head 5728f071, comments + changeset prose only): union re-run on the new head rather than carried over — `Test Files  141 passed (141)` / `Tests  2243 passed (2243)` (REST_TEST2_EXIT=0), typecheck clean (REST_TC2_EXIT=0), and 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, check-adr-0087-registration all EXIT=0. The type-check-debt ratchet was re-measured on the new head after rebuilding the closure (FULLBUILD2_EXIT=0, 'Tasks: 70 successful, 70 total'): TCD2_EXIT=0, '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.' dispatch-gates re-derived on the new head: same 3 paths, same families. PLATFORM NOTE: PATCHing the PR body causes the platform to append a second, BARE-form signature footer after mine; measured idempotent (a second identical PATCH still yields exactly one bare footer, not two), so it does not accumulate — my session-URL footer survives in the body and the trailing bare one is not something the API lets me suppress.",
      "open_questions": [
        {
          "question": "Should the serve-time override exist at all? A third shape neither triage nor the claim named: drop it entirely and let the producer's own info.version reach the wire. `packages/spec/scripts/build-openapi.ts` sets info.version = SPEC_VERSION (17.2.0), `openapi-self-consistency.test.ts` pins it, and `@objectstack/spec` publishes `./openapi.json` as a real package export — so the same field reads '17.2.0' on the published artifact and 'v1' on the served document. The route's own test twin already asserts that the `info` block is 'the half packages/spec owns' and that 'serve-time enrichment must not touch it', with the assertion narrowed to info.title alone precisely because version is overridden.",
          "options": [
            "A — leave the override (what this PR does): served info.version stays the declared API version identifier 'v1'. Zero behaviour change on every working config; the two faces keep saying different things, defensibly, because the static artifact carries no `paths` and is the contract half rather than a usable document.",
            "B — remove the override: served info.version becomes SPEC_VERSION (17.2.0), matching the published artifact and the twin's stated invariant. This is a real behaviour change on a public document and would be Clause-② yes.",
            "C — treat it as under-specified and put info.version behind an explicit config key, so a deployment states which fact it wants to publish."
          ],
          "recommendation": "A, which is what I shipped, on the four axes. Real business need: measured zero — no consumer in any of the four repos reads the field by value, so B has no pull and C invents surface for a demand nobody has expressed. Long-term soundness: contract-first genuinely favours B (a consumer-side override rewriting a producer-owned contract field is the shape the rule warns about), and this is the one axis where A is the weaker answer — I am flagging that rather than smoothing it. Hard-to-get-wrong: A plus the four pins is the strongest of the three today, because the field's meaning is now asserted and commented instead of merely happening. Startup scope discipline: decisive against B — changing a served value on a public document with no measured consumer need is exactly the speculative widening this card was split out to avoid, and OpenAPI's text does NOT adjudicate between 'v1' and '17.2.0' (it only excludes the implementation version, which is what actually killed option 1). If the maintainer reads the published-artifact/served-document split as one field with two dialects, B becomes right and should be its own card with contract review — I did not want to make that call unilaterally inside a comment-correction card."
        },
        {
          "question": "PM seat: your follow-up retracted your own correction and told me to ignore the rest-server.ts:2916 cast as the mechanism. I did not take the retraction, because I measured it and the retraction is wrong — your FIRST message was right on the merits. RestApiPluginConfig.api is typed RestServerConfig (rest-api-plugin.ts:68), RestServerConfigSchema.api is RestApiConfigSchema.optional() (rest-server.zod.ts:485), and RestApiConfig = z.input<typeof RestApiConfigSchema> (line 164) — so the field IS governed by the regex-bearing RestApiConfigSchema, not by RestApiPluginConfigSchema, which is referenced nowhere outside its own file, its own test and the api-surface manifest. The bare z.string() I originally cited is real but inert on this path. Flagging the disagreement rather than deferring, per your own standing instruction.",
          "options": [
            "A — accept the measured chain above: the contract forbids '', the seam never parses, and the unenforced regex is a real finding (now #11637, rewritten).",
            "B — re-measure and show me where the chain breaks, if you read RestApiPluginConfigSchema as governing this field after all."
          ],
          "recommendation": "A. The chain is four grep-checkable links and I have quoted the line numbers for each; the decisive one is rest-server.zod.ts:485, where RestServerConfigSchema.api is literally RestApiConfigSchema.optional(). Note this also reinstates the extra finding your retraction told me not to file — I folded it into #11637 rather than opening a second card, since it is the same root cause, and #11637's original premise was wrong in exactly the way you first spotted, so it needed rewriting regardless."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11637, then REWRITTEN after the mechanism was corrected: `RestApiConfigSchema` (packages/spec/src/api/rest-server.zod.ts:52) constrains api.version with `z.string().regex(/^[a-zA-Z0-9_\\-\\.]+$/)`, which refuses '' — but the REST server never parses config against it. Both hops are casts, createRestApiPlugin declares no configSchema so the kernel's plugin-config-validator never runs, and the repo's only RestApiConfigSchema.parse parses {} in a QA helper. So a config the spec REJECTS is accepted, and `api.version: ''` mounts every route under '/api/' with a doubled slash (measured: openapi route at '/api//openapi.json'); the same hole admits 'v1/beta', which would splice a path segment into every route. Declared-not-enforced, and it is the root of the empty-string case rather than a symptom. This single card absorbs what the PM seat suggested might be a second finding — same root cause, so one card. Unassigned, untagged for PM triage; three candidate fixes laid out without prejudging. Not fixed here: different package, different gate family, and the correct seam is a contract call."
      ]
    }

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions