Skip to content

Commit 80153f5

Browse files
feat(spec,rest)!: the served OpenAPI info carries the publisher's api.documentation identity; api.documentation.version retired (#20512)
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 #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`): - A pure `private static overlayDocumentationInfo(bundled, documentation)` sits directly above the method. It returns `bundled` itself when nothing is authored and a new object otherwise, with a closed member list: no spread of the block. - One handler line, `enriched.info = RestServer.overlayDocumentationInfo(…)`, runs before `res.json`. The same closure serves both doors. - The #11646 comment there is rewritten as "unset = the artifact's; authored = the publisher's; `version` never". - **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: - The `NormalizedRestServerConfig.api.documentation` comment (near `:1116`) still says a written `documentation` "arrives with its OWN declared inner defaults applied (`.title`)". There are none now. - The `parseDeclaredApiConfig` docblock's #14366 paragraph (near `:4112`) says `documentation` has "zero read sites outside this block". Since this change it has one, the overlay. 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 #20294 made `title` optional)" to the first and "(until #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](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e1689f commit 80153f5

16 files changed

Lines changed: 777 additions & 102 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
---
5+
6+
feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294)
7+
8+
Clause-②: yes (narrowing)
9+
10+
**BREAKING** — shipped as `minor` under the launch-window convention
11+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
12+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
13+
never by the level). The breaking half is one key: `api.documentation.version`.
14+
15+
`RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine
16+
members, and `RestServer` parsed them, copied them into its config — and never
17+
read them back. Measured before this change, with every member authored: both
18+
doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its
19+
environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9
20+
honoured. ADR-0049 enforce-or-remove, split by who owns each field:
21+
22+
- **Enforced — the publisher's identity.** `title`, `description`,
23+
`termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` /
24+
`url`) now overlay the served `info` on both doors. A member you leave unset
25+
keeps the bundled value, and a config with nothing authored — no block,
26+
`documentation: {}` — serves `info` byte-identical to
27+
`@objectstack/spec/openapi.json`, exactly as before. `contact` and `license`
28+
replace the bundled object **whole**: `license: { name: 'MIT' }` serves
29+
`{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a
30+
partial `contact` never keeps ObjectStack's name or URL.
31+
- **Retired — `documentation.version`.** The served `info.version` is the
32+
protocol version, the version of the `@objectstack/spec` package that generated
33+
the document, with no configured override: an earlier ruling made it equal the
34+
published artifact's so an integrator can read which protocol version they are
35+
talking to. A publisher-set version would give the field a third meaning, so
36+
the key is now refused.
37+
38+
```
39+
FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } })
40+
-> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API'
41+
and info.version = the spec version — both authored values ignored
42+
TO -> throws: REST API configuration is invalid: `api` does not satisfy
43+
`RestApiConfigSchema` …
44+
- api.documentation.version: `api.documentation.version` was removed in
45+
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish
46+
your app's own release number, write it into `api.documentation.description`, …
47+
48+
FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } })
49+
-> GET /api/v1/openapi.json: info.title 'ObjectStack REST API'
50+
TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door)
51+
52+
FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation
53+
-> { title: 'ObjectStack API', description: 'd' } // a default no document ever served
54+
TO -> { description: 'd' }
55+
```
56+
57+
**Fix.** `api.documentation.version` → delete the key. The served
58+
`info.version` is always the protocol version; to publish your app's own release
59+
number, write it into `api.documentation.description`. `tsc` refuses the key at
60+
the authoring site (its input type is `never`), and `RestServer` construction and
61+
the REST plugin's `start` refuse it with that prescription.
62+
63+
**What else changes.** `documentation.title` is `.optional()` instead of
64+
`.default('ObjectStack API')`: that default was materialized into every present
65+
block and never served, so the parsed block now carries exactly what was
66+
authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still
67+
never reach `info.version`. A host that authors none of these keys — every
68+
CLI-started deployment, since `os serve` forwards only `enableProjectScoping`
69+
and `projectResolution` — serves the same document as before.
70+
71+
### The kit
72+
73+
- **Schema.** The eight identity members carry describes naming the served
74+
`info` field; `version` is a `retiredKey()` tombstone inside the live
75+
`documentation` block (a non-strict `z.object()`, so a bare deletion would have
76+
stripped it in silence), next to the `enabled` tombstone.
77+
- **REST server.** `registerOpenApiEndpoints` builds `info` through a pure
78+
helper that returns a NEW object — the cached artifact's own `info` is never
79+
written — and the same handler serves both doors.
80+
- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains
81+
`api/RestApiConfig:documentation.version`; the D3 entry
82+
`rest-api-documentation-version-retired` carries the prescription to
83+
`os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig`
84+
is plugin TS configuration, never a stack collection member or a stored row.
85+
- **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and
86+
the `contact` / `license` containers flip to `live` with the overlay as
87+
evidence; the `version` row stays `dead` with a REMOVED note. The generated
88+
`state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the
89+
`rest-server` reference page is regenerated.
90+
91+
<!-- adr-0087: registered rest-api-documentation-version-retired -->

‎content/docs/references/api/rest-server.mdx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,20 +250,20 @@ const result = BatchEndpointsConfigSchema.parse(data);
250250
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
251251
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
252252
| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
253-
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
253+
| **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version |
254254
| **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. |
255255

256256
### Nested Shape: `RestApiConfig.documentation`
257257

258258
| Property | Type | Required | Description |
259259
| :--- | :--- | :--- | :--- |
260260
| **enabled** | `never` | optional | [REMOVED] `api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: whether the server publishes its OpenAPI document is decided by the sibling `api.enableOpenApi` at the mount, so `enabled: false` turned nothing off. Delete the key; `api.enableOpenApi: false` is the switch that leaves the `/openapi.json` document and its `/docs` viewer unmounted. |
261-
| **title** | `string` | optional (default: `"ObjectStack API"`) | API documentation title |
262-
| **description** | `string` | optional | API description |
263-
| **version** | `string` | optional | Documentation version |
264-
| **termsOfService** | `string` | optional | Terms of service URL |
265-
| **contact** | `{ name?: string; url?: string; email?: string }` | optional | |
266-
| **license** | `{ name: string; url?: string }` | optional | |
261+
| **title** | `string` | optional | Title of the served OpenAPI document (`info.title`); unset keeps the bundled title |
262+
| **description** | `string` | optional | Description of the served OpenAPI document (`info.description`); unset keeps the bundled description. Your app's own release number belongs here |
263+
| **version** | `never` | optional | [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. |
264+
| **termsOfService** | `string` | optional | Terms-of-service URL of the served OpenAPI document (`info.termsOfService`); unset serves none |
265+
| **contact** | `{ name?: string; url?: string; email?: string }` | optional | Contact of the served OpenAPI document; replaces the bundled `info.contact` whole, so a member left out is absent rather than inherited. Unset keeps the bundled contact |
266+
| **license** | `{ name: string; url?: string }` | optional | License of the served OpenAPI document; replaces the bundled `info.license` whole, so a license without `url` serves no URL. Unset keeps the bundled license |
267267

268268

269269
---
@@ -298,7 +298,7 @@ const result = BatchEndpointsConfigSchema.parse(data);
298298
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
299299
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
300300
| **requireAuth** | `never` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 17. Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
301-
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
301+
| **documentation** | `{ title?: string; description?: string; termsOfService?: string; contact?: object; … }` | optional | Publisher identity of the served OpenAPI document: each member set here overlays its `info` on both /openapi.json doors, and nothing set serves the bundled `info` unchanged. `info.version` is always the protocol version |
302302
| **responseFormat** | `never` | optional | [REMOVED] `api.responseFormat` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — nothing ever read it: `envelope`, `includeMetadata` and `includePagination` were parsed, defaulted and copied into the REST server's config and never consulted, so `envelope: false` unwrapped no response. Delete the key. Response shapes are fixed, not a server-wide option: each route answers in the response schema `@objectstack/spec/api` declares for it, which is what the client SDK parses and the served /openapi.json describes, so no configuration changes them. |
303303

304304
### Nested Shape: `RestServerConfig.crud`

‎packages/rest/src/rest-api-config-dead-keys-refused.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
* (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s
1515
* warn-and-ignore `.omit()`.
1616
*
17+
* [#20294] `api.documentation.version` joined them (ruling B on #20359): the
18+
* block's identity members are enforced now — they overlay the served OpenAPI
19+
* `info` — and `version` is the one member retired, because the served
20+
* `info.version` is the protocol version (#11646).
21+
*
1722
* ⛔ ANTI-VACUITY — the same rule as `rest-config-parse-not-cast.test.ts`: a pin
1823
* asking the SCHEMA whether it refuses is `packages/spec`'s job
1924
* (`rest-api-config-dead-keys-retirement.test.ts`). Every case below drives the
@@ -94,6 +99,8 @@ const RESPONSE_FORMAT_PRESCRIPTION =
9499
/`api\.responseFormat` was removed in @objectstack\/spec 17\.5\.0.*Delete the key\..*Response shapes are fixed/s;
95100
const DOCS_ENABLED_PRESCRIPTION =
96101
/`api\.documentation\.enabled` was removed in @objectstack\/spec 17\.5\.0.*Delete the key; `api\.enableOpenApi: false` is the switch/s;
102+
const DOCS_VERSION_PRESCRIPTION =
103+
/`api\.documentation\.version` was removed in @objectstack\/spec 17\.5\.0.*`info\.version` has one source: the protocol version.*Delete the key\. To publish your app's own release number, write it into `api\.documentation\.description`/s;
97104

98105
describe('[#20295] RestServer construction refuses the retired `api` keys', () => {
99106
it('refuses `api.responseFormat` — every former spelling, the old defaults and the empty block included', () => {
@@ -139,6 +146,39 @@ describe('[#20295] RestServer construction refuses the retired `api` keys', () =
139146
});
140147
});
141148

149+
describe('[#20294] RestServer construction refuses the retired `api.documentation.version`', () => {
150+
// Ruling B on #20359: the identity members of `documentation` are enforced
151+
// (they overlay the served `info` — `rest-openapi-info-overlay.test.ts`),
152+
// `version` is retired because the served `info.version` is the protocol
153+
// version (#11646). An authored one used to be accepted and ignored.
154+
it('refuses it — with the enforced siblings beside it undiagnosed', () => {
155+
const message = refusal({ documentation: { title: 'Acme Orders API', description: 'd', version: '2.3.0' } });
156+
expect(message).toContain(' - api.documentation.version: ');
157+
expect(message).toContain('RestApiConfigSchema');
158+
expect(message).toMatch(DOCS_VERSION_PRESCRIPTION);
159+
// Only the retired member is diagnosed — no issue line locates an
160+
// enforced sibling (the prescription itself NAMES
161+
// `api.documentation.description`, as the place a release number
162+
// goes, so the check is on the located-issue line, not the word).
163+
expect(message).not.toContain(' - api.documentation.title: ');
164+
expect(message).not.toContain(' - api.documentation.description: ');
165+
// Not the route identifier either: `api.version` is a different key.
166+
expect(message).not.toContain(' - api.version: ');
167+
});
168+
169+
it('the plugin path refuses it too', async () => {
170+
await expect(
171+
createRestApiPlugin({ api: { api: { documentation: { version: '2.3.0' } } } } as never).start!(bootCtx()),
172+
).rejects.toThrow(/api\.documentation\.version.*was removed/s);
173+
});
174+
175+
it('CONTROL: the same block without `version` constructs, and the enforced members pass through', () => {
176+
expect(refusal({ documentation: { title: 'Acme Orders API', description: 'd' } })).toBe('');
177+
const api = normalizedApi(construct({ documentation: { title: 'Acme Orders API', description: 'd' } }));
178+
expect(api.documentation).toEqual({ title: 'Acme Orders API', description: 'd' });
179+
});
180+
});
181+
142182
describe('[#20295] CONTROL: without the retired keys, the server is what it was', () => {
143183
it('the plugin path still boots (the ctx is not what refuses)', async () => {
144184
const ctx = bootCtx();

‎packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535
* ⚠️ This file mocks `@objectstack/spec/api` module-wide, so the schema it
3636
* drives is NOT the shipped one. The complementary pins that need the REAL
3737
* schema — that the shipped defaults are the schema's, that `requireAuth`
38-
* keeps its warn-and-ignore posture, and that the parse's inner defaults now
39-
* reach `documentation` (whose retired `enabled` member, like the retired
40-
* `responseFormat` block, is refused rather than defaulted since #20295) — live in
38+
* keeps its warn-and-ignore posture, and that `documentation` arrives exactly
39+
* as the parse outputs it (with no inner default left since #20294 made
40+
* `title` optional; its retired `enabled` and `version` members, like the
41+
* retired `responseFormat` block, are refused rather than defaulted since
42+
* #20295 and #20294) — live in
4143
* `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked.
4244
*/
4345

‎packages/rest/src/rest-config-parse-not-cast.test.ts‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,22 +334,33 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => {
334334
expect(normalizedApi(rest).enableSearch).toBe(false);
335335
});
336336

337-
it('THE BOUNDED DELTA: an authored `documentation` now carries its own declared inner defaults', () => {
337+
it('THE BOUNDED DELTA: an authored `documentation` arrives as the parse outputs it', () => {
338338
// The single measured behaviour change of #14366, pinned rather than
339339
// left to be rediscovered. The deleted `??` chain copied this object
340340
// through untouched (`documentation: api.documentation`), so a partial
341-
// one stayed partial; the parse fills the inner `.default()`s.
342-
// `documentation` has ZERO read sites outside this block (the #14369
343-
// census), so nothing observes it today — which is exactly why it needs
344-
// a pin: an unobserved change is the kind that gets reverted by
345-
// accident.
341+
// one stayed partial; the parse fills the inner `.default()`s — of
342+
// which, since #20294 (`title` → `.optional()`) and #20295 (`enabled`
343+
// retired), there are none, so a partial block stays partial again,
344+
// now by the schema's word rather than by a cast. Since #20294 this
345+
// block HAS a reader — `registerOpenApiEndpoints` overlays the served
346+
// `info` from it — which is why an invented member here would be
347+
// observable, and why the pin stays.
346348
const doc = normalizedApi(construct({ documentation: { description: 'd' } }))
347349
.documentation as Record<string, unknown>;
348350
expect(doc).toEqual(
349351
(declaredApi().parse({ documentation: { description: 'd' } }) as { documentation: unknown }).documentation,
350352
);
351353
expect(doc.description, 'the authored key survives').toBe('d');
352-
expect(doc.title, 'and the declared inner default arrives with it').toBe('ObjectStack API');
354+
// [#20294] REVERSED by design, not by regression: `documentation.title`
355+
// is `.optional()` now, not `.default('ObjectStack API')`. That default
356+
// was never served, and the served `info` is overlaid from this block
357+
// (`registerOpenApiEndpoints`), so materializing it would retitle the
358+
// document of a host that wrote only `description`. The block now
359+
// carries exactly what was authored — its declared inner defaults are
360+
// none — and an unset title keeps the bundled one
361+
// (`rest-openapi-info-overlay.test.ts`).
362+
expect(doc, 'no title is invented for a host that did not write one').not.toHaveProperty('title');
363+
expect(doc, 'the block is exactly what was authored').toEqual({ description: 'd' });
353364
// [#20295] REVERSED by design, not by regression: `documentation.enabled`
354365
// is a retired tombstone, so the parse no longer materializes its old
355366
// `.default(true)` — the block carries only its live members.

0 commit comments

Comments
 (0)