Skip to content

feat(spec,rest)!: the served OpenAPI info carries the publisher's api.documentation identity; api.documentation.version retired - #20512

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20294-openapi-info-overlay
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20294-openapi-info-overlay

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20294
Fixes #20359

Clause-②: yes (narrowing)

Summary

Executes maintainer ruling B on #20359 (5862477514, batch #231 item 1, maintainer 「同意 B」) for the rest-api-documentation family of the #18900 census, under ADR-0049 enforce-or-remove:

  • ENFORCED: the eight identity members of RestServerConfig.api.documentation. These are title, description, termsOfService, contact.name / contact.url / contact.email and license.name / license.url. An authored value now overlays the served OpenAPI info on both doors, {apiPath}/openapi.json and the environment-scoped twin. With nothing authored, the served info is byte-identical to the artifact.
  • RETIRED: documentation.version. The served info.version stays the spec package version (SPEC_VERSION, written by build-openapi.ts), as the [finding] the served openapi.json overrides the info.version that packages/spec owns, so the published artifact says 17.2.0 and the served document says v1 #11646 ruling (5405070350) set it. An authored version is now refused by a retiredKey() tombstone. Its message names the protocol version as the one source and points an app's own release number to description.

The ruling's execution parameters, each applied:

  • title is now .optional(). The materialized 'ObjectStack API' default was never served; the artifact's 'ObjectStack REST API' stays the default.
  • contact and license replace the bundled object whole, never member by member.
  • The overlay lives in registerOpenApiEndpoints and applies on both doors.
  • api.version and the runtime version never enter info.version.
  • documentation.version has an ADR-0087 D3 entry (family rest-api-documentation).
  • The ledger rows flip, with the pin as evidence.
  • The four pins (1)–(4) are below.

Premises measured before editing (origin/main fc0db22b)

I used a scratch single-file vitest (deleted, never committed), run under the verify lock after building the rest dependency closure. It booted RestServer plus registerRoutes() with project scoping on, then drove both openapi.json handlers:

authored documentation normalized config carries it served info (both doors) same object as the cached artifact info
absent — equals the artifact yes
{} { title: 'ObjectStack API' } (the materialized default) equals the artifact yes
all nine, version included all nine equals the artifact: 0 of 9 honoured yes
{ description } only plus the materialized title equals the artifact yes
{ license: { name: 'MIT' } } plus the materialized title equals the artifact yes

The scoped door answered the same info as the unscoped one in every case. The handler's clone is shallow, so enriched.info is the cached artifact's own object. The overlay therefore builds a NEW object and never writes into it (reverse-verified, leg B below).

Accept / serve changes, each one pinned

Door Input Before After Pin
GET /api/v1/openapi.json and GET /api/v1/environments/:environmentId/openapi.json all eight identity members bundled info each member served; info.version is still the spec package version rest-openapi-info-overlay.test.ts (1)
same two doors nothing authored / documentation: {} bundled info bundled info, byte-identical same file (2); the #11646 whole-block pin in rest-openapi-route.test.ts is unedited and kept as the no-config control
same two doors one key alone (each of title, description, termsOfService, contact, license) bundled info equals the artifact except that key same file (2)
same two doors license: { name: 'MIT' } bundled Apache-2.0 licence { name: 'MIT' }, no url same file (4)
same two doors contact: { email } bundled contact { email } only same file
same two doors version: 'v9' plus an overlay, with OS_RUNTIME_VERSION set to a sentinel — info.version is still the artifact's; no sentinel anywhere in info same file (1)
RestApiConfigSchema / RestServerConfigSchema documentation.version ('2.3.0', '17.4.0', '') accepted, ignored refused: invalid_type at ['documentation', 'version'] with the prescription packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts (3)
new RestServer(…) and createRestApiPlugin(…).start documentation.version constructed, ignored throws; the message locates api.documentation.version, names RestApiConfigSchema and carries the prescription packages/rest/src/rest-api-config-dead-keys-refused.test.ts (3)
TypeScript RestApiConfig (input) documentation.version string never: a tsc error at the authoring site an @ts-expect-error pin, held by check:test-typecheck
RestApiConfigSchema.parse({ documentation: {} }) — { title: 'ObjectStack API' } {} spec retirement test CONTROL; rest rest-config-parse-not-cast.test.ts §D (both flipped with the new substance asserted)

