diff --git a/.changeset/20294-openapi-info-publisher-overlay.md b/.changeset/20294-openapi-info-publisher-overlay.md new file mode 100644 index 00000000000..d6c9e0acc4c --- /dev/null +++ b/.changeset/20294-openapi-info-publisher-overlay.md @@ -0,0 +1,91 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +--- + +feat(spec,rest)!: the served OpenAPI `info` carries the publisher's `api.documentation` identity; `api.documentation.version` retired (#20294) + +Clause-②: yes (narrowing) + +**BREAKING** — shipped as `minor` under the launch-window convention +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by +this banner, the `(narrowing)` arm above and the ADR-0087 disposition below, +never by the level). The breaking half is one key: `api.documentation.version`. + +`RestServerConfig.api.documentation` (`RestApiConfigSchema`) declared nine +members, and `RestServer` parsed them, copied them into its config — and never +read them back. Measured before this change, with every member authored: both +doors that serve the OpenAPI document (`{apiPath}/openapi.json` and its +environment-scoped twin) answered the bundled artifact's `info` unchanged, 0 of 9 +honoured. ADR-0049 enforce-or-remove, split by who owns each field: + +- **Enforced — the publisher's identity.** `title`, `description`, + `termsOfService`, `contact` (`name` / `url` / `email`) and `license` (`name` / + `url`) now overlay the served `info` on both doors. A member you leave unset + keeps the bundled value, and a config with nothing authored — no block, + `documentation: {}` — serves `info` byte-identical to + `@objectstack/spec/openapi.json`, exactly as before. `contact` and `license` + replace the bundled object **whole**: `license: { name: 'MIT' }` serves + `{ name: 'MIT' }` with no URL, never MIT at the bundled Apache-2.0 URL, and a + partial `contact` never keeps ObjectStack's name or URL. +- **Retired — `documentation.version`.** The served `info.version` is the + protocol version, the version of the `@objectstack/spec` package that generated + the document, with no configured override: an earlier ruling made it equal the + published artifact's so an integrator can read which protocol version they are + talking to. A publisher-set version would give the field a third meaning, so + the key is now refused. + +``` +FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API', version: '2.3.0' } } }) + -> constructed; GET /api/v1/openapi.json served info.title 'ObjectStack REST API' + and info.version = the spec version — both authored values ignored +TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.documentation.version: `api.documentation.version` was removed in + @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … Delete the key. To publish + your app's own release number, write it into `api.documentation.description`, … + +FROM new RestServer(server, protocol, { api: { documentation: { title: 'Acme Orders API' } } }) + -> GET /api/v1/openapi.json: info.title 'ObjectStack REST API' +TO -> GET /api/v1/openapi.json: info.title 'Acme Orders API' (and on the environment-scoped door) + +FROM RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation + -> { title: 'ObjectStack API', description: 'd' } // a default no document ever served +TO -> { description: 'd' } +``` + +**Fix.** `api.documentation.version` → delete the key. The served +`info.version` is always the protocol version; to publish your app's own release +number, write it into `api.documentation.description`. `tsc` refuses the key at +the authoring site (its input type is `never`), and `RestServer` construction and +the REST plugin's `start` refuse it with that prescription. + +**What else changes.** `documentation.title` is `.optional()` instead of +`.default('ObjectStack API')`: that default was materialized into every present +block and never served, so the parsed block now carries exactly what was +authored (the parsed `title` is typed `string | undefined` now). `api.version` (the route identifier) and the runtime version still +never reach `info.version`. A host that authors none of these keys — every +CLI-started deployment, since `os serve` forwards only `enableProjectScoping` +and `projectResolution` — serves the same document as before. + +### The kit + +- **Schema.** The eight identity members carry describes naming the served + `info` field; `version` is a `retiredKey()` tombstone inside the live + `documentation` block (a non-strict `z.object()`, so a bare deletion would have + stripped it in silence), next to the `enabled` tombstone. +- **REST server.** `registerOpenApiEndpoints` builds `info` through a pure + helper that returns a NEW object — the cached artifact's own `info` is never + written — and the same handler serves both doors. +- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains + `api/RestApiConfig:documentation.version`; the D3 entry + `rest-api-documentation-version-retired` carries the prescription to + `os migrate meta` and the upgrade guide. No D2 conversion: a `RestServerConfig` + is plugin TS configuration, never a stack collection member or a stored row. +- **Ledger and docs.** `liveness/rest_api.json`: the eight identity leaves and + the `contact` / `license` containers flip to `live` with the overlay as + evidence; the `version` row stays `dead` with a REMOVED note. The generated + `state-counts.md` moves `rest_api` from 12 live / 12 dead to 20 / 4; the + `rest-server` reference page is regenerated. + + diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index faf10ede3a6..f01fe5a1457 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -250,7 +250,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | | **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. | -| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config | +| **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 | | **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. | ### Nested Shape: `RestApiConfig.documentation` @@ -258,12 +258,12 @@ const result = BatchEndpointsConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **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. | -| **title** | `string` | optional (default: `"ObjectStack API"`) | API documentation title | -| **description** | `string` | optional | API description | -| **version** | `string` | optional | Documentation version | -| **termsOfService** | `string` | optional | Terms of service URL | -| **contact** | `{ name?: string; url?: string; email?: string }` | optional | | -| **license** | `{ name: string; url?: string }` | optional | | +| **title** | `string` | optional | Title of the served OpenAPI document (`info.title`); unset keeps the bundled title | +| **description** | `string` | optional | Description of the served OpenAPI document (`info.description`); unset keeps the bundled description. Your app's own release number belongs here | +| **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. | +| **termsOfService** | `string` | optional | Terms-of-service URL of the served OpenAPI document (`info.termsOfService`); unset serves none | +| **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 | +| **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 | --- @@ -298,7 +298,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **enableProjectScoping** | `boolean` | optional (default: `false`) | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | optional (default: `"auto"`) | Project ID resolution strategy | | **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. | -| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config | +| **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 | | **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. | ### Nested Shape: `RestServerConfig.crud` diff --git a/packages/rest/src/rest-api-config-dead-keys-refused.test.ts b/packages/rest/src/rest-api-config-dead-keys-refused.test.ts index de1ba71b327..2818ed733d9 100644 --- a/packages/rest/src/rest-api-config-dead-keys-refused.test.ts +++ b/packages/rest/src/rest-api-config-dead-keys-refused.test.ts @@ -14,6 +14,11 @@ * (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s * warn-and-ignore `.omit()`. * + * [#20294] `api.documentation.version` joined them (ruling B on #20359): the + * block's identity members are enforced now — they overlay the served OpenAPI + * `info` — and `version` is the one member retired, because the served + * `info.version` is the protocol version (#11646). + * * ⛔ ANTI-VACUITY — the same rule as `rest-config-parse-not-cast.test.ts`: a pin * asking the SCHEMA whether it refuses is `packages/spec`'s job * (`rest-api-config-dead-keys-retirement.test.ts`). Every case below drives the @@ -94,6 +99,8 @@ const RESPONSE_FORMAT_PRESCRIPTION = /`api\.responseFormat` was removed in @objectstack\/spec 17\.5\.0.*Delete the key\..*Response shapes are fixed/s; const DOCS_ENABLED_PRESCRIPTION = /`api\.documentation\.enabled` was removed in @objectstack\/spec 17\.5\.0.*Delete the key; `api\.enableOpenApi: false` is the switch/s; +const DOCS_VERSION_PRESCRIPTION = + /`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; describe('[#20295] RestServer construction refuses the retired `api` keys', () => { 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', () = }); }); +describe('[#20294] RestServer construction refuses the retired `api.documentation.version`', () => { + // Ruling B on #20359: the identity members of `documentation` are enforced + // (they overlay the served `info` — `rest-openapi-info-overlay.test.ts`), + // `version` is retired because the served `info.version` is the protocol + // version (#11646). An authored one used to be accepted and ignored. + it('refuses it — with the enforced siblings beside it undiagnosed', () => { + const message = refusal({ documentation: { title: 'Acme Orders API', description: 'd', version: '2.3.0' } }); + expect(message).toContain(' - api.documentation.version: '); + expect(message).toContain('RestApiConfigSchema'); + expect(message).toMatch(DOCS_VERSION_PRESCRIPTION); + // Only the retired member is diagnosed — no issue line locates an + // enforced sibling (the prescription itself NAMES + // `api.documentation.description`, as the place a release number + // goes, so the check is on the located-issue line, not the word). + expect(message).not.toContain(' - api.documentation.title: '); + expect(message).not.toContain(' - api.documentation.description: '); + // Not the route identifier either: `api.version` is a different key. + expect(message).not.toContain(' - api.version: '); + }); + + it('the plugin path refuses it too', async () => { + await expect( + createRestApiPlugin({ api: { api: { documentation: { version: '2.3.0' } } } } as never).start!(bootCtx()), + ).rejects.toThrow(/api\.documentation\.version.*was removed/s); + }); + + it('CONTROL: the same block without `version` constructs, and the enforced members pass through', () => { + expect(refusal({ documentation: { title: 'Acme Orders API', description: 'd' } })).toBe(''); + const api = normalizedApi(construct({ documentation: { title: 'Acme Orders API', description: 'd' } })); + expect(api.documentation).toEqual({ title: 'Acme Orders API', description: 'd' }); + }); +}); + describe('[#20295] CONTROL: without the retired keys, the server is what it was', () => { it('the plugin path still boots (the ctx is not what refuses)', async () => { const ctx = bootCtx(); diff --git a/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts b/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts index c3defae6430..ee67566b00f 100644 --- a/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts +++ b/packages/rest/src/rest-api-config-defaults-follow-spec.pin.test.ts @@ -35,9 +35,11 @@ * ⚠️ This file mocks `@objectstack/spec/api` module-wide, so the schema it * drives is NOT the shipped one. The complementary pins that need the REAL * schema — that the shipped defaults are the schema's, that `requireAuth` - * keeps its warn-and-ignore posture, and that the parse's inner defaults now - * reach `documentation` (whose retired `enabled` member, like the retired - * `responseFormat` block, is refused rather than defaulted since #20295) — live in + * keeps its warn-and-ignore posture, and that `documentation` arrives exactly + * as the parse outputs it (with no inner default left since #20294 made + * `title` optional; its retired `enabled` and `version` members, like the + * retired `responseFormat` block, are refused rather than defaulted since + * #20295 and #20294) — live in * `rest-config-parse-not-cast.test.ts` §D, which is deliberately unmocked. */ diff --git a/packages/rest/src/rest-config-parse-not-cast.test.ts b/packages/rest/src/rest-config-parse-not-cast.test.ts index 72dc5f3bedb..45a1182a0c9 100644 --- a/packages/rest/src/rest-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-config-parse-not-cast.test.ts @@ -334,22 +334,33 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => { expect(normalizedApi(rest).enableSearch).toBe(false); }); - it('THE BOUNDED DELTA: an authored `documentation` now carries its own declared inner defaults', () => { + it('THE BOUNDED DELTA: an authored `documentation` arrives as the parse outputs it', () => { // The single measured behaviour change of #14366, pinned rather than // left to be rediscovered. The deleted `??` chain copied this object // through untouched (`documentation: api.documentation`), so a partial - // one stayed partial; the parse fills the inner `.default()`s. - // `documentation` has ZERO read sites outside this block (the #14369 - // census), so nothing observes it today — which is exactly why it needs - // a pin: an unobserved change is the kind that gets reverted by - // accident. + // one stayed partial; the parse fills the inner `.default()`s — of + // which, since #20294 (`title` → `.optional()`) and #20295 (`enabled` + // retired), there are none, so a partial block stays partial again, + // now by the schema's word rather than by a cast. Since #20294 this + // block HAS a reader — `registerOpenApiEndpoints` overlays the served + // `info` from it — which is why an invented member here would be + // observable, and why the pin stays. const doc = normalizedApi(construct({ documentation: { description: 'd' } })) .documentation as Record; expect(doc).toEqual( (declaredApi().parse({ documentation: { description: 'd' } }) as { documentation: unknown }).documentation, ); expect(doc.description, 'the authored key survives').toBe('d'); - expect(doc.title, 'and the declared inner default arrives with it').toBe('ObjectStack API'); + // [#20294] REVERSED by design, not by regression: `documentation.title` + // is `.optional()` now, not `.default('ObjectStack API')`. That default + // was never served, and the served `info` is overlaid from this block + // (`registerOpenApiEndpoints`), so materializing it would retitle the + // document of a host that wrote only `description`. The block now + // carries exactly what was authored — its declared inner defaults are + // none — and an unset title keeps the bundled one + // (`rest-openapi-info-overlay.test.ts`). + expect(doc, 'no title is invented for a host that did not write one').not.toHaveProperty('title'); + expect(doc, 'the block is exactly what was authored').toEqual({ description: 'd' }); // [#20295] REVERSED by design, not by regression: `documentation.enabled` // is a retired tombstone, so the parse no longer materializes its old // `.default(true)` — the block carries only its live members. diff --git a/packages/rest/src/rest-openapi-info-overlay.test.ts b/packages/rest/src/rest-openapi-info-overlay.test.ts new file mode 100644 index 00000000000..6d98adeed5f --- /dev/null +++ b/packages/rest/src/rest-openapi-info-overlay.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20294] The served OpenAPI `info` carries the publisher's identity — + * ruling B on #20359, ADR-0049 enforce-or-remove (the ENFORCE half). + * + * The eight identity members of `api.documentation` — `title`, `description`, + * `termsOfService`, `contact.{name,url,email}`, `license.{name,url}` — were + * parsed and copied into `this.config.api` by `normalizeConfig`, and nothing + * read them back: measured on origin/main fc0db22b with all of them authored, + * both doors served the bundled `info` unchanged (0 of 8 honoured). Now + * `registerOpenApiEndpoints` lays them over the artifact's `info`, on BOTH + * doors — `{apiPath}/openapi.json` and the environment-scoped twin. + * + * The four ruled pins, each on both doors: + * (1) all eight authored ⇒ the served `info` carries each one, and + * `info.version` is still the artifact's — the spec package version; + * (2) nothing authored, `documentation: {}`, and each key alone ⇒ `info` + * equals the artifact's except the authored member (the #11646 + * whole-block pin in `rest-openapi-route.test.ts` is kept, unedited, as + * the no-config control); + * (3) an authored `documentation.version` is refused — at parse in + * `packages/spec` (`rest-api-config-dead-keys-retirement.test.ts`) and at + * construction here (`rest-api-config-dead-keys-refused.test.ts`); + * (4) an authored `license` without `url` serves no `url`. + * + * Every case drives the REAL handler of a server with its real route table + * mounted (`registerRoutes()`), the way `rest-openapi-route.test.ts` does, so + * what it measures is the served body — not the helper in isolation. + */ + +import { createRequire } from 'node:module'; +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const SPEC_PACKAGE_VERSION: string = createRequire(import.meta.url)('@objectstack/spec/package.json').version; + +function makeServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +function makeProtocol() { + return { + getMetaItems: vi.fn(async ({ type }: { type: string }) => ({ type, items: [] })), + } as any; +} + +/** Both doors mounted: the environment-scoped twin exists only under project scoping. */ +function makeRest(api: Record) { + const rest = new RestServer( + makeServer(), + makeProtocol(), + { api: { version: 'v1', enableProjectScoping: true, projectResolution: 'auto', ...api } } as any, + ); + rest.registerRoutes(); + return rest; +} + +const DOORS = ['/api/v1', '/api/v1/environments/:environmentId'] as const; + +/** Drive one door's registered `GET {base}/openapi.json` handler and read the body. */ +async function serveFrom(rest: RestServer, base: string) { + const entry = (rest as any).routeManager.get('GET', `${base}/openapi.json`); + expect(entry, `the ${base}/openapi.json door must be mounted`).toBeDefined(); + let status = 200; + let body: any; + const res: any = { + status: (c: number) => { status = c; return res; }, + json: (b: any) => { body = b; }, + setHeader: () => {}, + send: () => {}, + }; + await entry.handler( + { headers: { host: 'example.test' }, params: { environmentId: 'env_1' }, path: `${base}/openapi.json` }, + res, + ); + expect(status).toBe(200); + return body; +} + +/** The artifact's own `info`, as the server's loader holds it, plus both served bodies. */ +async function serveBoth(api: Record) { + const rest = makeRest(api); + const artifact = await (rest as any).loadOpenApiSpec(); + expect(artifact?.info, 'the bundled artifact must be loadable for these pins to mean anything').toBeTruthy(); + const before = JSON.stringify(artifact.info); + const bodies = [] as any[]; + for (const door of DOORS) bodies.push(await serveFrom(rest, door)); + return { rest, artifact, before, bodies }; +} + +const ALL_EIGHT = { + title: 'Acme Orders API', + description: 'Orders, invoices and shipments for Acme. Release 2.3.0.', + termsOfService: 'https://acme.test/terms', + contact: { name: 'Acme API Team', url: 'https://acme.test/support', email: 'api@acme.test' }, + license: { name: 'Proprietary', url: 'https://acme.test/license' }, +}; + +describe('[#20294] (1) all eight identity members authored — both doors carry them', () => { + it('each authored member is served, and `info.version` is still the spec package version', async () => { + const { artifact, before, bodies } = await serveBoth({ documentation: ALL_EIGHT }); + + // Anti-vacuity: every authored value differs from the artifact's, so a + // served value equal to the authored one cannot be the artifact's. + for (const key of ['title', 'description', 'termsOfService', 'contact', 'license'] as const) { + expect(artifact.info[key], `the artifact's ${key} must differ from the authored one`).not.toEqual(ALL_EIGHT[key]); + } + + for (const [i, body] of bodies.entries()) { + const door = DOORS[i]; + expect(body.info.title, door).toBe('Acme Orders API'); + expect(body.info.description, door).toBe(ALL_EIGHT.description); + expect(body.info.termsOfService, door).toBe('https://acme.test/terms'); + expect(body.info.contact, door).toEqual(ALL_EIGHT.contact); + expect(body.info.license, door).toEqual(ALL_EIGHT.license); + // The one member no publisher signs: the protocol version (#11646). + expect(body.info.version, door).toBe(artifact.info.version); + expect(body.info.version, `${door}: info.version is the spec package version`).toBe(SPEC_PACKAGE_VERSION); + // Nothing beyond the overlay: the artifact's `info` keys plus the one it lacks. + expect(Object.keys(body.info).sort(), door).toEqual( + [...new Set([...Object.keys(artifact.info), 'termsOfService'])].sort(), + ); + } + + // The overlay is a NEW object: the cached artifact's `info` is unchanged. + expect(JSON.stringify(artifact.info), 'serving an overlay must not write into the cached artifact').toBe(before); + expect(bodies[0].info).not.toBe(artifact.info); + }); + + it('`api.version` and the runtime version still never reach `info.version`, with an overlay in play', async () => { + const SENTINEL = '9.9.9-openapi-info-overlay-sentinel'; + const old = process.env.OS_RUNTIME_VERSION; + process.env.OS_RUNTIME_VERSION = SENTINEL; + try { + const rest = makeRest({ version: 'v9', documentation: ALL_EIGHT }); + const artifact = await (rest as any).loadOpenApiSpec(); + for (const door of ['/api/v9', '/api/v9/environments/:environmentId']) { + const body = await serveFrom(rest, door); + // Positive control: the overlay did take effect on this server. + expect(body.info.title, door).toBe('Acme Orders API'); + expect(body.info.version, door).toBe(artifact.info.version); + expect(body.info.version, door).not.toBe('v9'); + expect(JSON.stringify(body.info), door).not.toContain(SENTINEL); + } + } finally { + if (old === undefined) delete process.env.OS_RUNTIME_VERSION; + else process.env.OS_RUNTIME_VERSION = old; + } + }); +}); + +describe('[#20294] (2) nothing authored serves the artifact\'s `info`; one key alone moves only that key', () => { + for (const [label, api] of [ + ['no `documentation` block', {}], + ['`documentation: {}`', { documentation: {} }], + ] as const) { + it(`${label} ⇒ \`info\` is byte-identical to the artifact's, on both doors`, async () => { + const { artifact, bodies } = await serveBoth(api); + for (const [i, body] of bodies.entries()) { + expect(JSON.stringify(body.info), DOORS[i]).toBe(JSON.stringify(artifact.info)); + } + }); + } + + const ONE_KEY: Array<[string, unknown]> = [ + ['title', 'Acme Orders API'], + ['description', 'Orders for Acme.'], + ['termsOfService', 'https://acme.test/terms'], + ['contact', { name: 'Acme API Team', url: 'https://acme.test/support', email: 'api@acme.test' }], + ['license', { name: 'Proprietary', url: 'https://acme.test/license' }], + ]; + for (const [key, value] of ONE_KEY) { + it(`\`${key}\` alone ⇒ \`info\` equals the artifact's except \`${key}\`, on both doors`, async () => { + const { artifact, bodies } = await serveBoth({ documentation: { [key]: value } }); + for (const [i, body] of bodies.entries()) { + expect(body.info, DOORS[i]).toEqual({ ...artifact.info, [key]: value }); + } + }); + } +}); + +describe('[#20294] `contact` / `license` replace the bundled object WHOLE — never member by member', () => { + it('(4) a `license` without `url` serves no `url` — not the bundled Apache-2.0 one', async () => { + const { artifact, bodies } = await serveBoth({ documentation: { license: { name: 'MIT' } } }); + // Positive control: the artifact really carries a licence URL to inherit. + expect(artifact.info.license.url, 'the artifact must carry a license url for this pin to bite').toBeTruthy(); + for (const [i, body] of bodies.entries()) { + expect(body.info.license, DOORS[i]).toEqual({ name: 'MIT' }); + expect(body.info.license, DOORS[i]).not.toHaveProperty('url'); + } + }); + + it('a partial `contact` serves only the authored members — ObjectStack\'s name and URL are not kept', async () => { + const { artifact, bodies } = await serveBoth({ documentation: { contact: { email: 'api@acme.test' } } }); + expect(artifact.info.contact.name, 'the artifact must carry a contact name for this pin to bite').toBeTruthy(); + for (const [i, body] of bodies.entries()) { + expect(body.info.contact, DOORS[i]).toEqual({ email: 'api@acme.test' }); + } + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 365f0a2c7d4..bc0904ff82a 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4799,6 +4799,45 @@ export class RestServer { }); } + /** + * The served OpenAPI `info`: the bundled artifact's, with the identity + * members the host authored in `api.documentation` laid over it (#20294, + * ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half). + * + * - Nothing authored (no block, `{}`, or only unset members) answers + * `bundled` ITSELF, so the served block is byte-identical to the + * artifact's — #11646's whole-block invariant, now the unset case. + * - Anything authored answers a NEW object and never writes into + * `bundled`, which is the cached artifact's own `info`: a write there + * would serve one request's overlay to every later request. + * - `title`, `description` and `termsOfService` overlay key by key. + * - `contact` and `license` REPLACE the bundled object whole: a member the + * host left out is absent, never inherited, so `license: { name: 'MIT' }` + * is not published at the bundled Apache-2.0 URL. + * - `version` is never read. It is a `retiredKey()` tombstone the + * construction-time parse refuses, and `info.version` stays the + * artifact's — the protocol version (#11646). + * + * Pure: its only inputs are its two arguments. The overlaid members are a + * closed list on purpose — the parsed block carries nothing else live, and + * a spread of it would publish whatever a later schema member meant for + * something other than `info`. + */ + private static overlayDocumentationInfo( + bundled: Record | undefined, + documentation: NormalizedRestServerConfig['api']['documentation'], + ): Record | undefined { + if (!documentation) return bundled; + const authored: Record = {}; + if (documentation.title !== undefined) authored.title = documentation.title; + if (documentation.description !== undefined) authored.description = documentation.description; + if (documentation.termsOfService !== undefined) authored.termsOfService = documentation.termsOfService; + if (documentation.contact !== undefined) authored.contact = { ...documentation.contact }; + if (documentation.license !== undefined) authored.license = { ...documentation.license }; + if (Object.keys(authored).length === 0) return bundled; + return { ...bundled, ...authored }; + } + /** * Register OpenAPI 3.1 spec + interactive docs viewer. * @@ -4839,7 +4878,11 @@ export class RestServer { * the cost of regenerating on every request, and a missing or * malformed file degrades to a stub instead of crashing. What survives * from it is what `packages/spec` genuinely owns: `components.schemas`, - * `info`, `securitySchemes` (and the document-level `security`). + * `info`, `securitySchemes` (and the document-level `security`) — with + * one addition to `info` since #20294: the publisher's identity members + * the host authored in `api.documentation` are laid over it, see + * {@link RestServer.overlayDocumentationInfo}. `info.version` stays the + * artifact's. */ private registerOpenApiEndpoints(basePath: string): void { const isScoped = basePath.includes('/environments/:environmentId'); @@ -4995,25 +5038,39 @@ export class RestServer { logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); } - // `info` is passed through from the artifact UNTOUCHED — the - // whole block, version included. `packages/spec` produces it - // (`build-openapi.ts`, pinned by `openapi-self-consistency.test.ts`) - // and owns it, so the served document and the published - // `@objectstack/spec/openapi.json` export now state the same - // fact about the same field (#11646). This handler enriches - // `paths` and `servers`; it writes nothing into `info`. + // 5) `info`: the artifact's, with the publisher's identity laid + // over it (#20294, ruling B on #20359). `packages/spec` + // produces the block (`build-openapi.ts`, pinned by + // `openapi-self-consistency.test.ts`); the host may sign it + // with the identity members of `api.documentation` — + // `title`, `description`, `termsOfService`, and `contact` / + // `license` each replaced whole. Nothing authored serves the + // artifact's `info` byte for byte, so the served document and + // the published `@objectstack/spec/openapi.json` export still + // state the same fact about every field nobody signed + // (#11646's invariant, now the unset case). The same closure + // serves this base and its environment-scoped twin, so both + // doors carry the overlay. The helper returns a NEW object: + // `enriched.info` is still the cached artifact's own `info` + // here (the clone above is shallow), and writing into it + // would leak one request's overlay into every later one. // - // The API version identifier this deployment declares - // (`api.version`, which `normalizeConfig` defaults to `'v1'`) - // is not lost — it lives where it is observable, in the mount - // `${basePath}/${version}` -> `/api/v1`. The runtime version - // is answered by `{basePath}/discovery` and `/health`, derived - // from `OS_RUNTIME_VERSION` (#10993/#11235/#11292). OpenAPI - // 3.1 defines this field as "the version of the OpenAPI - // document (which is distinct from the OpenAPI Specification - // version or the API implementation version)" — the document - // being served IS the artifact, so its version is the - // artifact's. + // `info.version` is NOT publisher identity, and nothing here + // writes it: it stays the artifact's — the protocol version + // (#11646) — and `api.documentation.version` is a retired + // tombstone the construction-time parse already refused. The + // API version identifier this deployment declares + // (`api.version`, which `normalizeConfig` defaults to `'v1'`) + // lives where it is observable, in the mount + // `${basePath}/${version}` -> `/api/v1`. The runtime version + // is answered by `{basePath}/discovery` and `/health`, derived + // from `OS_RUNTIME_VERSION` (#10993/#11235/#11292). OpenAPI + // 3.1 defines this field as "the version of the OpenAPI + // document (which is distinct from the OpenAPI Specification + // version or the API implementation version)" — the document + // being served IS the artifact, so its version is the + // artifact's. + enriched.info = RestServer.overlayDocumentationInfo(enriched.info, this.config.api.documentation); res.json(enriched); } catch (error: any) { diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index ec677507cde..3e01e014851 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -936,7 +936,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | metadata_endpoints | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `cacheTtl` and `endpoints.schema`. `enableCache` is live and `cacheTtl` is not, which is the pair worth reading together: the cached branch delegates to the protocol's `getMetaItemCached`, whose signature takes no TTL, and no cache header anywhere is built from this value. Its negative-bound observation travels in that row by triage ruling rather than as a separate defect — the schema declares `z.number().int()` with no lower bound, so `-1` is accepted, and #11984 pins it as accepted because that is what the contract says. `endpoints.schema` is the sharpest case in the family for per-key rows: its three siblings each gate a route mount and it gates nothing, because `GET /meta/:type/:name/schema` does not exist — `packages/rest/src` mounts no path ending in `/schema` at all **#14691 RETIRED both (2026-09-03, ADR-0049)**: `cacheTtl` and `endpoints.schema` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. The negative-bound observation dies with `cacheTtl` (its #11984 acceptance pin is reversed to a refusal pin); `endpoints.schema` had no route to gate, so there was nothing to enforce. `evidenceScope` widened to `cross-repo` (#14796) | | batch_endpoints | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **#14691 RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | | route_generation | seeded 2026-09-02 (#14369) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 6 = every key it has, and that is the finding: `routes` is parsed, defaulted and normalized into `this.config.routes`, and nothing ever reads it back. `excludeObjects: ['sys_log']` excludes nothing, `nameTransform: 'plural'` still mounts every route under the raw object name, and the per-object `overrides` record (drilled to `enabled` / `basePath` / `operations`) turns nothing on or off. ⚠️ The `overrides` hits in `packages/rest/src` are a REQUEST BODY and a test builder — different keys with the same name. This is the one member of the family with a customer-visible limb: `RestServerConfigSchema`'s own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, so the published prose promises a capability the runtime does not deliver (Prime Directive #10). Fixing that example belongs to whichever enforce-or-remove limb the key lands on — `routes.*` reads as designed-but-never-wired, so enforcing it is real work in route generation that changes the mounted surface, and no dev agent decides that **#14691 RETIRED all six (2026-09-03, ADR-0049)**: every key is now a `retiredKey()` tombstone and the sub-object is tombstones-only; the rows stay `dead` with a REMOVED note (non-strict schema) and the three `overrides.*` child rows collapse into the one `overrides` row. Triage held `overrides` open as an ENFORCE candidate; the measurement closed it as REMOVE because the capability already exists at its proper seat — per-object exposure is the object's own `enable.apiEnabled` / `enable.apiMethods`, enforced by rest-server.ts#enforceApiAccess (404 / 405) — and `basePath` / `nameTransform` would contradict the one deployment-wide data base and Prime Directive #6 (the object name IS the REST path segment). The `@example` limb is corrected in the same change. `evidenceScope` widened to `cross-repo` (#14796) | -| rest_api | seeded 2026-09-21 (#14640) — the FIFTH `RestServerConfig` sub-object, enrolled a round after the four above and deliberately so. #14369 left `RestApiConfigSchema` out because the `api` block's consumption seam was then still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census would have recorded a half that was about to move; the gate source and four rows of this table said as much. That fence was re-tested before a line of this ledger was written and it has EXPIRED: `RestServer.normalizeConfig` now BUILDS the `api` block from `parseDeclaredApiConfig`'s output — “the asymmetry is gone and all five now build from their parsed output” — and the change is RELEASED, not in flight, with `packages/rest/CHANGELOG.md` re-stating the same zero this file records. ⛔ **The ledger is `rest_api.json`, NOT `api.json`**: that name was already taken by `ApiEndpointSchema`, the registered `api` metadata type with real consumers in the matcher, executor, policy chain and mapping layer — one spelling, two unrelated meanings inside `packages/spec`, and filing here would have published one file's measurement under the other's name. Live 12 = `version` / `basePath` / `apiPath`, which `getApiBasePath` splices into the prefix of EVERY mounted route (read through a whole-block destructure, which is why the dead-key census below had to sweep destructuring shapes and not a property-access pattern alone), the eight `enable*` switches, each gating a mount and most of them also the discovery document's capability block, and `projectResolution`. Dead 12 = the `requireAuth` tombstone (#3963, still `.omit()`ed by this seam because #3963 chose warn-and-ignore and converting that to a boot failure is that decision's to make), the `responseFormat` and `documentation.enabled` tombstones (RETIRED 2026-09-27, #20295, ADR-0049 enforce-or-remove — refused at `RestServer` construction with their prescription; `responseFormat` retired whole, so its three child rows collapsed into one), plus the other nine members of `documentation` (drilled, including its nested `contact` / `license`) — normalized into `this.config.api` and read back by nothing, so `documentation.title` retitles no served document. Every zero carries a lit control on the same instrument (twelve sibling keys on the same block return 1-2 reads), each of the three shapes a spelling sweep is blind to was swept with its own control, and the backstop is structural rather than textual: `NormalizedRestServerConfig` is module-local with no `export` and `RestServer.config` is `private`, so the normalized block cannot be reached from outside that one class. ⛔ **The two dead containers do NOT share one verdict**: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (`info` is written by `build-openapi.ts` and passed through untouched by #11646), while `responseFormat`'s enforce route means making the response envelope configurable — a larger claim. This file records status; the enforce-or-remove call per key is a follow-up on the human floor — made for `responseFormat` and `documentation.enabled` (retired, #20295), still open for `documentation`'s other members. `evidenceScope` stays `in-repo`: objectui was measured clean at the pinned sha and at head against a lit control, but the closed cloud runtime was not reachable from the measuring container, so #14796's structural reading is cited as a standing reading rather than re-claimed as a sweep | +| rest_api | seeded 2026-09-21 (#14640) — the FIFTH `RestServerConfig` sub-object, enrolled a round after the four above and deliberately so. #14369 left `RestApiConfigSchema` out because the `api` block's consumption seam was then still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census would have recorded a half that was about to move; the gate source and four rows of this table said as much. That fence was re-tested before a line of this ledger was written and it has EXPIRED: `RestServer.normalizeConfig` now BUILDS the `api` block from `parseDeclaredApiConfig`'s output — “the asymmetry is gone and all five now build from their parsed output” — and the change is RELEASED, not in flight, with `packages/rest/CHANGELOG.md` re-stating the same zero this file records. ⛔ **The ledger is `rest_api.json`, NOT `api.json`**: that name was already taken by `ApiEndpointSchema`, the registered `api` metadata type with real consumers in the matcher, executor, policy chain and mapping layer — one spelling, two unrelated meanings inside `packages/spec`, and filing here would have published one file's measurement under the other's name. Live 20 = `version` / `basePath` / `apiPath`, which `getApiBasePath` splices into the prefix of EVERY mounted route (read through a whole-block destructure, which is why the dead-key census below had to sweep destructuring shapes and not a property-access pattern alone), the eight `enable*` switches, each gating a mount and most of them also the discovery document's capability block, and `projectResolution` — plus, since #20294 (ENFORCED 2026-09-28, ruling B on #20359), the eight identity members of `documentation` (`title`, `description`, `termsOfService`, `contact.name` / `url` / `email`, `license.name` / `url`), which `RestServer.overlayDocumentationInfo` lays over the served OpenAPI `info` on both `/openapi.json` doors (`contact` / `license` replaced whole; nothing authored serves the artifact's `info` unchanged). Dead 4 = the `requireAuth` tombstone (#3963, still `.omit()`ed by this seam because #3963 chose warn-and-ignore and converting that to a boot failure is that decision's to make), the `responseFormat` and `documentation.enabled` tombstones (RETIRED 2026-09-27, #20295, ADR-0049 enforce-or-remove — refused at `RestServer` construction with their prescription; `responseFormat` retired whole, so its three child rows collapsed into one), and the `documentation.version` tombstone (RETIRED 2026-09-28, #20294 — the served `info.version` is the protocol version, #11646; an app's own release number goes into `description`). Until #20294 the nine non-`enabled` members of `documentation` (drilled, including its nested `contact` / `license`) were all dead too — normalized into `this.config.api` and read back by nothing, so `documentation.title` retitled no served document. Every zero carries a lit control on the same instrument (twelve sibling keys on the same block return 1-2 reads), each of the three shapes a spelling sweep is blind to was swept with its own control, and the backstop is structural rather than textual: `NormalizedRestServerConfig` is module-local with no `export` and `RestServer.config` is `private`, so the normalized block cannot be reached from outside that one class. ⛔ **The two dead containers do NOT share one verdict**: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (`info` is written by `build-openapi.ts` and was passed through untouched by #11646 — settled by ruling B on #20359, which split the block by field owner: identity overlaid, `version` retired), while `responseFormat`'s enforce route means making the response envelope configurable — a larger claim. This file records status; the enforce-or-remove call per key is a follow-up on the human floor — made for `responseFormat` and `documentation.enabled` (retired, #20295), and for `documentation`'s other members (#20294: the identity members enforced, `version` retired). `evidenceScope` stays `in-repo`: objectui was measured clean at the pinned sha and at head against a lit control, but the closed cloud runtime was not reachable from the measuring container, so #14796's structural reading is cited as a standing reading rather than re-claimed as a sweep | | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | | connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts.md` row, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts.md` row carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | diff --git a/packages/spec/liveness/rest_api.json b/packages/spec/liveness/rest_api.json index 6175e047620..43e9c32cb7d 100644 --- a/packages/spec/liveness/rest_api.json +++ b/packages/spec/liveness/rest_api.json @@ -1,6 +1,6 @@ { "type": "rest_api", - "_note": "RestApiConfigSchema — packages/spec/src/api/rest-server.zod.ts#RestApiConfigSchema, the `api` sub-object of RestServerConfig and the FIFTH of its five. It is not a metadata type, not a request body and not a manifest: it is part of the REST server's CONSTRUCTION ARGUMENT, so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` take; check-liveness.mts carries the rationale, including why the sub-objects are rooted separately instead of the whole RestServerConfigSchema. ⛔ WHY THIS FILE IS `rest_api.json` AND NOT `api.json`, which is the first mistake a reader makes here: packages/spec/liveness/api.json ALREADY EXISTS and is a DIFFERENT `api` — its own header names ApiEndpointSchema (packages/spec/src/api/endpoint.zod.ts), the registered `api` metadata type, with real consumers in the matcher, executor, policy chain and mapping layer. It has nothing to do with RestApiConfigSchema. Filing these verdicts there would publish one file's measurement under another file's name. One spelling, two unrelated meanings, inside packages/spec — the same shape as the `userMessage` collision. The gate's own SPEC_ONLY_SCHEMAS paragraph repeats this fence so the next enrolment does not have to rediscover it. SEEDED 2026-09-21, a round later than its four siblings, and the delay is the point. #14369 enrolled the four and deliberately left this one out: at that moment the `api` block's consumption seam was still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census taken then would have recorded a half that was about to move, and both the gate source and the crud_endpoints / route_generation README rows said so in as many words. THE FENCE HAS EXPIRED, measured before anything here was written: RestServer.normalizeConfig now BUILDS the `api` block from parseDeclaredApiConfig's output instead of discarding it — 'the asymmetry is gone and all five now build from their parsed output' (packages/rest/src/rest-server.ts#parseDeclaredApiConfig) — and that change is RELEASED, not in flight: it is in packages/rest/CHANGELOG.md, whose entry re-states the same zero this file records ('nothing in the platform reads either key today ... the repo has no other read site for either key'). So the census below is taken on a settled seam, which is the whole condition the exclusion was waiting on. No other seam was found to constrain these keys, and the sweep for one is recorded per row. ⚠️ THE UPSTREAM REFERENCES IN THIS FAMILY'S PAPER TRAIL DO NOT RESOLVE. #14366 (the card that landed the consumption seam), #14369 (the card that enrolled the four siblings), #14691 (the retirement that removed their dead keys), #14365 and #14690 all return HTTP 404, measured 2026-09-21 against neighbours that resolve at 200 (#14368, #14370, #14692). Those numbers are cited throughout rest-server.ts, rest-server.zod.ts and the four sibling ledgers, and the work they name is all VISIBLY LANDED in the tree — so read the tree, not the tracker, and ⛔ do not guess replacement numbers. The class is carded at #17512, which measured five instances; these are not all of the same five. MIXED: twelve keys gate or shape the mounted surface (`version` / `basePath` / `apiPath` become the prefix of every route; the eight `enable*` switches decide mounts and the discovery document's capability block; `enableProjectScoping` / `projectResolution` decide the scoped mount and are the only two keys a shipped boot path can author). Eleven do not: the `requireAuth` tombstone, and the two declared containers `documentation` (seven members) and `responseFormat` (three), which normalizeConfig copies through and nothing reads back. THIS FILE RECORDS STATUS; IT DECIDES NOTHING. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor — the enforce route is a feature per key, and for a key published in an `@example` or in the generated reference docs the remove route is a capability retirement, not a tidy-up. ⛔ The two dead containers do NOT get one shared verdict by default: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (#11646 — see the per-row notes), while `responseFormat`'s enforce route means making the response envelope configurable, a larger claim. Each row states its own. REMOVAL SHAPE, if that is the call the human floor makes, stated here because the precedent is split and the wrong half is the obvious one: a RestServerConfig is plugin TS configuration, never a stack collection member and never a sys_metadata row (the RestServerConfig.openApi31 precedent, #4579), so a retirement here takes the `crud.patterns` route — a `retiredKey()` tombstone at the schema plus a D3 registry entry carrying the prescription — and NOT an ADR-0087 conversion. The counter-example is a trap: `stack.api.requireAuth` DOES have a conversion (`stack-api-require-auth-removed`), but its surface is the STACK's own top-level `api:` block, a separate and deliberately narrow schema in stack.zod.ts carrying four keys (`requireAuth` tombstone, `enableProjectScoping`, `projectResolution`, `enforceProjectMembership`). `documentation` and `responseFormat` are NOT in it, so they are not authorable from objectstack.config.ts at all and there is no stored source for a conversion to strip. CENSUS METHOD AND SCOPE, run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f: `git grep` with NO pathspec over the whole tracked tree, filtered afterwards (the pathspec form has a measured trap in this checkout); read sites outside NormalizedRestServerConfig's type declaration and normalizeConfig itself, comments and tests excluded. Every zero carries a LIT CONTROL on the same instrument and the same object, and every named hole in the radius carries a second instrument with its own control — the per-row notes record both, plus the three shapes a spelling sweep is blind to (spread, destructuring, computed access / casts), each swept and each empty. The structural backstop is what a grep cannot give: NormalizedRestServerConfig is a module-local type with no `export` and RestServer.config is `private`, so the normalized block is unreachable from outside that one class. HOLES IN THE RADIUS, named rather than papered over: (1) the sibling repo objectui — swept separately and clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head 98178b20, 0 hits for RestApiConfig / RestServerConfig / responseFormat / includeMetadata / includePagination / termsOfService against a lit control of 181 for `basePath` at the pin (182 at head); (2) the closed cloud runtime, NOT reachable from the measuring container — #14796's structural reading (cloud never authors a RestServerConfig) is cited as a standing reading, not re-measured here, which is why every `evidenceScope` below says `in-repo` and not `cross-repo`; (3) untracked build output, which `git grep` does not see — packages/console/dist is objectui's build and returns 0 for the keys and 0 for the control, so it contributes no reading either way. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS, and a RestServerConfig is not part of a stack at all — it is the argument a host passes when it constructs the server, so a warn flag here would emit nothing, which is the same silent no-op this ledger exists to catch. The dead entries carry their correction in `note`, and the construction-time parse is what actually reaches the author — for accept/reject, which is a different question from liveness. REACHABILITY IS A SEPARATE AXIS FROM `status`, and every row below carries the sentence: `live` means the runtime READS the key, which is the only thing these statuses classify; reachability answers who can WRITE it. Both facts for this block are #15543's standing reading — embedder-only, one programmatic door, `os serve` forwarding exactly two keys — and re-stating it here re-verified no call graph. RETIRED 2026-09-27 (#20295, ADR-0049 enforce-or-remove): the call was made for four of the dead keys — `responseFormat` (the whole block, one `retiredKey()` tombstone; its three child rows collapse into one row) and `documentation.enabled` — by exactly the REMOVAL SHAPE above: tombstones plus a D3 entry, no conversion. Both rows stay `dead` with a REMOVED note. `documentation`'s other members are a separate decision and keep their rows unchanged.", + "_note": "RestApiConfigSchema — packages/spec/src/api/rest-server.zod.ts#RestApiConfigSchema, the `api` sub-object of RestServerConfig and the FIFTH of its five. It is not a metadata type, not a request body and not a manifest: it is part of the REST server's CONSTRUCTION ARGUMENT, so no registry has ever held it and no ratchet rooted in one could ask who reads it. The ledger governs it through the gate's SPEC_ONLY_SCHEMAS override, the same route `query` / `qa` / `manifest` take; check-liveness.mts carries the rationale, including why the sub-objects are rooted separately instead of the whole RestServerConfigSchema. ⛔ WHY THIS FILE IS `rest_api.json` AND NOT `api.json`, which is the first mistake a reader makes here: packages/spec/liveness/api.json ALREADY EXISTS and is a DIFFERENT `api` — its own header names ApiEndpointSchema (packages/spec/src/api/endpoint.zod.ts), the registered `api` metadata type, with real consumers in the matcher, executor, policy chain and mapping layer. It has nothing to do with RestApiConfigSchema. Filing these verdicts there would publish one file's measurement under another file's name. One spelling, two unrelated meanings, inside packages/spec — the same shape as the `userMessage` collision. The gate's own SPEC_ONLY_SCHEMAS paragraph repeats this fence so the next enrolment does not have to rediscover it. SEEDED 2026-09-21, a round later than its four siblings, and the delay is the point. #14369 enrolled the four and deliberately left this one out: at that moment the `api` block's consumption seam was still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census taken then would have recorded a half that was about to move, and both the gate source and the crud_endpoints / route_generation README rows said so in as many words. THE FENCE HAS EXPIRED, measured before anything here was written: RestServer.normalizeConfig now BUILDS the `api` block from parseDeclaredApiConfig's output instead of discarding it — 'the asymmetry is gone and all five now build from their parsed output' (packages/rest/src/rest-server.ts#parseDeclaredApiConfig) — and that change is RELEASED, not in flight: it is in packages/rest/CHANGELOG.md, whose entry re-states the same zero this file records ('nothing in the platform reads either key today ... the repo has no other read site for either key'). So the census below is taken on a settled seam, which is the whole condition the exclusion was waiting on. No other seam was found to constrain these keys, and the sweep for one is recorded per row. ⚠️ THE UPSTREAM REFERENCES IN THIS FAMILY'S PAPER TRAIL DO NOT RESOLVE. #14366 (the card that landed the consumption seam), #14369 (the card that enrolled the four siblings), #14691 (the retirement that removed their dead keys), #14365 and #14690 all return HTTP 404, measured 2026-09-21 against neighbours that resolve at 200 (#14368, #14370, #14692). Those numbers are cited throughout rest-server.ts, rest-server.zod.ts and the four sibling ledgers, and the work they name is all VISIBLY LANDED in the tree — so read the tree, not the tracker, and ⛔ do not guess replacement numbers. The class is carded at #17512, which measured five instances; these are not all of the same five. MIXED: twelve keys gate or shape the mounted surface (`version` / `basePath` / `apiPath` become the prefix of every route; the eight `enable*` switches decide mounts and the discovery document's capability block; `enableProjectScoping` / `projectResolution` decide the scoped mount and are the only two keys a shipped boot path can author). Eleven do not: the `requireAuth` tombstone, and the two declared containers `documentation` (seven members) and `responseFormat` (three), which normalizeConfig copies through and nothing reads back. THIS FILE RECORDS STATUS; IT DECIDES NOTHING. The enforce-or-remove call per dead key (ADR-0049) is a follow-up on the human floor — the enforce route is a feature per key, and for a key published in an `@example` or in the generated reference docs the remove route is a capability retirement, not a tidy-up. ⛔ The two dead containers do NOT get one shared verdict by default: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (#11646 — see the per-row notes), while `responseFormat`'s enforce route means making the response envelope configurable, a larger claim. Each row states its own. REMOVAL SHAPE, if that is the call the human floor makes, stated here because the precedent is split and the wrong half is the obvious one: a RestServerConfig is plugin TS configuration, never a stack collection member and never a sys_metadata row (the RestServerConfig.openApi31 precedent, #4579), so a retirement here takes the `crud.patterns` route — a `retiredKey()` tombstone at the schema plus a D3 registry entry carrying the prescription — and NOT an ADR-0087 conversion. The counter-example is a trap: `stack.api.requireAuth` DOES have a conversion (`stack-api-require-auth-removed`), but its surface is the STACK's own top-level `api:` block, a separate and deliberately narrow schema in stack.zod.ts carrying four keys (`requireAuth` tombstone, `enableProjectScoping`, `projectResolution`, `enforceProjectMembership`). `documentation` and `responseFormat` are NOT in it, so they are not authorable from objectstack.config.ts at all and there is no stored source for a conversion to strip. CENSUS METHOD AND SCOPE, run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f: `git grep` with NO pathspec over the whole tracked tree, filtered afterwards (the pathspec form has a measured trap in this checkout); read sites outside NormalizedRestServerConfig's type declaration and normalizeConfig itself, comments and tests excluded. Every zero carries a LIT CONTROL on the same instrument and the same object, and every named hole in the radius carries a second instrument with its own control — the per-row notes record both, plus the three shapes a spelling sweep is blind to (spread, destructuring, computed access / casts), each swept and each empty. The structural backstop is what a grep cannot give: NormalizedRestServerConfig is a module-local type with no `export` and RestServer.config is `private`, so the normalized block is unreachable from outside that one class. HOLES IN THE RADIUS, named rather than papered over: (1) the sibling repo objectui — swept separately and clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head 98178b20, 0 hits for RestApiConfig / RestServerConfig / responseFormat / includeMetadata / includePagination / termsOfService against a lit control of 181 for `basePath` at the pin (182 at head); (2) the closed cloud runtime, NOT reachable from the measuring container — #14796's structural reading (cloud never authors a RestServerConfig) is cited as a standing reading, not re-measured here, which is why every `evidenceScope` below says `in-repo` and not `cross-repo`; (3) untracked build output, which `git grep` does not see — packages/console/dist is objectui's build and returns 0 for the keys and 0 for the control, so it contributes no reading either way. AUTHOR-WARN CHANNEL: none exists for this type, and no entry here is marked `authorWarn` for that reason (`_authorWarnSkipped`). The CLI lint (packages/lint/src/lint-liveness-properties.ts) walks stack COLLECTIONS, and a RestServerConfig is not part of a stack at all — it is the argument a host passes when it constructs the server, so a warn flag here would emit nothing, which is the same silent no-op this ledger exists to catch. The dead entries carry their correction in `note`, and the construction-time parse is what actually reaches the author — for accept/reject, which is a different question from liveness. REACHABILITY IS A SEPARATE AXIS FROM `status`, and every row below carries the sentence: `live` means the runtime READS the key, which is the only thing these statuses classify; reachability answers who can WRITE it. Both facts for this block are #15543's standing reading — embedder-only, one programmatic door, `os serve` forwarding exactly two keys — and re-stating it here re-verified no call graph. RETIRED 2026-09-27 (#20295, ADR-0049 enforce-or-remove): the call was made for four of the dead keys — `responseFormat` (the whole block, one `retiredKey()` tombstone; its three child rows collapse into one row) and `documentation.enabled` — by exactly the REMOVAL SHAPE above: tombstones plus a D3 entry, no conversion. Both rows stay `dead` with a REMOVED note. `documentation`'s other members are a separate decision and keep their rows unchanged. ENFORCED / RETIRED 2026-09-28 (#20294, ruling B on #20359): that separate decision split `documentation` by who owns each field. Its identity members — `title`, `description`, `termsOfService`, and the `contact` / `license` containers with their members — are LIVE: `RestServer.overlayDocumentationInfo` lays the authored ones over the served OpenAPI `info` on both doors (the containers replace the bundled objects whole), and nothing authored serves the artifact's `info` unchanged. `version` is RETIRED by the same REMOVAL SHAPE as `enabled` — a tombstone plus a D3 entry, no conversion — because the served `info.version` is the protocol version (#11646). Its row stays `dead` with a REMOVED note.", "props": { "version": { "status": "live", @@ -113,72 +113,92 @@ "note": "REMOVED 2026-09-27 (#20295) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error, and RestServer construction refuses it with that prescription instead of re-defaulting it to `true`). No source is stripped by a conversion: a RestServerConfig is plugin TS configuration, never a stack collection member or a sys_metadata row (the `RestServerConfig.openApi31` precedent, #4579), so the D3 entry `rest-api-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). What to do instead: whether the server publishes its OpenAPI document is `api.enableOpenApi` at the mount (rest-server.ts#registerRoutes gates registerOpenApiEndpoints on it), the switch the prescription names; this key was a second switch nothing consulted. Only this member of `documentation` retires — its siblings keep their own rows and their own decision. Pre-retirement verdict, kept as the record: 0 read sites at 31184e5d, with the three shapes a spelling sweep is blind to (spread, destructuring, computed access / casts) each swept against its own control, and the structural backstop that NormalizedRestServerConfig is module-local and RestServer.config is private. Re-measured 2026-09-27 on origin/main 4e0f72e8 before the tombstone landed: `packages/**` non-test code carried 0 reads (the only code sites were NormalizedRestServerConfig's type declaration and normalizeConfig's own write), against a lit control on the same instrument (`enableOpenApi` finds its read at rest-server.ts#registerRoutes); objectui at its pin f8a9d0fb returned 0 for RestApiConfig / RestServerConfig / responseFormat / includePagination / enableOpenApi against a lit control (`basePath` = 184); cloud at 96eb092 returned 0 authoring sites for RestApiConfig / RestServerConfig / responseFormat / includePagination / `documentation.enabled`, against a lit control (`createRestApiPlugin` = 11 — every call site forwards the stack's own top-level `api:` block, whose schema carries neither key). So `evidenceScope` is `cross-repo`: the closed runtime was reachable this time and was swept, not cited. REACHABILITY (#15543, standing reading): embedder-only — see this file's _note." }, "title": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. `api.documentation.title: 'Acme API'` retitles no served document — the served `info.title` is the literal 'ObjectStack REST API' from build-openapi.ts. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (an authored `title` replaces the served `info.title`) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): the host's `title` is the served document's `info.title`; unset keeps the bundled 'ObjectStack REST API'. The schema no longer defaults it: the old `.default('ObjectStack API')` was materialized into every present block and was never the served title, so reading it would have retitled the document of a host that wrote only, say, `description` — `title` is `.optional()` like its siblings. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "description": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. no consumer; the served `info.description` is a build-openapi.ts literal. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (an authored `description` replaces the served `info.description`) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): the host's `description` is the served `info.description`; unset keeps the bundled one. It is also where the `documentation.version` tombstone sends an app's own release number. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "version": { "status": "dead", - "verifiedAt": "2026-09-21", - "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. no consumer; the served `info.version` is SPEC_VERSION. ⚠️ Not to be confused with the live sibling `version` at the top of this block, which IS read (it becomes a path segment in every mount) — same word, two keys, one live and one dead. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other." + "verifiedAt": "2026-09-28", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-28 (#20294, ruling B on #20359) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error, and RestServer construction refuses it with that prescription). No source is stripped by a conversion: a RestServerConfig is plugin TS configuration, never a stack collection member or a sys_metadata row (the `documentation.enabled` precedent one row up), so the D3 entry `rest-api-documentation-version-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). What to do instead: nothing sets the served `info.version` — it is the protocol version, the `@objectstack/spec` package version the bundled artifact carries (#11646) — and an app's own release number goes into `documentation.description`, which the served `info.description` now carries. The block's other members were ENFORCED in the same change (their rows, above and below). ⚠️ Not to be confused with the live sibling `version` at the top of this block, the route identifier every mount splices in — same word, two keys; that one is untouched. Pre-retirement verdict, kept as the record: 0 read sites at 31184e5d; re-measured 2026-09-28 on origin/main fc0db22b: an authored `documentation.version: '2.3.0'` was accepted, forwarded by normalizeConfig and served nowhere (both doors answered the artifact's `info.version`). objectui at its pin dd3f7e1b: 0 hits for `documentation.version`, `RestApiConfig` and `RestServerConfig` against a lit control (`basePath` = 163); cloud was not re-measured here — the #20295 reading of the same block at cloud 96eb092 (0 authoring sites for `RestApiConfig` / `RestServerConfig`) is cited as a standing reading." }, "termsOfService": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. no consumer, and the served document carries no `info.termsOfService` at all. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (an authored `termsOfService` becomes the served `info.termsOfService`) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): the host's `termsOfService` is served as `info.termsOfService`; unset serves none, because the bundled artifact carries none. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "contact": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. no consumer. A CONTAINER (`name` / `url` / `email`) whose members ride on this blanket verdict, which is safe here only because the verdict is `dead` for the container itself: nothing reads the object, so nothing can read a member of it. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other.", + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (an authored `contact` REPLACES the served `info.contact` whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): an authored `contact` replaces the bundled `info.contact` WHOLE — never member by member — so a member the host leaves out is absent rather than inherited: a partial contact must not be published under ObjectStack's name and URL. Unset keeps the bundled `info.contact`. Its members (`name`, `url`, `email`) carry their own rows. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`.", "children": { "name": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "The contact name an OpenAPI `info.contact` would carry. No consumer: the served document's `info.contact` is the literal `{ name: 'ObjectStack', url: 'https://objectstack.io' }` written by packages/spec/scripts/build-openapi.ts, and rest-server.ts#registerOpenApiEndpoints passes `info` through untouched (#11646). Drilled rather than left riding on its container's blanket verdict, and NOT by fanning the parent's status out over its children — the call graph for this key is genuinely closed, by a structural argument rather than by a spelling sweep. ⛔ A spelling sweep would be worthless here and saying so is the point: `name` / `url` / `email` are far too generic to grep, so an absence of hits would carry no information. What carries information is that the only object this key can be reached through is unreachable: `normalizeConfig` copies `documentation` into `RestServer.config.api`, that field is `private`, `NormalizedRestServerConfig` is a module-local type with no `export`, and the container itself has 0 read sites across the whole tracked tree (lit control on the same instrument: twelve sibling keys on the same block return 1-2). Nothing reads the object, so nothing can read a member of it — the verdict is a proof, not a failed search. Measured 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f. Scope: in-repo, plus objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head; the closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo`. REACHABILITY (#15543, standing reading): embedder-only — written only by a host that constructs a RestServerConfig, and no shipped boot path authors this block, so a CLI-started deployment always gets the schema default. `live` answers who READS the key; reachability answers who can SET it." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (served as `info.contact.name` inside the authored `contact`, which replaces the bundled one whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): served as `info.contact.name` whenever the host authors a `contact` block, which replaces the bundled one whole; a `contact` without this member serves none (never the bundled value). Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "url": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "The contact URL an OpenAPI `info.contact` would carry. No consumer; the served value is the build-openapi.ts literal. Drilled rather than left riding on its container's blanket verdict, and NOT by fanning the parent's status out over its children — the call graph for this key is genuinely closed, by a structural argument rather than by a spelling sweep. ⛔ A spelling sweep would be worthless here and saying so is the point: `name` / `url` / `email` are far too generic to grep, so an absence of hits would carry no information. What carries information is that the only object this key can be reached through is unreachable: `normalizeConfig` copies `documentation` into `RestServer.config.api`, that field is `private`, `NormalizedRestServerConfig` is a module-local type with no `export`, and the container itself has 0 read sites across the whole tracked tree (lit control on the same instrument: twelve sibling keys on the same block return 1-2). Nothing reads the object, so nothing can read a member of it — the verdict is a proof, not a failed search. Measured 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f. Scope: in-repo, plus objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head; the closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo`. REACHABILITY (#15543, standing reading): embedder-only — written only by a host that constructs a RestServerConfig, and no shipped boot path authors this block, so a CLI-started deployment always gets the schema default. `live` answers who READS the key; reachability answers who can SET it." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (served as `info.contact.url` inside the authored `contact`, which replaces the bundled one whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): served as `info.contact.url` whenever the host authors a `contact` block, which replaces the bundled one whole; a `contact` without this member serves none (never the bundled value). Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "email": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "The contact email an OpenAPI `info.contact` would carry. No consumer, and the served document carries no `info.contact.email` at all — build-openapi.ts writes only `name` and `url`. Drilled rather than left riding on its container's blanket verdict, and NOT by fanning the parent's status out over its children — the call graph for this key is genuinely closed, by a structural argument rather than by a spelling sweep. ⛔ A spelling sweep would be worthless here and saying so is the point: `name` / `url` / `email` are far too generic to grep, so an absence of hits would carry no information. What carries information is that the only object this key can be reached through is unreachable: `normalizeConfig` copies `documentation` into `RestServer.config.api`, that field is `private`, `NormalizedRestServerConfig` is a module-local type with no `export`, and the container itself has 0 read sites across the whole tracked tree (lit control on the same instrument: twelve sibling keys on the same block return 1-2). Nothing reads the object, so nothing can read a member of it — the verdict is a proof, not a failed search. Measured 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f. Scope: in-repo, plus objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head; the closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo`. REACHABILITY (#15543, standing reading): embedder-only — written only by a host that constructs a RestServerConfig, and no shipped boot path authors this block, so a CLI-started deployment always gets the schema default. `live` answers who READS the key; reachability answers who can SET it." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (served as `info.contact.email` inside the authored `contact`, which replaces the bundled one whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): served as `info.contact.email` whenever the host authors a `contact` block, which replaces the bundled one whole; a `contact` without this member serves none (never the bundled value). The bundled artifact carries no contact email, so this member is only ever the host's. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." } } }, "license": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "Normalized by `RestServer` and read by NOTHING — the ADR-0049 fourth state (parsed, unmarked, unenforced). `normalizeConfig` lists `documentation: api.documentation` straight into `this.config.api` and no site ever reads it back. no consumer. A CONTAINER (`name` / `url`) on the same blanket-verdict footing as `contact`; the served document does carry an `info.license`, and it is the Apache-2.0 literal from build-openapi.ts, not this key. 0 read sites. Census re-run 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f (the card's own figure was taken at 2514d49f3 and re-confirmed by triage at 4d0d944; both were stale, so nothing here is inherited). Method: `git grep` with NO pathspec over the whole tracked tree, then filtered — the pathspec form has a measured trap in this checkout. Radius: every tracked file in this repo. Excluded from the read population, as on the four sibling ledgers: NormalizedRestServerConfig's type declaration and normalizeConfig itself (a key the normalizer writes into its own output is not thereby read), plus comments and tests. LIT CONTROL for the zero, same instrument, same object: `this.config.api.` returns 1-2 sites for twelve sibling keys on this very block and 0 for this one, so the instrument reads real reads. THE THREE SHAPES A SPELLING SWEEP IS BLIND TO were each swept with their own control and each came back empty for this key: (a) SPREAD — the only `{ ...api }` in the tree is packages/spec/src/conversions/registry.ts#stackApiRequireAuthRemoved, which copies the STACK `api:` block minus one key and reads nothing off it; (b) DESTRUCTURING — the only whole-block destructure is `const { api } = this.config` inside getApiBasePath, whose body reads apiPath / basePath / version and nothing else, and the control (`enableProjectScoping`) does find its real destructure at rest-server.ts#registerRoutes; (c) COMPUTED ACCESS / CASTS — no bracket access and no `as any` over `this.config` anywhere in packages/rest. STRUCTURAL BACKSTOP, which a grep cannot give: `NormalizedRestServerConfig` is a module-local type with no `export`, and `RestServer.config` is `private`, so the normalized block cannot be reached from outside this one class at all. objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 AND at its head 98178b20 — `RestApiConfig` / `RestServerConfig` / `responseFormat` / `includeMetadata` / `includePagination` / `termsOfService` all 0, against a lit control (`basePath` = 181 at the pin, 182 at head), so the sibling repo never sees this config. WHAT THE ENFORCE ROUTE WOULD HAVE TO REVERSE, recorded here so the next reader does not propose it blind: every member of this container is an OpenAPI `info` field, and `info` already has an owner and an explicit ruling. packages/spec/scripts/build-openapi.ts writes the whole block as literals (title 'ObjectStack REST API', version SPEC_VERSION, a description, contact and an Apache-2.0 license), openapi-self-consistency.test.ts pins it, and rest-server.ts#registerOpenApiEndpoints passes `info` through UNTOUCHED by a recorded decision (#11646) so the served document and the published @objectstack/spec/openapi.json export state the same fact about the same field — the handler enriches `paths` and `servers` and writes nothing into `info`. So enforce here is not \"wire up a title\": it is reopening who owns `info`, which is above this ledger. Remove is the other honest route and has a worked precedent one file over (#14691 on crud.patterns / crud.objectParamStyle). ⛔ This file records the measurement; it does not make that call — and ⛔ the two containers do not get one shared verdict by default, because they differ: `documentation` describes customer-facing metadata an OpenAPI document plausibly SHOULD carry, while `responseFormat` describes an envelope the REST layer already produces unconditionally. Scope: in-repo, plus objectui measured clean. The closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo` rather than claiming a sweep that was not run. REACHABILITY (#15543, standing reading): embedder-only. A RestServerConfig is the ARGUMENT a host passes when it constructs the server, and the one door is programmatic — createRestApiPlugin({ api }) (packages/rest/src/rest-api-plugin.ts). No shipped boot path opens it with a config of its own: packages/cli/src/commands/serve.ts forwards exactly two keys out of the stack's own top-level `api:` block (enableProjectScoping, projectResolution) and plugin-dev calls createRestApiPlugin() with no config at all, so a CLI-started deployment always gets the schema default for every key here. `live` answers who READS the key; reachability answers who can SET it, and the two never substitute for each other.", + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (an authored `license` REPLACES the served `info.license` whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): an authored `license` replaces the bundled `info.license` WHOLE — never member by member — so a member the host leaves out is absent rather than inherited: `license: { name: 'MIT' }` must not be published at the bundled Apache-2.0 URL — a false statement in a machine-read contract. `name` is the one member the schema requires, so a served licence always carries it. Unset keeps the bundled `info.license`. Its members (`name`, `url`) carry their own rows. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`.", "children": { "name": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "The license identifier an OpenAPI `info.license` would carry. ⚠️ The one member of either container that is REQUIRED by its own schema (`z.string()`, no `.optional()`), so authoring a `license` object at all forces a value the runtime then ignores. No consumer: the served `info.license.name` is the literal 'Apache-2.0' from packages/spec/scripts/build-openapi.ts. Drilled rather than left riding on its container's blanket verdict, and NOT by fanning the parent's status out over its children — the call graph for this key is genuinely closed, by a structural argument rather than by a spelling sweep. ⛔ A spelling sweep would be worthless here and saying so is the point: `name` / `url` / `email` are far too generic to grep, so an absence of hits would carry no information. What carries information is that the only object this key can be reached through is unreachable: `normalizeConfig` copies `documentation` into `RestServer.config.api`, that field is `private`, `NormalizedRestServerConfig` is a module-local type with no `export`, and the container itself has 0 read sites across the whole tracked tree (lit control on the same instrument: twelve sibling keys on the same block return 1-2). Nothing reads the object, so nothing can read a member of it — the verdict is a proof, not a failed search. Measured 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f. Scope: in-repo, plus objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head; the closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo`. REACHABILITY (#15543, standing reading): embedder-only — written only by a host that constructs a RestServerConfig, and no shipped boot path authors this block, so a CLI-started deployment always gets the schema default. `live` answers who READS the key; reachability answers who can SET it." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (served as `info.license.name` inside the authored `license`, which replaces the bundled one whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): served as `info.license.name` whenever the host authors a `license` block, which replaces the bundled one whole; a `license` without this member serves none (never the bundled value). REQUIRED by its own schema whenever `license` is authored, so the served licence always names itself. Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." }, "url": { - "status": "dead", - "verifiedAt": "2026-09-21", + "status": "live", + "verifiedAt": "2026-09-28", "evidenceScope": "in-repo", - "note": "The license URL an OpenAPI `info.license` would carry. No consumer; the served value is the Apache-2.0 literal URL from build-openapi.ts. Drilled rather than left riding on its container's blanket verdict, and NOT by fanning the parent's status out over its children — the call graph for this key is genuinely closed, by a structural argument rather than by a spelling sweep. ⛔ A spelling sweep would be worthless here and saying so is the point: `name` / `url` / `email` are far too generic to grep, so an absence of hits would carry no information. What carries information is that the only object this key can be reached through is unreachable: `normalizeConfig` copies `documentation` into `RestServer.config.api`, that field is `private`, `NormalizedRestServerConfig` is a module-local type with no `export`, and the container itself has 0 read sites across the whole tracked tree (lit control on the same instrument: twelve sibling keys on the same block return 1-2). Nothing reads the object, so nothing can read a member of it — the verdict is a proof, not a failed search. Measured 2026-09-21 on origin/main 31184e5daf3bc48cf51bd9af7d6b05fe9c53ab6f. Scope: in-repo, plus objectui measured clean at the pinned sha 87af769e9a3ee28ace099fdd653d3ebd79fe82e2 and at head; the closed cloud runtime was not reachable from the measuring container, so the declared scope stays `in-repo`. REACHABILITY (#15543, standing reading): embedder-only — written only by a host that constructs a RestServerConfig, and no shipped boot path authors this block, so a CLI-started deployment always gets the schema default. `live` answers who READS the key; reachability answers who can SET it." + "evidence": "packages/rest/src/rest-server.ts#overlayDocumentationInfo (served as `info.license.url` inside the authored `license`, which replaces the bundled one whole) — called by the `openapi.json` handler registerOpenApiEndpoints builds, which the server mounts on BOTH doors (`{apiPath}/openapi.json` and the environment-scoped twin), so both serve the overlay; pinned per door by packages/rest/src/rest-openapi-info-overlay.test.ts", + "producer": "packages/rest/src/rest-server.ts#normalizeConfig (parses `config.api` through `parseDeclaredApiConfig` and lists `documentation: api.documentation` into `this.config.api`, the object the overlay reads; no default is materialized — an unset member stays unset and keeps the bundled `info` value)", + "note": "ENFORCED 2026-09-28 (#20294, ruling B on #20359 — ADR-0049 enforce-or-remove, the ENFORCE half): served as `info.license.url` whenever the host authors a `license` block, which replaces the bundled one whole; a `license` without this member serves none (never the bundled value). A licence authored without it serves no URL at all — ruled pin (4). Pre-enforcement verdict, kept as the record: `dead` — normalized by `RestServer` and read by nothing (0 read sites at 31184e5d, with spread, destructuring and computed access each swept against its own control); re-measured 2026-09-28 on origin/main fc0db22b before the overlay landed: with every identity member authored, both doors served the artifact's `info` unchanged (0 of 8 honoured). objectui at its pin dd3f7e1b: 0 hits for `RestApiConfig` / `RestServerConfig` / `termsOfService` against a lit control (`basePath` = 163), so the renderer never sees this config. REACHABILITY (#15543, standing reading): embedder-only — the one door is `createRestApiPlugin({ api })`, and `os serve` forwards only `enableProjectScoping` / `projectResolution`." } } } diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 9e9edfb7172..cc3aa02fb81 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -62,9 +62,9 @@ for both corollaries. | `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | -| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | +| `rest_api` | 20 | 0 | 0 | 4 | 0 | 24 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 18 | 0 | 0 | 9 | 0 | 27 | -| **total** | **941** | **5** | **1** | **147** | **9** | **1103** | +| **total** | **949** | **5** | **1** | **139** | **9** | **1103** | diff --git a/packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts b/packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts index 907e46e5a6a..ae6c993792b 100644 --- a/packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts +++ b/packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts @@ -5,6 +5,14 @@ * ADR-0049 enforce-or-remove; triage's grade, verbatim: 「Verdict: **RETIRE** * the 4 keys, by the maintainer's criterion」. * + * [#20294] `api.documentation.version` RETIRED too, by ruling B on #20359: + * the block's eight identity members are ENFORCED (they overlay the served + * OpenAPI `info`, pinned in `packages/rest`'s + * `rest-openapi-info-overlay.test.ts`) and `version` alone retires, because + * the served `info.version` is the protocol version (#11646). The same ruling + * made `documentation.title` `.optional()` — its never-served + * `'ObjectStack API'` default is gone — which the CONTROL block below pins. + * * Both sat on `RestApiConfigSchema` (the `api` sub-object of the REST * server's construction argument), were parsed, defaulted and copied into * `RestServer`'s config by `normalizeConfig` — and were read by nothing @@ -51,6 +59,8 @@ const RESPONSE_FORMAT_PRESCRIPTION = /`api\.responseFormat` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*nothing ever read it.*`envelope: false` unwrapped no response.*Delete the key\..*Response shapes are fixed, not a server-wide option/s; const DOCS_ENABLED_PRESCRIPTION = /`api\.documentation\.enabled` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*nothing ever read it.*decided by the sibling `api\.enableOpenApi`.*Delete the key; `api\.enableOpenApi: false` is the switch/s; +const DOCS_VERSION_PRESCRIPTION = + /`api\.documentation\.version` was removed in @objectstack\/spec 17\.5\.0 \(ADR-0049 enforce-or-remove\).*nothing ever read it.*`info\.version` has one source: the protocol version.*`@objectstack\/spec` package.*Delete the key\. To publish your app's own release number, write it into `api\.documentation\.description`/s; describe('rest_api retirement — `api.responseFormat`, at every door that parses the api block', () => { // Every former spelling is refused: the old defaults, the one that "meant" @@ -135,6 +145,55 @@ describe('rest_api retirement — `api.documentation.enabled`, a tombstone insid }); }); +describe('rest_api retirement — `api.documentation.version` (#20294), a second tombstone inside the live block', () => { + // The old authored spellings: a release number, a semver-looking protocol + // version, and an empty string. + for (const version of ['2.3.0', '17.4.0', '']) { + it(`RestApiConfigSchema refuses \`documentation.version: ${JSON.stringify(version)}\` at its path, with the prescription`, () => { + const r = RestApiConfigSchema.safeParse({ documentation: { title: 'Acme Orders API', version } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === 'documentation.version'); + expect(issue, 'the refusal must locate `documentation.version`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['documentation', 'version']); + expect(issue!.message).toMatch(DOCS_VERSION_PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`api.documentation.version` was removed')).toBe(true); + // Only the retired member is diagnosed — the enforced `title` beside it parses. + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual(['documentation.version']); + }); + } + + it('the whole-config door refuses it THROUGH `api`, located at `api.documentation.version`', () => { + const r = RestServerConfigSchema.safeParse({ api: { documentation: { version: '2.3.0' } } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === 'api.documentation.version'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch(DOCS_VERSION_PRESCRIPTION); + }); + + it('fails tsc at the authoring site: the input type is `never`', () => { + const authored: RestApiConfig = { + documentation: { + title: 'Acme Orders API', + // @ts-expect-error — `documentation.version` is a retiredKey() tombstone: its input type is `never`. + version: '2.3.0', + }, + }; + expect(() => RestApiConfigSchema.parse(authored)).toThrow(DOCS_VERSION_PRESCRIPTION); + }); + + it('`api.version` — the route identifier, a different key — is untouched by the tombstone', () => { + const r = RestApiConfigSchema.safeParse({ version: 'v2', documentation: { title: 'Acme Orders API' } }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.version).toBe('v2'); + }); +}); + describe('rest_api retirement — CONTROL: the live keys are untouched', () => { it('a config without the retired keys parses; the replacement switch and the block\'s siblings keep their values', () => { const r = RestApiConfigSchema.safeParse({ @@ -142,7 +201,6 @@ describe('rest_api retirement — CONTROL: the live keys are untouched', () => { documentation: { title: 'ObjectStack API', description: 'd', - version: '1.0.0', termsOfService: 'https://example.com/terms', contact: { name: 'API Support', email: 'api@example.com' }, license: { name: 'MIT' }, @@ -152,11 +210,11 @@ describe('rest_api retirement — CONTROL: the live keys are untouched', () => { if (!r.success) return; // The switch the prescription names is live and keeps an authored `false`. expect(r.data.enableOpenApi).toBe(false); - // `documentation`'s other members parse byte-identically to before. + // `documentation`'s enforced members parse byte-identically to before + // (`version` left them in #20294 — its refusal is pinned above). expect(r.data.documentation).toEqual({ title: 'ObjectStack API', description: 'd', - version: '1.0.0', termsOfService: 'https://example.com/terms', contact: { name: 'API Support', email: 'api@example.com' }, license: { name: 'MIT' }, @@ -169,8 +227,15 @@ describe('rest_api retirement — CONTROL: the live keys are untouched', () => { expect(empty.enableOpenApi, 'the live switch still defaults on').toBe(true); // A present `documentation` block no longer grows `enabled: true`. const doc = RestApiConfigSchema.parse({ documentation: {} }).documentation; - expect(doc).toEqual({ title: 'ObjectStack API' }); expect(doc).not.toHaveProperty('enabled'); + // [#20294] ...nor `title: 'ObjectStack API'`: `title` is `.optional()`, + // because the served `info` is overlaid from this block and that default + // was never the served title (the bundled one, 'ObjectStack REST API', + // is). An empty block parses to an empty block — nothing authored, + // nothing overlaid. + expect(doc).toEqual({}); + expect(doc).not.toHaveProperty('title'); + expect(RestApiConfigSchema.parse({ documentation: { description: 'd' } }).documentation).toEqual({ description: 'd' }); }); }); @@ -188,6 +253,16 @@ describe('rest_api retirement — ADR-0087 registration', () => { // key would be a strip with nothing to strip. expect(step.conversionIds.filter((id) => /response-format|documentation-enabled/.test(id))).toEqual([]); }); + + it('[#20294] declares `documentation.version` under major 18 with its own family D3 entry, and no D2 conversion', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('api/RestApiConfig:documentation.version'); + const step = MIGRATIONS_BY_MAJOR[18]!; + const entry = step.semantic.find((e) => e.id === 'rest-api-documentation-version-retired'); + expect(entry, 'the family D3 entry must be registered in the step-18 chain').toBeDefined(); + expect(entry!.surface).toBe('restServer.api.documentation.version'); + expect(entry!.replacement).toContain('`api.documentation.description`'); + expect(step.conversionIds.filter((id) => /documentation-version/.test(id))).toEqual([]); + }); }); // ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── @@ -208,7 +283,9 @@ describe('rest_api retirement — ADR-0087 registration', () => { // is an object literal (or YAML mapping) that is the VALUE of a // `responseFormat` key and carries one of the retired members // (`envelope` / `includeMetadata` / `includePagination`), or the value of a -// `documentation` key that carries `enabled`. Nothing else. +// `documentation` key that carries `enabled` or (since #20294) `version`. +// Nothing else — the route identifier `api.version` is a sibling of +// `documentation`, never inside it, so it does not match. // // The bound, stated: a block assembled by SPREAD or computed keys, and a YAML // flow mapping (`responseFormat: { envelope: false }` on one YAML line), are @@ -246,9 +323,12 @@ describe('tree-scoped absence: no `api` block inside the declared radius still a /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + /** [#20294] `version` joined `enabled` as a retired `documentation` member. */ + const DOCUMENTATION_RETIRED_MEMBERS = ['enabled', 'version']; + const isOffender = (parentKey: string | undefined, keys: Set): boolean => (parentKey === 'responseFormat' && RESPONSE_FORMAT_MEMBERS.some((k) => keys.has(k))) - || (parentKey === 'documentation' && keys.has('enabled')); + || (parentKey === 'documentation' && DOCUMENTATION_RETIRED_MEMBERS.some((k) => keys.has(k))); /** * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame @@ -402,6 +482,10 @@ describe('tree-scoped absence: no `api` block inside the declared radius still a expect(offendersIn('.json', '{ "api": { "responseFormat": { "includePagination": false } } }')).toEqual([1]); expect(offendersIn('.yaml', 'api:\n documentation:\n title: X\n enabled: false\n')).toEqual([2]); expect(offendersIn('.yaml', 'api:\n responseFormat:\n includeMetadata: false\n')).toEqual([2]); + // [#20294] the retired `documentation.version`, in the three syntaxes. + expect(offendersIn('.ts', "createRestApiPlugin({ api: { api: { documentation: { title: 'X', version: '2.3.0' } } } } as never)")).toEqual([1]); + expect(offendersIn('.json', '{ "api": { "documentation": { "version": "1.0.0" } } }')).toEqual([1]); + expect(offendersIn('.yaml', 'api:\n documentation:\n title: X\n version: 1.0.0\n')).toEqual([2]); expect(offendersIn('.md', 'Prose.\n\n```ts\nnew RestServer(s, p, { api: { responseFormat: { envelope: false } } });\n```\n')).toEqual([4]); // Neighbours that must NOT match. // The agent alias map spells `responseFormat` with a STRING value. @@ -420,6 +504,10 @@ describe('tree-scoped absence: no `api` block inside the declared radius still a expect(offendersIn('.ts', 'const s = "{ responseFormat: { envelope: false } }";')).toEqual([]); // A YAML `documentation` mapping whose `enabled` belongs to a nested block. expect(offendersIn('.yaml', 'documentation:\n title: X\n contact:\n enabled: true\n')).toEqual([]); + // [#20294] the route identifier `api.version` sits BESIDE `documentation`, not in it. + expect(offendersIn('.ts', "({ api: { version: 'v1', documentation: { title: 'X' } } })")).toEqual([]); + // The enforced members alone are not an authoring of a retired key. + expect(offendersIn('.ts', "({ documentation: { title: 'X', description: 'd', license: { name: 'MIT' } } })")).toEqual([]); }); it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { @@ -433,7 +521,7 @@ describe('tree-scoped absence: no `api` block inside the declared radius still a expect(vanished.length).toBe(before + 1); }); - it('no `api` block authoring `responseFormat` or `documentation.enabled` survives inside the declared radius', () => { + it('no `api` block authoring `responseFormat`, `documentation.enabled` or `documentation.version` survives inside the declared radius', () => { const offenders: string[] = []; let visited = 0; let bearing = 0; diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index 26bd6e5e720..2af534c31f9 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -114,10 +114,11 @@ describe('RestApiConfigSchema', () => { }); describe('Documentation Configuration', () => { - // `documentation.enabled` is a retiredKey() tombstone since #20295 — its - // refusal, prescription and tsc pins live in - // `rest-api-config-dead-keys-retirement.test.ts`. The block's other - // members are live-parsed here, unchanged. + // `documentation.enabled` (#20295) and `documentation.version` (#20294) are + // retiredKey() tombstones — their refusal, prescription and tsc pins live + // in `rest-api-config-dead-keys-retirement.test.ts`. The block's other + // members are live-parsed here; since #20294 they overlay the served + // OpenAPI `info` (`packages/rest`, `rest-openapi-info-overlay.test.ts`). it('should accept basic documentation config', () => { const config = RestApiConfigSchema.parse({ documentation: { @@ -135,7 +136,9 @@ describe('RestApiConfigSchema', () => { documentation: { title: 'ObjectStack API', description: 'Complete API for ObjectStack platform', - version: '1.0.0', + // No `version`: it is a retiredKey() tombstone since #20294 — the + // served `info.version` is the protocol version. Its refusal pins + // live in `rest-api-config-dead-keys-retirement.test.ts`. termsOfService: 'https://example.com/terms', contact: { name: 'API Support', @@ -645,7 +648,6 @@ describe('Integration Tests', () => { documentation: { title: 'ObjectStack API', description: 'REST API for ObjectStack platform', - version: '1.0.0', }, }, crud: { diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 32ebfe55ec1..e69191ec8f6 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -201,7 +201,19 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ ), /** - * API documentation configuration + * The publisher's identity on the served OpenAPI document (#20294, ruling B + * on #20359; ADR-0049 enforce-or-remove). Each member an author sets + * overlays the document's `info` on BOTH doors that serve it — + * `{apiPath}/openapi.json` and its environment-scoped twin — in + * `packages/rest`'s `registerOpenApiEndpoints`; a member left unset keeps + * the bundled artifact's value, so nothing authored serves `info` + * byte-identical to `@objectstack/spec/openapi.json` (the #11646 invariant, + * now the unset case). `contact` and `license` replace the bundled object + * WHOLE, never member by member: a document must not state one party's + * licence name at another party's licence URL. `info.version` is not + * publisher identity — it is the protocol version, owned here (#11646) — so + * `version` is a tombstone, and neither `api.version` nor the runtime + * version ever reaches it. */ documentation: z.object({ /** @@ -212,8 +224,9 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * `enableOpenApi` at the mount (`registerRoutes`). Tombstoned rather than * deleted: this inline object is a non-strict `z.object()`, so a bare * deletion would strip `enabled: false` in silence and the author would - * keep believing the document is off (ADR-0104). Only this key retires - * here; the block's other members are a separate decision. + * keep believing the document is off (ADR-0104). Only this key retired + * then; the block's other members were decided by #20294 (the identity + * members enforced, `version` retired below). */ enabled: retiredKey( '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — ' @@ -222,20 +235,49 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ + '`api.enableOpenApi: false` is the switch that leaves the `/openapi.json` document and its `/docs` ' + 'viewer unmounted.', ), - title: z.string().default('ObjectStack API').describe('API documentation title'), - description: z.string().optional().describe('API description'), - version: z.string().optional().describe('Documentation version'), - termsOfService: z.string().optional().describe('Terms of service URL'), + // [#20294] `.optional()`, no longer `.default('ObjectStack API')`: that + // default was materialized into every present block and never served + // (the served title was, and unset still is, the bundled artifact's + // 'ObjectStack REST API'), so reading it would have retitled the document + // of an author who wrote only, say, `description`. + title: z.string().optional() + .describe('Title of the served OpenAPI document (`info.title`); unset keeps the bundled title'), + description: z.string().optional() + .describe('Description of the served OpenAPI document (`info.description`); unset keeps the bundled description. Your app\'s own release number belongs here'), + /** + * [REMOVED in #20294] Retired by ruling B on #20359 (ADR-0049 + * enforce-or-remove): the served `info.version` is the protocol version — + * `SPEC_VERSION`, written by `build-openapi.ts` — as the #11646 ruling + * settled it, so a publisher-set version would give the field a third + * meaning after the route identifier and the runtime version. + * `normalizeConfig` copied this key into the server's config and nothing + * read it back. Tombstoned rather than deleted: this inline object is a non-strict + * `z.object()`, so a bare deletion would strip `version: '2.3.0'` in + * silence and the author would keep believing the document carries it + * (ADR-0104). + */ + version: retiredKey( + '`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.', + ), + termsOfService: z.string().optional() + .describe('Terms-of-service URL of the served OpenAPI document (`info.termsOfService`); unset serves none'), contact: z.object({ - name: z.string().optional(), - url: z.string().optional(), - email: z.string().optional(), - }).optional(), + name: z.string().optional().describe('Contact name (`info.contact.name`)'), + url: z.string().optional().describe('Contact URL (`info.contact.url`)'), + email: z.string().optional().describe('Contact email (`info.contact.email`)'), + }).optional() + .describe('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'), license: z.object({ - name: z.string(), - url: z.string().optional(), - }).optional(), - }).optional().describe('OpenAPI/Swagger documentation config'), + name: z.string().describe('License name (`info.license.name`)'), + url: z.string().optional().describe('License URL (`info.license.url`)'), + }).optional() + .describe('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'), + }).optional() + .describe('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'), /** * [REMOVED in #20295] Server-wide toggles for the response envelope diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.version.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.version.ts new file mode 100644 index 00000000000..5e8dc3bc90d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.version.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20294 — ruling B on #20359 (family `rest-api-documentation`, rank 8 of the +// #18900 census): the eight identity members of `api.documentation` are +// ENFORCED (they overlay the served OpenAPI `info`) and `version` alone is +// RETIRED, because the served `info.version` is the protocol version and #11646 +// settled that nothing configured overrides it. Tombstoned with `retiredKey()` +// inside the live `documentation` block, next to the #20295 `enabled` +// tombstone. No D2 conversion: a `RestServerConfig` is plugin TS configuration, +// never a stack collection member or a `sys_metadata` row. D3 semantic entry +// `rest-api-documentation-version-retired`. Registered under 18 for the +// launch-window reason its neighbours state. +// +// Nested key of an inline block — no `authorable-surface/` line of its own. +export const entry = 'api/RestApiConfig:documentation.version'; diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-api-documentation-version-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-api-documentation-version-retired.ts new file mode 100644 index 00000000000..4b1ad93cc40 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.rest-api-documentation-version-retired.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20294 (family `rest-api-documentation`, rank 8 of the #18900 census) — the +// D3 entry of the family's RETIRE half, under ruling B on #20359: the eight +// identity members of `api.documentation` are enforced (they overlay the served +// OpenAPI `info` on both doors) and `documentation.version` is retired. One D3 +// entry per retirement family (ruling B on #17152). Registered key: +// `api/RestApiConfig:documentation.version`. No D2 conversion: a +// `RestServerConfig` is plugin TS configuration, never a stack collection member +// or a stored row (the `rest-api-config-dead-keys-retired` precedent on this +// same block), so this entry is where the prescription reaches +// `os migrate meta`, the upgrade guide and `spec-changes.json`. +export const entry: SemanticMigration = { + id: 'rest-api-documentation-version-retired', + surface: 'restServer.api.documentation.version', + replacement: + '(removed — delete the key. The served OpenAPI document\'s `info.version` is the protocol version, ' + + 'i.e. the version of the `@objectstack/spec` package that generated the document, with no configured ' + + 'override. An app that wants to publish ' + + 'its own release number writes it into `api.documentation.description`, which the served ' + + '`info.description` now carries.)', + reason: + 'The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and ' + + 'copied it into the REST server\'s config, and no site read it back, so `version: \'2.3.0\'` never ' + + 'reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who ' + + 'owns each field. The title, description, terms of service, contact and license are the publisher\'s ' + + 'identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the ' + + 'served `info.version` equal the published artifact\'s, so an integrator can read which protocol ' + + 'version they are talking to, and it removed the serve-time override that had made the field mean the ' + + 'route identifier. A publisher-set version would give the field a third meaning, so the key is ' + + 'retired instead of enforced. `RestApiConfigSchema`\'s inline `documentation` block is a non-strict ' + + '`z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED ' + + 'note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed ' + + 'its integrators read that number from the document, and only that host knows whether any client was ' + + 'built on the belief and where the number should be published instead.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin carries `api.documentation.version` — a config ' + + 'that does now fails `RestServer` construction (and so the REST plugin\'s `start`) with the retirement ' + + 'prescription, naming the key and `RestApiConfigSchema`, instead of being accepted and ignored; `tsc` ' + + 'refuses the key at the authoring site (`never`). `GET {apiPath}/openapi.json` and its ' + + 'environment-scoped twin serve `info.version` equal to the one the bundled ' + + '`@objectstack/spec/openapi.json` carries, whatever the config says. A release number the host still ' + + 'wants published appears in the served `info.description` after it is written into ' + + '`api.documentation.description`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 61578482006..8eba239392a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14186,6 +14186,49 @@ const step18: MigrationStep = { + 'REST surface is unchanged: ' + 'neither key ever reached it.', }, + // #20294 (family `rest-api-documentation`, rank 8 of the #18900 census) — the + // D3 entry of the family's RETIRE half, under ruling B on #20359: the eight + // identity members of `api.documentation` are enforced (they overlay the served + // OpenAPI `info` on both doors) and `documentation.version` is retired. One D3 + // entry per retirement family (ruling B on #17152). Registered key: + // `api/RestApiConfig:documentation.version`. No D2 conversion: a + // `RestServerConfig` is plugin TS configuration, never a stack collection member + // or a stored row (the `rest-api-config-dead-keys-retired` precedent on this + // same block), so this entry is where the prescription reaches + // `os migrate meta`, the upgrade guide and `spec-changes.json`. + { + id: 'rest-api-documentation-version-retired', + surface: 'restServer.api.documentation.version', + replacement: + '(removed — delete the key. The served OpenAPI document\'s `info.version` is the protocol version, ' + + 'i.e. the version of the `@objectstack/spec` package that generated the document, with no configured ' + + 'override. An app that wants to publish ' + + 'its own release number writes it into `api.documentation.description`, which the served ' + + '`info.description` now carries.)', + reason: + 'The `rest_api` liveness census found `documentation.version` `dead`: `normalizeConfig` parsed it and ' + + 'copied it into the REST server\'s config, and no site read it back, so `version: \'2.3.0\'` never ' + + 'reached the served document. Enforce-or-remove (ADR-0049) split the `documentation` block by who ' + + 'owns each field. The title, description, terms of service, contact and license are the publisher\'s ' + + 'identity and are now enforced. `info.version` is a fact of the protocol: an earlier ruling made the ' + + 'served `info.version` equal the published artifact\'s, so an integrator can read which protocol ' + + 'version they are talking to, and it removed the serve-time override that had made the field mean the ' + + 'route identifier. A publisher-set version would give the field a third meaning, so the key is ' + + 'retired instead of enforced. `RestApiConfigSchema`\'s inline `documentation` block is a non-strict ' + + '`z.object()`, so the key is a `retiredKey()` tombstone and its ledger row stays `dead` with a REMOVED ' + + 'note. The consumer still owes the judgment because a host that WROTE `documentation.version` believed ' + + 'its integrators read that number from the document, and only that host knows whether any client was ' + + 'built on the belief and where the number should be published instead.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin carries `api.documentation.version` — a config ' + + 'that does now fails `RestServer` construction (and so the REST plugin\'s `start`) with the retirement ' + + 'prescription, naming the key and `RestApiConfigSchema`, instead of being accepted and ignored; `tsc` ' + + 'refuses the key at the authoring site (`never`). `GET {apiPath}/openapi.json` and its ' + + 'environment-scoped twin serve `info.version` equal to the one the bundled ' + + '`@objectstack/spec/openapi.json` carries, whatever the config says. A release number the host still ' + + 'wants published appears in the served `info.description` after it is written into ' + + '`api.documentation.description`.', + }, { id: 'rest-api-endpoint-handler-status-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -18108,6 +18151,19 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api.enableOpenApi` decides the mount, and this key was consulted nowhere. // Nested key of an inline block — no `authorable-surface/` line of its own. 'api/RestApiConfig:documentation.enabled', + // #20294 — ruling B on #20359 (family `rest-api-documentation`, rank 8 of the + // #18900 census): the eight identity members of `api.documentation` are + // ENFORCED (they overlay the served OpenAPI `info`) and `version` alone is + // RETIRED, because the served `info.version` is the protocol version and #11646 + // settled that nothing configured overrides it. Tombstoned with `retiredKey()` + // inside the live `documentation` block, next to the #20295 `enabled` + // tombstone. No D2 conversion: a `RestServerConfig` is plugin TS configuration, + // never a stack collection member or a `sys_metadata` row. D3 semantic entry + // `rest-api-documentation-version-retired`. Registered under 18 for the + // launch-window reason its neighbours state. + // + // Nested key of an inline block — no `authorable-surface/` line of its own. + 'api/RestApiConfig:documentation.version', // #20295 — ADR-0049 enforce-or-remove on the `api` sub-object of // `RestServerConfig`, executing the `rest_api` liveness census (#14640: every // member of the block `dead`, 0 read sites outside `normalizeConfig` and the