Skip to content

Commit 26daf0b

Browse files
feat(spec,rest)!: retire api.responseFormat and api.documentation.enabled (ADR-0049) (#20343)
Fixes #20295 Clause-②: no (narrowing) ## Summary Retires two keys of `RestServerConfig.api` (`RestApiConfigSchema`) under ADR-0049 enforce-or-remove — triage's grade, verbatim: 「Verdict: **RETIRE** the 4 keys, by the maintainer's criterion」. - `api.responseFormat` — the whole block (`envelope`, `includeMetadata`, `includePagination`): one `retiredKey()` tombstone for the container. - `api.documentation.enabled` — a tombstone inside the still-live `documentation` block. Its other members (`title`, `description`, `version`, `termsOfService`, `contact`, `license`) are untouched: they belong to the sibling card #20294. Both keys were parsed, defaulted and copied into `RestServer`'s config by `normalizeConfig`, and nothing read them back. `envelope: false` unwrapped no response. `documentation.enabled: false` turned no document off, because `api.enableOpenApi` decides that mount. Now each key carries its prescription at the schema, `RestServer` construction refuses it (so does the REST plugin's `start`), and `normalizeConfig` neither forwards nor re-defaults it. ## Accept / refuse changes — every one pinned | Door | Input | Before | After | Pin | |:--|:--|:--|:--|:--| | `RestApiConfigSchema` | `responseFormat: {…}` — `{ envelope: false }`, the old defaults, `{ includePagination: false }`, `{}` | accepted, inner defaults filled | refused: `invalid_type` at `['responseFormat']`, message = prescription below | `packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts` | | `RestServerConfigSchema` | `api.responseFormat` | accepted | refused at `api.responseFormat`, same prescription | same file | | `RestApiConfigSchema` | `documentation.enabled: false` / `true` (the old default) | accepted (default `true`) | refused: `invalid_type` at `['documentation', 'enabled']`, prescription below | same file | | `RestServerConfigSchema` | `api.documentation.enabled` | accepted | refused at `api.documentation.enabled` | same file | | `new RestServer(…)` and `createRestApiPlugin(…).start` | either key | constructed; the key was ignored | throws; the message names `api.responseFormat` / `api.documentation.enabled`, `RestApiConfigSchema` and the prescription | `packages/rest/src/rest-api-config-dead-keys-refused.test.ts` | | `RestApiConfigSchema` | `documentation: {}` | parsed to `{ enabled: true, title }` | parsed to `{ title }` | spec retirement test (control), `rest-server.test.ts`, `rest-config-parse-not-cast.test.ts` §D | | TypeScript `RestApiConfig` (input) | either key | object / boolean | `never` (a tsc error at the authoring site) | two `@ts-expect-error` pins, held by `check:test-typecheck` | Unchanged, pinned as controls: every live key of the `api` block parses as before, including `documentation`'s other members; a config without the two keys mounts the same route set (`mounted({ documentation: { title, description } })` equals `mounted({})`); `enableOpenApi: false` removes exactly `GET /api/v1/docs` and `GET /api/v1/openapi.json`. ### Refusal texts (verbatim; both are also the new `.describe()` text, prefixed `[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. > `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. Construction refusal (unchanged envelope, new lines): ``REST API configuration is invalid: `api` does not satisfy `RestApiConfigSchema` (@objectstack/spec/api), the schema that declares it.`` followed by ` - api.responseFormat: PRESCRIPTION` (or `api.documentation.enabled`). Describes removed with the keys: "Response format options", "Wrap responses in standard envelope", "Include response metadata (timestamp, requestId)", "Include pagination info in list responses", "Enable API documentation". No `os migrate meta` sentence in either prescription, on purpose: there is no D2 conversion for the command to list (see below), matching the sibling `crud.*` / `metadata.*` / `batch.*` tombstones on this same file. ## Premises measured before editing (origin/main 4e0f72e) 1. **Nothing reads the four keys.** `packages/**` non-test code: 0 reads — the only code sites were `NormalizedRestServerConfig`'s type and `normalizeConfig`'s own write. Lit control on the same instrument: `enableOpenApi` finds its read at `registerRoutes`. A spread / whole-block-destructure sweep over `packages/rest/src` (non-test) returns only the parse call and `const { enableProjectScoping, projectResolution } = this.config.api`. objectui at its pin `f8a9d0fb`: 0 for `RestApiConfig|RestServerConfig`, `responseFormat`, `includePagination`, `enableOpenApi` (control `basePath` = 184). cloud at `96eb092`: 0 authoring sites for the same terms and `documentation.enabled` (control `createRestApiPlugin` = 11; every call forwards the stack's own top-level `api:` block, whose schema carries neither key). 2. **Producers.** None outside the kit: the in-repo authors were four spec test cases, two rest test cases and the `@example` in the schema docblock; all are converted (the example now shows `enableOpenApi`). No example app, skill, form, i18n bundle or hand-written doc authors either key. 3. **Route.** `RestApiConfigSchema` and its inline `documentation` object are non-strict `z.object()`s ⇒ `retiredKey()` tombstones (a bare deletion would strip the key in silence, ADR-0104). Ledger rows stay `dead` with a REMOVED note; `responseFormat`'s three child rows collapse into its one row, because the tombstone is a leaf and child rows would report ORPHAN (the `crud.patterns` precedent). 4. **`normalizeConfig`** no longer lists `responseFormat`; `documentation` still passes through, so the retired `enabled` is neither forwarded nor re-defaulted (pinned). ## Choices settled here (four axes) - **`responseFormat` retired as one container tombstone, not three member tombstones.** Business need: no author and no reader of any member. Long-term: a container whose every member is retired would keep accepting `responseFormat: {}` — an empty knob a reader takes for a capability. AI-safety: one refusal on the key an author actually types; `{}` is refused too. Scope: one tombstone, one ledger row (the `crud.patterns` precedent, which also collapsed child rows). - **The server REFUSES (the `crud.patterns` posture), it does not `.omit()` and ignore (the `requireAuth` posture).** No boot path or shipped config writes either key, so nothing chose warn-and-ignore for them; a silent `.omit()` would recreate the strip this retirement removes. Ablation C below shows the difference is measurable. - **A tree-scoped absence pin was added** (the retirement playbook's default sweep), over the radius `@objectstack/spec` already declares in `scripts/cross-package-test-inputs.mjs`; no new declaration was needed (`check:cross-package-test-inputs` green). Its matcher is structural so that the agent alias map's `responseFormat: 'structuredOutput'` and an OpenAI-style `responseFormat: { type }` never match. ## The retirement kit - **Schema** `packages/spec/src/api/rest-server.zod.ts` — two tombstones with in-schema comments; docblock example converted. - **REST server** `packages/rest/src/rest-server.ts` — only the `NormalizedRestServerConfig.api` type (near the old `:1105`) and the `parseDeclaredApiConfig` / `normalizeConfig` region. None of the regions #20319 edited were touched; this branch was rebuilt on main after #20319 landed. - **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` and `api/RestApiConfig:documentation.enabled` (one entry file each); one D3 entry for the family, `rest-api-config-dead-keys-retired` (ruling B on #17152); no D2 conversion, because a `RestServerConfig` is plugin TS configuration, never a stack collection member or a stored row. The generated regions of `registry.ts` are regenerated. - **Ledger** `packages/spec/liveness/rest_api.json` (both rows REMOVED, `cross-repo` scope, `_note` addendum), `liveness/README.md` row, generated `state-counts.md` (`rest_api` 14 → 12 dead, 26 → 24 classified). - **Generated** `authorable-surface/api.json` (`responseFormat` → `[RETIRED]`), `content/docs/references/api/rest-server.mdx`, `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` (`api/` 432 → 431 sites: the inline `responseFormat` object left). - **Tests** — the two new pin files above (the spec one runs in the `repo` project); converted cases in `rest-server.test.ts`, `rest-config-parse-not-cast.test.ts` §D and one comment in `rest-api-config-defaults-follow-spec.pin.test.ts`. - **Changeset** `.changeset/20295-rest-api-config-dead-keys-retired.md` — `@objectstack/spec` and `@objectstack/rest` `minor`, BREAKING banner, FROM → TO with the one-line fix, and the ADR-0087 disposition `registered rest-api-config-dead-keys-retired`. ## Verification — at HEAD `6f07f0c1` All heavy runs went through `scripts/pm/os-verify-lock.sh`; every exit code below was captured to disk before any pipe. `6f07f0c1` is `origin/main` `a78f731a` merged in (after #20319 landed) through `scripts/pm/os-regen-merge.sh`, then the rest dependency closure rebuilt. - `pnpm --filter @objectstack/rest exec vitest run --project local` — exit 0, 204 files, 3680 passed, 1 skipped. - `pnpm --filter @objectstack/spec exec vitest run --project local` — exit 0, 554 files, 16353 passed, 1 todo. - `pnpm --filter @objectstack/spec exec vitest run --project repo` — exit 0, 34 files, 620 passed (the new tree-scoped pin runs here). - `pnpm --filter @objectstack/spec run typecheck` and `pnpm --filter @objectstack/rest run typecheck` — both exit 0 (`tsc --noEmit` plus `check:test-typecheck`; the spec one also `check:scripts-typecheck`). `check:test-typecheck` green is what proves the two `@ts-expect-error` pins bite: an unused one would be a new TS2578 signature. - `pnpm --filter @objectstack/spec run check:generated` — exit 0, all 15 generated artifacts current at this head. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands for this diff at this head; `--ran` reconciliation: **115 accounted — 113 run, all exit 0; 2 NOT MEASURED; 0 unrun.** - NOT MEASURED `pnpm check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: it reads every package's `dist/`, and a whole-tree build is outside this card's local scope (CI's Build Core builds it). - NOT MEASURED `pnpm check:type-check-debt` — its `--re-measure` rebuilds every package and re-runs tsc over every DEBT / EXEMPT entry; this diff touches no DEBT or EXEMPT package (CI's Lint job). `pnpm check:type-check-coverage` ran, exit 0. - Consumer fixture triage: the tree-scoped absence pin is the sweep — it walks `packages`, `examples`, `skills`, `content` and `scripts` and finds no `api` block authoring either key anywhere (anti-vacuity: more than 1000 files visited, more than 5 of them spell `responseFormat`). The near consumers that parse or type this config were also run: `@objectstack/core` `src/qa/http-adapter.test.ts` (it parses `RestApiConfigSchema.parse({})` for the route prefix) — exit 0, 32 passed; `@objectstack/client` `client.data-prefix.test.ts` + `client.metadata-prefix.test.ts` (they type a `RestServerConfig`) — exit 0, 13 passed. The rest of the downstream closure (`pnpm --filter '...^@objectstack/rest'`: cli, runtime, hono, the examples, …) was NOT rebuilt or run locally, by the retirement playbook's rule that the absence pin, not a consumer-closure rebuild, is the sweep; CI runs them. ### Reverse verification (one-time; each mutation through `scripts/ablation-replace.mjs`, restored and proven by blob == HEAD and an empty `git diff HEAD`) Run at `2e575f15` (this branch before the second merge of main; the merge touched none of the three mutated files). Every direction observed is the expected one: red. - **A — the schema tombstone.** `responseFormat: retiredKey(` → `responseFormat: z.any().optional() ?? retiredKey(` in `rest-server.zod.ts` (anchor 1 → 0, blob `32ebfe55` → `bfbb9755`). Spec retirement test: **6 failed | 10 passed** — the four `responseFormat` door pins, the whole-config door, and the tsc pin's parse leg. Restored (blob == HEAD); control rerun 16/16. - **B — the tree-scoped pin.** First attempt was a NO-OP and is not counted: its replacement contained the anchor, so `ablation-replace` refused (anchor 1 → 1), restored, and never ran the test. Redone with a disjoint spelling: `responseFormat: { envelope: false } as never,` planted before `enableDiscovery: true,` in `packages/spec/src/api/rest-server.test.ts` (anchor 1 → 0, blob `794c1326` → `0766f4e0`). **1 failed | 15 passed** — the absence pin, naming `packages/spec/src/api/rest-server.test.ts:644`. Restored (blob == HEAD). - **C — the server's parse.** `RestApiConfigSchema.omit({ requireAuth: true })` → `.omit({ requireAuth: true, responseFormat: true })` in `rest-server.ts` — the silent-strip posture (anchor 1 → 0, blob `abf25fa0` → `e93b894a`). Rest refusal test: **3 failed | 5 passed** — the `responseFormat` refusal, its no-`api.version` positive control and the plugin path; the `documentation.enabled` pins stay green, as they must. Restored (blob == HEAD); control rerun 8/8. After all four legs: `git diff HEAD` empty, each blob equal to its HEAD blob. The rest test's subject is `./rest-server.ts` (source, no alias hop), and the spec test's subject is `./rest-server.zod.ts` (source), so no `dist/` preflight applies. ## Acceptance notes - **`packages/rest/CHANGELOG.md`** is in the card's file surface but is release-owned (AGENTS.md Documentation Guardrails): not edited; the changeset is its input. - **`.changeset/14640-rest-api-liveness-ledger.md`** (pending, another card's) says `documentation` and `responseFormat` "are accepted, validated and normalized, and change nothing" — true when it landed; not edited here. The release compiles it next to this changeset. - **Clause-② value.** The claim carried `Clause-②: yes`, from triage's execution note. Measured on this diff: no accept set widens and no export is added; the only additions are ADR-0087 ledger registrations, which the two nearest retirements (#20227, #20238) declared under `no`. On that measurement the seat answered `no (narrowing)`, and this body and the changeset carry it. The gate readings are unchanged (`minor`, declared breaking, `registered`). - **Observed, not filed (dormant):** `RestApiPluginConfigSchema.responseEnvelope` in `packages/spec/src/api/plugin-rest-api.zod.ts` is a second declared envelope toggle. The spec schema has no runtime parser (`packages/rest` declares its own `RestApiPluginConfig` interface) and is not enrolled in any liveness ledger. No reach was measured, so nothing is filed. Carrier: none. - No governed surface is touched (`docs/audits/**` is not on the register). --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f39ea95 commit 26daf0b

19 files changed

Lines changed: 1002 additions & 108 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
---
5+
6+
feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295)
7+
8+
Clause-②: no (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).
14+
15+
Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given
16+
defaults and copied into the REST server's config by `normalizeConfig` — and no
17+
site ever read them back. `responseFormat` (`envelope`, `includeMetadata`,
18+
`includePagination`) toggled nothing: `envelope: false` unwrapped no response.
19+
`documentation.enabled` was a second on/off switch for the OpenAPI document that
20+
nothing consulted: `api.enableOpenApi` decides that mount. Measured before
21+
removal, each against a lit control on the same instrument: no reader in this
22+
repo's packages, and no author in objectui at its pinned commit or in cloud.
23+
ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion —
24+
mainstream data APIs keep a fixed response envelope that no administrator toggles
25+
server-wide, and the OpenAPI switch already exists and is enforced.
26+
27+
```
28+
FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } })
29+
-> constructed; `envelope: false` changed nothing
30+
TO -> throws: REST API configuration is invalid: `api` does not satisfy
31+
`RestApiConfigSchema` …
32+
- api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0
33+
(ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, …
34+
35+
FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } })
36+
-> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway
37+
TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'],
38+
message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049
39+
enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' }
40+
```
41+
42+
**Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so
43+
there is nothing to configure. `api.documentation.enabled` → delete the key; to
44+
serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves
45+
`GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the
46+
authoring site (their input type is `never`).
47+
48+
**What does not change.** Every live key of the `api` block parses exactly as
49+
before, including the rest of `documentation` (`title`, `description`,
50+
`version`, `termsOfService`, `contact`, `license` — a separate decision). A
51+
config without the two keys mounts the same REST surface: neither key ever
52+
reached it. A `documentation` block no longer grows an `enabled: true` default.
53+
54+
### The retirement kit
55+
56+
- **Schema.** `RestApiConfigSchema` and its inline `documentation` object are
57+
non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its
58+
prescription (a bare deletion would have stripped it in silence).
59+
`responseFormat` retires whole — its three members were its only members.
60+
- **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns`
61+
posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key
62+
now fails `RestServer` construction and the REST plugin's `start` with the
63+
prescription, and the normalized config no longer carries or re-defaults them.
64+
- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat`
65+
and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a
66+
`RestServerConfig` is plugin TS configuration, never a stack collection member or
67+
a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries
68+
the prescription to `os migrate meta` and the upgrade guide.
69+
- **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a
70+
REMOVED note (`responseFormat`'s three child rows collapse into its one row);
71+
the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the
72+
reference page for `rest-server` is regenerated.
73+
74+
<!-- adr-0087: registered rest-api-config-dead-keys-retired -->

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

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -249,29 +249,21 @@ const result = BatchEndpointsConfigSchema.parse(data);
249249
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
250250
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
251251
| **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. |
252-
| **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
253-
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
252+
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
253+
| **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. |
254254

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

257257
| Property | Type | Required | Description |
258258
| :--- | :--- | :--- | :--- |
259-
| **enabled** | `boolean` | optional (default: `true`) | Enable API documentation |
259+
| **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. |
260260
| **title** | `string` | optional (default: `"ObjectStack API"`) | API documentation title |
261261
| **description** | `string` | optional | API description |
262262
| **version** | `string` | optional | Documentation version |
263263
| **termsOfService** | `string` | optional | Terms of service URL |
264264
| **contact** | `{ name?: string; url?: string; email?: string }` | optional | |
265265
| **license** | `{ name: string; url?: string }` | optional | |
266266

267-
### Nested Shape: `RestApiConfig.responseFormat`
268-
269-
| Property | Type | Required | Description |
270-
| :--- | :--- | :--- | :--- |
271-
| **envelope** | `boolean` | optional (default: `true`) | Wrap responses in standard envelope |
272-
| **includeMetadata** | `boolean` | optional (default: `true`) | Include response metadata (timestamp, requestId) |
273-
| **includePagination** | `boolean` | optional (default: `true`) | Include pagination info in list responses |
274-
275267

276268
---
277269

@@ -305,8 +297,8 @@ const result = BatchEndpointsConfigSchema.parse(data);
305297
| **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs |
306298
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy |
307299
| **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. |
308-
| **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
309-
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
300+
| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config |
301+
| **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. |
310302

311303
### Nested Shape: `RestServerConfig.crud`
312304

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ directory rather than per file.
257257
| Dir | Sites |
258258
|---|---|
259259
| `ai/` | 78 |
260-
| `api/` | 432 |
260+
| `api/` | 431 |
261261
| `identity/` | 32 |
262262
| `integration/` | 8 |
263263
| `kernel/` | 247 |
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20295] The REST server REFUSES the retired `api.responseFormat` and
5+
* `api.documentation.enabled` at construction — ADR-0049 enforce-or-remove.
6+
*
7+
* Before: both were parsed by `parseDeclaredApiConfig`, defaulted, and copied
8+
* into `this.config.api` by `normalizeConfig`, where nothing read them back —
9+
* `responseFormat.envelope: false` unwrapped no response and
10+
* `documentation.enabled: false` turned no document off (`api.enableOpenApi`
11+
* decides that mount). Now both are `retiredKey()` tombstones on
12+
* `RestApiConfigSchema`, and this seam runs that schema, so the tombstone's
13+
* refusal reaches the operator at boot — the `crud.patterns` posture
14+
* (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s
15+
* warn-and-ignore `.omit()`.
16+
*
17+
* ⛔ ANTI-VACUITY — the same rule as `rest-config-parse-not-cast.test.ts`: a pin
18+
* asking the SCHEMA whether it refuses is `packages/spec`'s job
19+
* (`rest-api-config-dead-keys-retirement.test.ts`). Every case below drives the
20+
* REAL `RestServer` construction or the real plugin composition, so what it
21+
* measures is whether the SERVER refuses. `refusal()` answers `''` when
22+
* construction succeeds, and `''` contains no key name, so every `toContain`
23+
* below is its own positive control.
24+
*
25+
* On the assertion set: this is a construction-time refusal, not an HTTP
26+
* answer — nothing is mounted yet, so there is no ADR-0112 `code` / `status`
27+
* envelope to assert. The strongest set this door has is: refused, the located
28+
* key (`api.<path>`), the declaring schema's name, and the prescription.
29+
*
30+
* This file is one of the two the retirement's tree-scoped absence pin
31+
* excludes by name: its JOB is to author the retired keys.
32+
*/
33+
34+
import { describe, it, expect, vi } from 'vitest';
35+
import { RestServer } from './rest-server.js';
36+
import { createRestApiPlugin } from './rest-api-plugin.js';
37+
38+
function makeServer() {
39+
return {
40+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
41+
use: vi.fn(), listen: vi.fn(), close: vi.fn(),
42+
} as any;
43+
}
44+
45+
function makeProtocol() {
46+
return {
47+
getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })),
48+
} as any;
49+
}
50+
51+
/** Construct the real server with `api` as given — the seam under test. */
52+
function construct(api: Record<string, unknown>) {
53+
return new RestServer(makeServer(), makeProtocol(), { api } as any);
54+
}
55+
56+
/** The construction refusal's message, or `''` when the server constructed. */
57+
function refusal(api: Record<string, unknown>): string {
58+
try {
59+
construct(api);
60+
return '';
61+
} catch (err) {
62+
return err instanceof Error ? err.message : String(err);
63+
}
64+
}
65+
66+
/** The normalized `api` block, read off the constructed server. */
67+
const normalizedApi = (rest: unknown) =>
68+
(rest as { config: { api: Record<string, unknown> } }).config.api;
69+
70+
/** The mounted `METHOD path` set of a constructed server. */
71+
function mounted(api: Record<string, unknown>): string[] {
72+
const rest = construct(api);
73+
rest.registerRoutes();
74+
return rest.getRoutes().map((r: any) => `${r.method} ${r.path}`).sort();
75+
}
76+
77+
function bootCtx() {
78+
const services: Record<string, unknown> = { 'http.server': makeServer(), protocol: makeProtocol() };
79+
return {
80+
registerService: vi.fn(),
81+
getService: vi.fn((name: string) => {
82+
if (name in services) return services[name];
83+
throw new Error(`Service '${name}' not found`);
84+
}),
85+
getServices: vi.fn(() => new Map(Object.entries(services))),
86+
hook: vi.fn(),
87+
trigger: vi.fn().mockResolvedValue(undefined),
88+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
89+
getKernel: vi.fn(),
90+
} as any;
91+
}
92+
93+
const RESPONSE_FORMAT_PRESCRIPTION =
94+
/`api\.responseFormat` was removed in @objectstack\/spec 17\.5\.0.*Delete the key\..*Response shapes are fixed/s;
95+
const DOCS_ENABLED_PRESCRIPTION =
96+
/`api\.documentation\.enabled` was removed in @objectstack\/spec 17\.5\.0.*Delete the key; `api\.enableOpenApi: false` is the switch/s;
97+
98+
describe('[#20295] RestServer construction refuses the retired `api` keys', () => {
99+
it('refuses `api.responseFormat` — every former spelling, the old defaults and the empty block included', () => {
100+
for (const responseFormat of [
101+
{ envelope: false },
102+
{ envelope: true, includeMetadata: true, includePagination: true },
103+
{ includeMetadata: false },
104+
{},
105+
]) {
106+
const label = JSON.stringify(responseFormat);
107+
const message = refusal({ responseFormat });
108+
expect(message, label).toContain(' - api.responseFormat: ');
109+
expect(message, label).toContain('RestApiConfigSchema');
110+
expect(message, label).toMatch(RESPONSE_FORMAT_PRESCRIPTION);
111+
}
112+
});
113+
114+
it('refuses `api.documentation.enabled` — both values — while its siblings in the block are untouched', () => {
115+
for (const enabled of [false, true]) {
116+
const message = refusal({ documentation: { title: 'My API', enabled } });
117+
expect(message, String(enabled)).toContain(' - api.documentation.enabled: ');
118+
expect(message, String(enabled)).toContain('RestApiConfigSchema');
119+
expect(message, String(enabled)).toMatch(DOCS_ENABLED_PRESCRIPTION);
120+
// Only the retired member is diagnosed — `title` is not named.
121+
expect(message, String(enabled)).not.toContain('api.documentation.title');
122+
}
123+
});
124+
125+
it('a retired-key refusal never diagnoses `api.version` — a key this config did not write', () => {
126+
const message = refusal({ responseFormat: { envelope: false } });
127+
expect(message, 'positive control: the refusal is present').toContain('api.responseFormat');
128+
expect(message).not.toContain('/api//');
129+
expect(message).not.toContain('api.version');
130+
});
131+
132+
it('the plugin path refuses the same keys (both cast hops)', async () => {
133+
await expect(
134+
createRestApiPlugin({ api: { api: { responseFormat: { envelope: false } } } } as never).start!(bootCtx()),
135+
).rejects.toThrow(/api\.responseFormat.*was removed/s);
136+
await expect(
137+
createRestApiPlugin({ api: { api: { documentation: { enabled: false } } } } as never).start!(bootCtx()),
138+
).rejects.toThrow(/api\.documentation\.enabled.*was removed/s);
139+
});
140+
});
141+
142+
describe('[#20295] CONTROL: without the retired keys, the server is what it was', () => {
143+
it('the plugin path still boots (the ctx is not what refuses)', async () => {
144+
const ctx = bootCtx();
145+
await expect(
146+
createRestApiPlugin({ api: { api: { documentation: { title: 'My API' } } } } as never).start!(ctx),
147+
).resolves.toBeUndefined();
148+
expect(ctx.logger.error).not.toHaveBeenCalled();
149+
});
150+
151+
it('normalizeConfig neither forwards nor re-defaults either retired key', () => {
152+
const api = normalizedApi(construct({ documentation: { title: 'My API' } }));
153+
expect(api).not.toHaveProperty('responseFormat');
154+
expect(api.documentation, 'the live members pass through with their declared defaults only').toEqual({ title: 'My API' });
155+
expect(api.documentation as object).not.toHaveProperty('enabled');
156+
});
157+
158+
it('`api.enableOpenApi` is the OpenAPI switch the prescription names — it really unmounts the document', () => {
159+
const on = mounted({});
160+
const off = mounted({ enableOpenApi: false });
161+
// Positive control: the default mounts both routes the switch owns.
162+
expect(on).toContain('GET /api/v1/openapi.json');
163+
expect(on).toContain('GET /api/v1/docs');
164+
expect(on.filter((r) => !off.includes(r)).sort()).toEqual(['GET /api/v1/docs', 'GET /api/v1/openapi.json']);
165+
});
166+
167+
it('a `documentation` block without `enabled` mounts exactly the default surface — the key never reached it', () => {
168+
expect(mounted({ documentation: { title: 'Renamed', description: 'd' } })).toEqual(mounted({}));
169+
});
170+
});

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
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`
3838
* keeps its warn-and-ignore posture, and that the parse's inner defaults now
39-
* reach `documentation` / `responseFormat` — live in
39+
* reach `documentation` (whose retired `enabled` member, like the retired
40+
* `responseFormat` block, is refused rather than defaulted since #20295) — live in
4041
* `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked.
4142
*/
4243

0 commit comments

Comments
 (0)