Refusal text, verbatim. It is also the regenerated reference row, which gen:docs prefixes with [REMOVED]:

api.documentation.version was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it, and the served OpenAPI document's info.version has one source: the protocol version, i.e. the version of the @objectstack/spec package that generated the document, which no deployment configuration overrides. Delete the key. To publish your app's own release number, write it into api.documentation.description, which the served info.description carries.

"Protocol version" is spelled out as the package version on purpose. The in-repo constant PROTOCOL_VERSION (17.0.0) is a different value from the served info.version (the package's 17.4.0), and the ruling means the latter.

The kit

  • Schema, packages/spec/src/api/rest-server.zod.ts, the documentation block only:
    • title is .optional().
    • The eight members carry describes that name the served info field.
    • version is a retiredKey() tombstone with an in-schema comment, next to the enabled tombstone. The inline object is a non-strict z.object(), so a bare deletion would strip the key in silence.
  • REST server, packages/rest/src/rest-server.ts, only the registerOpenApiEndpoints region (three hunks between the discovery registrar and loadOpenApiSpec):
  • ADR-0087:
    • RETIRED_KEYS_BY_MAJOR[18] gains api/RestApiConfig:documentation.version (entries/retired-keys/18.api__RestApiConfig__documentation.version.ts).
    • The family D3 entry is rest-api-documentation-version-retired (entries/semantic/18.rest-api-documentation-version-retired.ts).
    • registry.ts is regenerated by gen:migration-registry.
    • There is no D2 conversion: a RestServerConfig is plugin TS configuration, never a stack collection member or a stored row. This matches the rest-api-config-dead-keys-retired precedent on this same block.
    • The route therefore owes exactly two registrations, the exact-key D2 table row and one D3 semantic entry, and no conversion id in MIGRATIONS_BY_MAJOR[18].conversionIds.
  • Ledger, packages/spec/liveness/rest_api.json:
    • title, description, termsOfService, the contact / license containers and their five members flip to live. Evidence is packages/rest/src/rest-server.ts#overlayDocumentationInfo plus the pin file; the producer is normalizeConfig.
    • The version row stays dead with a REMOVED note.
    • A _note addendum records the change.
    • The generated state-counts.md moves rest_api from 12 live / 12 dead to 20 / 4. The README's hand-written rest_api row is edited to match.
  • Generated: content/docs/references/api/rest-server.mdx.
  • Tests:
    • The new packages/rest/src/rest-openapi-info-overlay.test.ts.
    • A [#20294] block in rest-api-config-dead-keys-refused.test.ts.
    • A documentation.version block in the spec retirement test. Its tree-scoped absence walk now also matches a documentation object carrying version, with anti-vacuity cases in .ts, .json and .yaml, plus two neighbour cases that must not match: the route identifier api.version, and the enforced members alone.
    • Two fixtures in packages/spec/src/api/rest-server.test.ts that authored documentation.version: '1.0.0' drop it.
    • One comment in rest-api-config-defaults-follow-spec.pin.test.ts.
  • Changeset, .changeset/20294-openapi-info-publisher-overlay.md:
    • @objectstack/spec minor and @objectstack/rest minor (both in the fixed group).
    • BREAKING banner, Clause-②: yes (narrowing), FROM → TO with the one-line fix.
    • The ADR-0087 disposition marker registered rest-api-documentation-version-retired, in the changeset body where check:adr-0087-registration reads it.

On the Clause-② spelling. The value is the ruling's yes: a behaviour change on a public document, which now publishes owner-set termsOfService / contact.email it never carried. The (narrowing) arm is required because the retirement refuses a formerly accepted input. check-adr-0087-registration.mjs#breakingDeclaration reads that arm, as well as the **BREAKING** banner, as a breaking declaration. The adr-0087: marker lives in the changeset, not in this body. This body carries only the declaration line that Check Changeset reads.

Verification, at HEAD 69b4d203

69b4d203 is origin/main 2b24b8b8 merged in through scripts/pm/os-regen-merge.sh. No generated path needed regeneration after the merge: check:generated read all 15 artifacts current. After the merge I ran pnpm install --frozen-lockfile and rebuilt the rest dependency closure (pnpm --filter '@objectstack/rest^...' build, which includes spec). Every heavy run went through scripts/pm/os-verify-lock.sh, and every exit code was written to disk before any pipe.

  • pnpm --filter @objectstack/rest exec vitest run --project local: exit 0 · 219 files · 4185 passed, 34 skipped.
  • pnpm --filter @objectstack/rest exec vitest run --project repo: exit 0 · 1 file · 8 passed.
  • pnpm --filter @objectstack/spec exec vitest run --project local: exit 0 · 572 files · 16794 passed, 1 todo.
  • pnpm --filter @objectstack/spec exec vitest run --project repo: exit 0 · 38 files · 697 passed. The tree-scoped absence walk runs here.
  • pnpm --filter @objectstack/rest run typecheck and pnpm --filter @objectstack/spec run typecheck: both exit 0. Each is tsc --noEmit plus check:test-typecheck; spec also runs check:scripts-typecheck. A green check:test-typecheck is what proves the new @ts-expect-error pin bites, since an unused one would add a TS2578 signature.
  • pnpm --filter @objectstack/spec check:generated: exit 0, all 15 artifacts current. pnpm --filter @objectstack/spec check:liveness: exit 0.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 115 commands at this head. The --ran reconciliation reads: 115 derived — 113 run, 0 NOT-MEASURED, 2 UNRUN. Of the 113, 112 exit 0 and 1 exits 1:
    • pnpm check:platform-checklist exits 1 with areas/identity-auth.json: ABSENT SYMBOL — packages/plugins/plugin-auth/src/auth-plugin.ts#twoFactor. This red is inherited from main: this diff touches neither docs/qa/** nor packages/plugins/plugin-auth/** (0 diff lines against origin/main there). The anchor went stale when 7d630889 turned the literal twoFactor: true into an inline plugins: { twoFactor: true } object. The gate is deliberately outside per-PR CI (the lint.yml comment near the checklist step).
    • check:skill-examples first exited 3 (PREREQUISITE NOT MET: no client-react declarations). It went green after building @objectstack/client and @objectstack/client-react: 259 prose examples type-check.
    • UNRUN, declared, not measured locally: pnpm check:dual-build-cjs-loads, which reads every package's dist/ (a whole-tree build is outside this card's scope; CI's Build Core builds it), and pnpm check:type-check-debt, whose --re-measure rebuilds every package. This diff touches no DEBT or EXEMPT package, and check:type-check-coverage ran with exit 0.
  • Lint, narrowed and shown not to exclude anything. I ran eslint --no-inline-config --format json over the 11 changed code files: 11 files, 0 errors, 0 warnings. The population comes from eslint.config.mjs's catch-all files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], which all 11 are in. The config never enables type-aware linting (no parserOptions.project, no typed rules, as stated in the config itself), so this diff cannot move the verdict on any untouched file. The repo-wide pnpm lint is CI's.

turbo ls --affected was not used to narrow anything; consumer packages were not rebuilt. The public surface that moves is the RestApiConfig input type (documentation.version becomes never) and the parsed title (string | undefined). The retirement playbook's tree-scoped absence pin is the sweep for authoring sites. It walks packages, examples, skills, content and scripts and finds none. No non-test reader of documentation.title exists outside packages/rest.

Reverse verification (one-time; committed head 69b4d203)

Every mutation went through scripts/ablation-replace.mjs: the anchor hit exactly once, the blob change was verified on disk, and the restore was proven by blob == HEAD with an empty git diff HEAD. Tree status was empty before and after. Each subject is imported from source (./rest-server, ./rest-server.zod), so no dist/ preflight applies. Every direction observed is the expected one, red.

  • Control: rest-openapi-info-overlay.test.ts on the unmutated tree: 11/11 passed.
  • A: the overlay line deleted. The mutation was enriched.info = RestServer.overlayDocumentationInfo(…); → a comment. Blob 3f01f03a → 5c7dde2c. Result: 9 failed | 2 passed. The two survivors are exactly the nothing-authored controls. Restored (blob == HEAD).
  • B: the helper writes into the cached artifact. The mutation was return { ...bundled, ...authored }; → return Object.assign(bundled ?? {}, authored);. Blob 3f01f03a → 662c8f32. Result: 3 failed | 8 passed: the cache-unchanged assertion in (1), and the positive controls of (4) and of partial-contact, whose artifact licence URL / contact name had been overwritten. Restored.
  • C: the tombstone neutralised. The mutation was version: retiredKey( → version: z.string().optional() ?? retiredKey(. Blob e69191ec → 1acff1f8. Result on the spec retirement test: 5 failed | 18 passed, which are the three parse doors, the whole-config door and the tsc pin's parse leg. Restored.
  • D: cross-package type proof. I planted const probe20294: RestApiConfig = { documentation: { title: 'Acme', version: '2.3.0' } } in packages/rest/src and ran pnpm --filter @objectstack/rest exec tsc --noEmit. It exited 1 with src/zz-typeproof-20294.ts(3,76): error TS2322: Type 'string' is not assignable to type 'undefined' and 0 errors elsewhere. That proves rest's tsc reads the rebuilt spec .d.ts. The probe was then removed and its absence confirmed.

Acceptance notes

  • Stale comments outside the fenced region, not edited. The dispatch fences rest-server.ts to the registerOpenApiEndpoints region, so two comments are left untouched:

    Neither comment is emitted into a published .d.ts: the type is module-local, and the method is private. The one-line fix is to append "(none since rest: the served OpenAPI document's info block comes from api.documentation (9 keys) #20294 made title optional)" to the first and "(until rest: the served OpenAPI document's info block comes from api.documentation (9 keys) #20294's overlay)" to the second. Carrier: whoever next touches either region.

  • Two title literals not covered by this ruling:

    • The /docs viewer page's HTML title stays the literal ObjectStack API Docs. It is the browser-tab title of the Scalar page, not an info field. The viewer renders the served document, overlaid info.title included, in the page body. No declared contract ties the tab to info.title, and no key authors it, so this is polish (class a/b/c: none). Carrier: none.
    • OpenApiGenerationConfigSchema.title (packages/spec/src/api/plugin-rest-api.zod.ts) carries a third 'ObjectStack API' default. It is dormant: 0 non-test readers under packages/** (only migration-entry prose names the schema), against a lit control (RestApiConfigSchema found in rest-server.ts). It has no runtime parser (packages/rest declares its own RestApiPluginConfig interface) and no ledger enrolment. Not filed; carrier: none.
  • Release-owned files, not edited: packages/rest/CHANGELOG.md and packages/spec/CHANGELOG.md. The changeset is their input. The pending .changeset/14640-rest-api-liveness-ledger.md still describes documentation as changing nothing, which was true when it landed. The release compiles it next to this changeset.

  • Reachability is unchanged: RestServerConfig is embedder-only. os serve forwards only enableProjectScoping / projectResolution, so every CLI-started deployment serves the same document as before.

  • Governed surfaces: none touched (no .claude/**, docs/adr/**, skills/**, AGENTS.md).

Closing target #20359: the seat claimed it for this branch, closure claim 5878320739 on #20359. That is the ruling carrier; its ruling 5862477514 orders this PR to close it.


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/rest_api.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 4 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v12.mdx (via RestApiConfigSchema (symbol, a top-level const), RestServer (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via RestServer (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via RestServer (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/rest_api.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1 (route, 89 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 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 0bbe4005e82fce2058238720cac7ba5cd2f182d5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6a9004d691727794fdf9672010e1589148233f92 — the merge of head 8e26fbbe887a339ca71d602db5dc46171281a2e2 into base 0bbe4005e82fce2058238720cac7ba5cd2f182d5, 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 6a9004d691727794fdf9672010e1589148233f92 && git checkout 6a9004d691727794fdf9672010e1589148233f92
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0bbe4005e82fce2058238720cac7ba5cd2f182d5 8e26fbbe887a339ca71d602db5dc46171281a2e2 && git checkout -B drift-repro 0bbe4005e82fce2058238720cac7ba5cd2f182d5 && git merge --no-ff 8e26fbbe887a339ca71d602db5dc46171281a2e2

node scripts/docs-audit/affected-docs.mjs --json 0bbe4005e82fce2058238720cac7ba5cd2f182d5

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 69b4d20391b1b2170d3b1c482859b81d0887647c
Local-runs: none

Inputs: card #20294 (body and all nine comments — triage 5859594397, seat-1 claim 5861942248, dev report 5862210322, release 5862232313, transition 5862356883, unblock 5862495365, this seat's claim 5876330236, dev report 5878278933); #20359 (body, ruling 5862477514, closure claim 5878320739); PR #20512 body, 16-file list and the diff at this head; the head's 40 check-runs; the spec-property-retirement skill; origin/main at 9449512a. Nothing built, run or re-run; git merge-tree and the record-template render are the only local commands.

① Derived judgments

Accept set (RestApiConfigSchema / RestServerConfigSchema, @objectstack/spec):

  1. documentation.version → retiredKey() tombstone: input never, parse raises the prescription at ['documentation','version'], RestServer construction and the plugin start refuse through api. Right — ruling pin (3); route 1 of the skill §2 for a non-strict inline z.object(), where a bare deletion would have stripped in silence. Only the retired member is diagnosed; api.version (the route identifier) is untouched — pinned both ways.
  2. documentation.title .default('ObjectStack API') → .optional(). Right — ruled; the input accept set is unchanged, the parsed output widens to string | undefined, parse({ documentation: {} }) yields {}; the two default pins (spec CONTROL block, rest §D) are flipped with the new substance asserted.
  3. The eight enforced members change describes only; license.name stays required. Right — the ruling changed no other shape.
  4. Tombstone message (verbatim in the schema, the mdx [REMOVED] row and the D3 replacement): opens with the backticked fully-qualified key; was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove); the dash clause says why it was inert; Delete the key. plus the mechanism that does take effect. No os migrate meta sentence — right: no D2 conversion covers it, the enabled precedent's message has none either, and the migrate-sentence pin judges only prescriptions that name the command.
  5. Truth of the message: on main, build-openapi.ts:30 sets const SPEC_VERSION = pkg.version and :139 writes version: SPEC_VERSION, so the served info.version is the @objectstack/spec package version (17.4.0); kernel/protocol-version.ts:18 PROTOCOL_VERSION = '17.0.0' is a different number. The message's "the protocol version, i.e. the version of the @objectstack/spec package that generated the document, which no deployment configuration overrides" is TRUE of the served value — the "i.e." clause is what makes it true — and it does what the ruling asked: one named source, an app's release number sent to description. Right.

Public surface (the served OpenAPI document, @objectstack/rest):
6. Overlay location: private static overlayDocumentationInfo(bundled, documentation) directly above registerOpenApiEndpoints; one handler line enriched.info = RestServer.overlayDocumentationInfo(enriched.info, this.config.api.documentation) before res.json. registerRoutes → registerForBase(bp) → registerOpenApiEndpoints(bp) once per base, so the same handler body is registered on {apiPath}/openapi.json and the environment-scoped twin (one closure per door, identical code); both doors carry the overlay and the pin drives each door's own handler. Right.
7. Semantics: title / description / termsOfService per key (!== undefined); contact / license copied whole into authored and spread over bundled at the top level, so an authored container replaces the bundled one whole — license: { name: 'MIT' } serves no url (pin 4), a partial contact keeps nothing of ObjectStack's. Closed member list, no spread of the block; version is never read; api.version and OS_RUNTIME_VERSION never enter info.version (sentinel pin, with the overlay as positive control). Right.
8. Cache. loadOpenApiSpec() memoises the parsed artifact in _openApiSpecCache; the handler's enriched is a shallow clone, so enriched.info IS the cached object. The helper returns bundled itself when !documentation or when authored is empty (nothing authored ⇒ byte-identical; the #11646 whole-block pin in rest-openapi-route.test.ts is not in the file list and stays as the control), otherwise { ...bundled, ...authored } — a new object; nothing writes into bundled. Verified from the code. Ablation leg B (Object.assign(bundled ?? {}, authored)) read against the pin's assertions would redden exactly three cases — pin (1)'s cache-unchanged / not.toBe case, pin (4)'s positive control (artifact.info.license.url truthy, which door 1 would have overwritten) and the partial-contact control (artifact.info.contact.name) — while the one-key toEqual({ ...artifact.info, [key]: value }) cases, the nothing-authored cases and the sentinel case stay green: 3 red / 8 green, as reported. Right (read off the assertions, not run).
9. Pins (1)–(4): each per door in rest-openapi-info-overlay.test.ts, with anti-vacuity (every authored value differs from the artifact's; the served key set equals the artifact's plus termsOfService); (3) at parse in spec (three spellings, the whole-config door, an @ts-expect-error pin held by check:test-typecheck) and at construction / plugin-start in rest. Right.

Retirement route (skill §2–§4), against the enabled precedent (#20295, 26daf0b0):
10. RETIRED_KEYS_BY_MAJOR[18] exact key api/RestApiConfig:documentation.version (entries/retired-keys/18.api__RestApiConfig__documentation.version.ts, the precedent's naming); D3 rest-api-documentation-version-retired (entries/semantic/…, non-empty reason / acceptanceCriteria, prose surface); registry.ts regenerated (check:migration-registry in Lint & Repo Gates: success); no D2 conversion and no conversionIds entry — right: plugin TS configuration has no stored source to rewrite (skill §3, last bullet), the precedent took the same shape, and the registration test pins the absence. The ruling's family D3 entry for rest-api-documentation: present. Right.
11. Ledger rest_api.json: 10 rows flip dead → live (title, description, termsOfService, contact + name/url/email, license + name/url), each with evidence = rest-server.ts#overlayDocumentationInfo plus the per-door pin, producer = #normalizeConfig, verifiedAt 2026-09-28, in-repo. The gate counts 24 rows (a container declaring children is not itself counted), so state-counts.md moves by the eight leaf members: rest_api 12/12 → 20/4, totals 940/148 → 948/140 — exactly the ruling's set. The four remaining dead are requireAuth, responseFormat, documentation.enabled, documentation.version. The version row is kept dead with a REMOVED note (tombstone route ⇒ the row stays, skill §2 table), re-dated, evidenceScope: cross-repo naming objectui at pin dd3f7e1b — the same shape as the enabled row (cross-repo, objectui f8a9d0fb). The _note addendum and the README's hand-written rest_api row ("Live 20 = … Dead 4 = …") agree. Spec property liveness: success. Right.
12. Generated: rest-server.mdx (title without default, [REMOVED] version row, the new describes); Type Check · source gates (check:docs, check:authorable-surface, check:spec-changes, check:upgrade-guide): success; Build Docs: success. The tree-scoped absence walk is widened to documentation.version with anti-vacuity in .ts / .json / .yaml and the two must-not-match neighbours (api.version beside documentation; the enforced members alone). Right.

The diff implies no accept-set or surface change beyond these; build-openapi.ts, rest-openapi-route.test.ts and every other rest-server.ts region are untouched.

② Semver level

.changeset/20294-openapi-info-publisher-overlay.md: @objectstack/spec: minor, @objectstack/rest: minor; feat(spec,rest)!: summary; **BREAKING** banner; FROM → TO with the one-line fix (delete the key; the release number goes into description); the ADR-0087 disposition marker adr-0087: registered rest-api-documentation-version-retired as an HTML comment in the changeset body, where check:adr-0087-registration reads it.

Matches what the diff publishes: spec refuses a formerly accepted input (documentation.version) and widens a parsed output type; rest changes a served document and refuses a formerly accepted construction argument. AGENTS.md: Clause-②: yes takes at least minor, (narrowing) is BREAKING and is carried by the banner, and major is refused by check-changeset-no-major — so minor is both the floor and the ceiling for both packages; they sit in one fixed group, so @objectstack/rest rides with spec whatever its own policy says, and the #20295 precedent changeset has the identical shape. breakingDeclaration reads the banner, the bang and the narrowing arm. Check Changeset (including the ADR-0087 disposition step): success on both runs.

Clause-②: yes (narrowing) in the PR body and in the changeset — the ruling's yes, with the one arm the closed pair allows: a formerly accepted input is refused. The served-document widening (owner-set termsOfService / contact.email now appear) is the behaviour change the yes declares, not a second arm. Right. The pending .changeset/14640-rest-api-liveness-ledger.md still says documentation changes nothing — release-owned, compiled beside this one; not this PR's to edit.

③ Boundary flags

  • Closing targets. Body: Fixes #20294, Fixes #20359. The ruling orders the landing PR to close [Decision] May an app owner put their own title, description, version, contact and license on the API document ObjectStack serves (/openapi.json)? #20359; the seat's closure claim 5878320739 on [Decision] May an app owner put their own title, description, version, contact and license on the API document ObjectStack serves (/openapi.json)? #20359 names Branch: claude/issue-20294-openapi-info-overlay. The guard "The card this PR closes must claim this branch" failed on run 36481623553 (20:47Z, before the claim at 20:53Z) and passed on run 36482521157 after the one body edit — the newest run on this head governs. Part-of PR must not also close its card, No other open PR may claim the same issue / …the same single-writer path: success. The dev's open question (A/B/C): answered — A was taken. Resolved.
  • rest-server.test.ts fixtures (two documentation.version: '1.0.0' authors dropped, one comment updated): inside the route. The tombstone makes those fixtures a tsc and a parse error; the skill (§1) says the tombstone finds the authoring sites and each stops authoring — a pin that authors the retired key moving to the tombstone is the kit, not a widening. Accepted.
  • Two stale comments outside the fence (NormalizedRestServerConfig.api.documentation at :1116–1120; the parseDeclaredApiConfig docblock's [finding] RestServer.normalizeConfig still discards the parsed api output — its ?? chain duplicates RestApiConfigSchema's defaults key for key, and the validate-only reason has expired #14366 paragraph at :4112–4118). Leaving them is acceptable under the fence — the claim forbids every other rest-server.ts region, and the dev reported both with one-line fixes. On the d.ts question the dev's report is half right: the :1116 comment sits inside the non-exported type NormalizedRestServerConfig, referenced only from private members whose types are erased — it does not ship. The :4112 paragraph sits inside the one contiguous JSDoc block that heads private parseDeclaredApiConfig(; TypeScript's declaration emit keeps a private member's JSDoc on its private name; stub (checked on a tsc-emitted d.ts present on this machine: a documented private insertLegacyPlugins; stub in @oclif/core), the repo sets no removeComments / stripInternal, and rest builds dist/index.d.ts through tsup dts: true — so that paragraph ("zero read sites outside this block … nothing observes it today", false since this change) most likely ships as a comment on a private stub in @objectstack/rest's published typings. Reach is nil (a private stub is not a consumer-facing signature and the text is prose, not a prescription): not a class a/b/c finding and not a FAIL, but the dev's "neither ships" claim is wrong, and the carrier should be named instead of "whoever next touches": the seat carries the two one-liners in the next rest-server.ts PR it dispatches, or as a follow-up on this branch after this record.
  • Gates the dev left UNRUN / red. check:dual-build-cjs-loads → Build Core: success. check:type-check-debt → Type Check · debt ledger: success. check:platform-checklist → outside per-PR CI by design (lint.yml), red on main since 7d630889 (identity-auth.json → auth-plugin.ts#twoFactor), 0 diff lines here under docs/qa/** or packages/plugins/plugin-auth/** — inherited, not this PR's; the dev filed it as class a with a named producer; the brief names [finding] check:platform-checklist is red on main: identity-auth.json anchors auth-plugin.ts#twoFactor, which #20429 turned into an inline nested key #20464 as its carrier, which lies outside this review's inputs and is not verified here.
  • Check-runs on the head (40): all success or skipped except the superseded guard run above and four still in progress at read time — Test Core (3/6), Test Core (4/6), Test Core (5/6) (sharded by package, so the spec / rest suites may sit in an unfinished shard) and Type Check · workspace (hosts turbo run typecheck: rest's and spec's tsc --noEmit + check:test-typecheck, the run that proves the @ts-expect-error pin bites). Judged on the rest; the dev's local greens for those four families (rest 4185, spec 16794 + 697 passed; both typechecks exit 0) are the dev's word, not a gate verdict.
  • out_of_scope_findings. (i) the platform-checklist red: class a as filed, inherited — above. (ii) the /docs viewer page's HTML title element ObjectStack API Docs (:5135): the browser-tab text of the Scalar page, not an info field; no declared contract, no authoring key — polish; not a/b/c; no carrier owed. (iii) OpenApiGenerationConfigSchema.title's third 'ObjectStack API' default (plugin-rest-api.zod.ts:555; its only non-test readers are its own type exports and the openApi: slot at :711; enrolled in no ledger): dormant — no runtime parser, no producer, no stored metadata, so not (c); a census-enrolment observation that belongs on the [Decision] Route declared≠enforced work by the SEAM, not the layer — a Seam: line on filing, vertical dispatch by default in the spec lane, automatic parent + sub-issues for spec↔objectui seams, Journey as a filter, bulk retirement per spec family, Console Pin Gate back to required (the maintainer's 「同意」 on the five-line batch, 2026-09-18) #18900 sweep's closing card if anywhere, not a carrier PR. (iv) the two stale comments: judged above; carrier: the seat.
  • Process deviations (container restart with a full re-run at 69b4d203, verify-lock queue timeouts, model-free commit trailers, client / client-react built for check:skill-examples): no contract effect.
  • Fence. Governed surfaces untouched (no .claude/**, docs/adr/**, skills/**, AGENTS.md); build-openapi.ts untouched; packages/runtime untouched.
  • Merge. git merge-tree --write-tree --name-only origin/main 69b4d203 → exit 0, tree d7d4b67a210b4e089819c432329ca8564d639315, no conflicted paths, with origin/main at 9449512a (seven commits past the dev's merge-base 2b24b8b8, including fix(rest, runtime): the dispatcher serves the layered view on both spellings, as RestServer does (#20478) #20505's rest-server.ts edit in another region).

Implemented-by: claude/issue-20294-openapi-info-overlay
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Regen-provenance: 5878749194 · 69b4d20391b1b2170d3b1c482859b81d0887647c → 8e26fbbe887a339ca71d602db5dc46171281a2e2 · pnpm --filter @objectstack/spec gen:liveness-counts && git diff --name-only → (empty)

domain:spec seat 4 (session_01ARcDurZ5j34RdqsGgc4jgH), 2026-09-28T22:21Z. The at-tier PASS 5878749194 was recorded at 69b4d203. This hop is a merge of origin/main 0bbe4005 (#20511) through os-regen-merge.sh as 0945eb21, plus 8e26fbbe, which regenerates packages/spec/liveness/state-counts.md on the merged tree. Nothing was resolved by hand (dev report on #20294).

Seat's check of the committed trees:

domain:spec seat 4 · #18917 · session_01ARcDurZ5j34RdqsGgc4jgH

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 22:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 80153f5 Sep 28, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20294-openapi-info-overlay branch September 28, 2026 23:04
This was referenced Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants