diff --git a/.changeset/20295-rest-api-config-dead-keys-retired.md b/.changeset/20295-rest-api-config-dead-keys-retired.md new file mode 100644 index 00000000000..df9b34b26a1 --- /dev/null +++ b/.changeset/20295-rest-api-config-dead-keys-retired.md @@ -0,0 +1,74 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +--- + +feat(spec,rest)!: retire `api.responseFormat` and `api.documentation.enabled` — parsed, defaulted, and read by nothing (#20295) + +Clause-②: no (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). + +Two keys of `RestServerConfig.api` (`RestApiConfigSchema`) were accepted, given +defaults and copied into the REST server's config by `normalizeConfig` — and no +site ever read them back. `responseFormat` (`envelope`, `includeMetadata`, +`includePagination`) toggled nothing: `envelope: false` unwrapped no response. +`documentation.enabled` was a second on/off switch for the OpenAPI document that +nothing consulted: `api.enableOpenApi` decides that mount. Measured before +removal, each against a lit control on the same instrument: no reader in this +repo's packages, and no author in objectui at its pinned commit or in cloud. +ADR-0049 enforce-or-remove; the verdict is RETIRE, by the maintainer's criterion — +mainstream data APIs keep a fixed response envelope that no administrator toggles +server-wide, and the OpenAPI switch already exists and is enforced. + +``` +FROM new RestServer(server, protocol, { api: { responseFormat: { envelope: false } } }) + -> constructed; `envelope: false` changed nothing +TO -> throws: REST API configuration is invalid: `api` does not satisfy + `RestApiConfigSchema` … + - api.responseFormat: `api.responseFormat` was removed in @objectstack/spec 17.5.0 + (ADR-0049 enforce-or-remove) — … Delete the key. Response shapes are fixed, … + +FROM RestApiConfigSchema.parse({ documentation: { enabled: false, title: 'My API' } }) + -> { documentation: { enabled: false, title: 'My API' }, … } // served the document anyway +TO -> ZodError { code: 'invalid_type', path: ['documentation', 'enabled'], + message: '`api.documentation.enabled` was removed in @objectstack/spec 17.5.0 (ADR-0049 + enforce-or-remove) — … Delete the key; `api.enableOpenApi: false` is the switch …' } +``` + +**Fix.** `api.responseFormat` → delete the key; response shapes are fixed, so +there is nothing to configure. `api.documentation.enabled` → delete the key; to +serve no OpenAPI document, set `api.enableOpenApi: false` (it leaves +`GET /openapi.json` and `GET /docs` unmounted). `tsc` refuses both keys at the +authoring site (their input type is `never`). + +**What does not change.** Every live key of the `api` block parses exactly as +before, including the rest of `documentation` (`title`, `description`, +`version`, `termsOfService`, `contact`, `license` — a separate decision). A +config without the two keys mounts the same REST surface: neither key ever +reached it. A `documentation` block no longer grows an `enabled: true` default. + +### The retirement kit + +- **Schema.** `RestApiConfigSchema` and its inline `documentation` object are + non-strict `z.object()`s, so each key is a `retiredKey()` tombstone carrying its + prescription (a bare deletion would have stripped it in silence). + `responseFormat` retires whole — its three members were its only members. +- **REST server.** `normalizeConfig` runs the tombstones (the `crud.patterns` + posture, not `requireAuth`'s warn-and-ignore), so a config carrying either key + now fails `RestServer` construction and the REST plugin's `start` with the + prescription, and the normalized config no longer carries or re-defaults them. +- **ADR-0087.** `RETIRED_KEYS_BY_MAJOR[18]` gains `api/RestApiConfig:responseFormat` + and `api/RestApiConfig:documentation.enabled`. No D2 conversion: a + `RestServerConfig` is plugin TS configuration, never a stack collection member or + a stored row. The family's D3 entry, `rest-api-config-dead-keys-retired`, carries + the prescription to `os migrate meta` and the upgrade guide. +- **Ledger and docs.** `liveness/rest_api.json` keeps both rows `dead` with a + REMOVED note (`responseFormat`'s three child rows collapse into its one row); + the generated `state-counts.md` moves `rest_api` from 14 to 12 dead; the + reference page for `rest-server` is regenerated. + + diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index fa5e13fccbc..82a58bee0d1 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -249,14 +249,14 @@ 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** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | -| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | +| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config | +| **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` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Enable API documentation | +| **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 | @@ -264,14 +264,6 @@ const result = BatchEndpointsConfigSchema.parse(data); | **contact** | `{ name?: string; url?: string; email?: string }` | optional | | | **license** | `{ name: string; url?: string }` | optional | | -### Nested Shape: `RestApiConfig.responseFormat` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **envelope** | `boolean` | optional (default: `true`) | Wrap responses in standard envelope | -| **includeMetadata** | `boolean` | optional (default: `true`) | Include response metadata (timestamp, requestId) | -| **includePagination** | `boolean` | optional (default: `true`) | Include pagination info in list responses | - --- @@ -305,8 +297,8 @@ 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** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | -| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | +| **documentation** | `{ title: string; description?: string; version?: string; termsOfService?: string; … }` | optional | OpenAPI/Swagger documentation config | +| **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/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 6f730cddc09..e4d205a396d 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 78 | -| `api/` | 432 | +| `api/` | 431 | | `identity/` | 32 | | `integration/` | 8 | | `kernel/` | 247 | 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 new file mode 100644 index 00000000000..de1ba71b327 --- /dev/null +++ b/packages/rest/src/rest-api-config-dead-keys-refused.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20295] The REST server REFUSES the retired `api.responseFormat` and + * `api.documentation.enabled` at construction — ADR-0049 enforce-or-remove. + * + * Before: both were parsed by `parseDeclaredApiConfig`, defaulted, and copied + * into `this.config.api` by `normalizeConfig`, where nothing read them back — + * `responseFormat.envelope: false` unwrapped no response and + * `documentation.enabled: false` turned no document off (`api.enableOpenApi` + * decides that mount). Now both are `retiredKey()` tombstones on + * `RestApiConfigSchema`, and this seam runs that schema, so the tombstone's + * refusal reaches the operator at boot — the `crud.patterns` posture + * (`rest-sub-config-parse-not-cast.test.ts` §E), NOT `requireAuth`'s + * warn-and-ignore `.omit()`. + * + * ⛔ 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 + * REAL `RestServer` construction or the real plugin composition, so what it + * measures is whether the SERVER refuses. `refusal()` answers `''` when + * construction succeeds, and `''` contains no key name, so every `toContain` + * below is its own positive control. + * + * On the assertion set: this is a construction-time refusal, not an HTTP + * answer — nothing is mounted yet, so there is no ADR-0112 `code` / `status` + * envelope to assert. The strongest set this door has is: refused, the located + * key (`api.`), the declaring schema's name, and the prescription. + * + * This file is one of the two the retirement's tree-scoped absence pin + * excludes by name: its JOB is to author the retired keys. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server.js'; +import { createRestApiPlugin } from './rest-api-plugin.js'; + +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; +} + +/** Construct the real server with `api` as given — the seam under test. */ +function construct(api: Record) { + return new RestServer(makeServer(), makeProtocol(), { api } as any); +} + +/** The construction refusal's message, or `''` when the server constructed. */ +function refusal(api: Record): string { + try { + construct(api); + return ''; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +/** The normalized `api` block, read off the constructed server. */ +const normalizedApi = (rest: unknown) => + (rest as { config: { api: Record } }).config.api; + +/** The mounted `METHOD path` set of a constructed server. */ +function mounted(api: Record): string[] { + const rest = construct(api); + rest.registerRoutes(); + return rest.getRoutes().map((r: any) => `${r.method} ${r.path}`).sort(); +} + +function bootCtx() { + const services: Record = { 'http.server': makeServer(), protocol: makeProtocol() }; + return { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }), + getServices: vi.fn(() => new Map(Object.entries(services))), + hook: vi.fn(), + trigger: vi.fn().mockResolvedValue(undefined), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getKernel: vi.fn(), + } as any; +} + +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; + +describe('[#20295] RestServer construction refuses the retired `api` keys', () => { + it('refuses `api.responseFormat` — every former spelling, the old defaults and the empty block included', () => { + for (const responseFormat of [ + { envelope: false }, + { envelope: true, includeMetadata: true, includePagination: true }, + { includeMetadata: false }, + {}, + ]) { + const label = JSON.stringify(responseFormat); + const message = refusal({ responseFormat }); + expect(message, label).toContain(' - api.responseFormat: '); + expect(message, label).toContain('RestApiConfigSchema'); + expect(message, label).toMatch(RESPONSE_FORMAT_PRESCRIPTION); + } + }); + + it('refuses `api.documentation.enabled` — both values — while its siblings in the block are untouched', () => { + for (const enabled of [false, true]) { + const message = refusal({ documentation: { title: 'My API', enabled } }); + expect(message, String(enabled)).toContain(' - api.documentation.enabled: '); + expect(message, String(enabled)).toContain('RestApiConfigSchema'); + expect(message, String(enabled)).toMatch(DOCS_ENABLED_PRESCRIPTION); + // Only the retired member is diagnosed — `title` is not named. + expect(message, String(enabled)).not.toContain('api.documentation.title'); + } + }); + + it('a retired-key refusal never diagnoses `api.version` — a key this config did not write', () => { + const message = refusal({ responseFormat: { envelope: false } }); + expect(message, 'positive control: the refusal is present').toContain('api.responseFormat'); + expect(message).not.toContain('/api//'); + expect(message).not.toContain('api.version'); + }); + + it('the plugin path refuses the same keys (both cast hops)', async () => { + await expect( + createRestApiPlugin({ api: { api: { responseFormat: { envelope: false } } } } as never).start!(bootCtx()), + ).rejects.toThrow(/api\.responseFormat.*was removed/s); + await expect( + createRestApiPlugin({ api: { api: { documentation: { enabled: false } } } } as never).start!(bootCtx()), + ).rejects.toThrow(/api\.documentation\.enabled.*was removed/s); + }); +}); + +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(); + await expect( + createRestApiPlugin({ api: { api: { documentation: { title: 'My API' } } } } as never).start!(ctx), + ).resolves.toBeUndefined(); + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); + + it('normalizeConfig neither forwards nor re-defaults either retired key', () => { + const api = normalizedApi(construct({ documentation: { title: 'My API' } })); + expect(api).not.toHaveProperty('responseFormat'); + expect(api.documentation, 'the live members pass through with their declared defaults only').toEqual({ title: 'My API' }); + expect(api.documentation as object).not.toHaveProperty('enabled'); + }); + + it('`api.enableOpenApi` is the OpenAPI switch the prescription names — it really unmounts the document', () => { + const on = mounted({}); + const off = mounted({ enableOpenApi: false }); + // Positive control: the default mounts both routes the switch owns. + expect(on).toContain('GET /api/v1/openapi.json'); + expect(on).toContain('GET /api/v1/docs'); + expect(on.filter((r) => !off.includes(r)).sort()).toEqual(['GET /api/v1/docs', 'GET /api/v1/openapi.json']); + }); + + it('a `documentation` block without `enabled` mounts exactly the default surface — the key never reached it', () => { + expect(mounted({ documentation: { title: 'Renamed', description: 'd' } })).toEqual(mounted({})); + }); +}); 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 7c3a274e159..c3defae6430 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 @@ -36,7 +36,8 @@ * 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` / `responseFormat` — live in + * reach `documentation` (whose retired `enabled` member, like the retired + * `responseFormat` block, is refused rather than defaulted since #20295) — 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 de4c4b29e10..72dc5f3bedb 100644 --- a/packages/rest/src/rest-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-config-parse-not-cast.test.ts @@ -313,10 +313,18 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => { // The measurement the consumption decision rests on: a key the method // read but the schema did not declare would be silently STRIPPED by a // consumed parse, which is the failure #11637 avoided by discarding. - const declared = Object.keys(declaredApi().shape).sort(); + // [#20295] `responseFormat` is a `retiredKey()` tombstone that this + // parse RUNS (and so refuses) rather than `.omit()`s: it stays in the + // declared shape and is, by design, not threaded — the one declared + // key the normalized block does not carry. + const TOMBSTONES_THE_PARSE_REFUSES = ['responseFormat']; + const declared = Object.keys(declaredApi().shape) + .filter((k) => !TOMBSTONES_THE_PARSE_REFUSES.includes(k)) + .sort(); const normalized = Object.keys(normalizedApi(construct({}))).sort(); expect(normalized).toEqual(declared); expect(declared, 'the retired tombstone stays out of the parsed shape').not.toContain('requireAuth'); + expect(Object.keys(declaredApi().shape), 'the refused tombstone is still IN the shape the seam runs').toContain('responseFormat'); }); it('an authored value still wins over the schema default', () => { @@ -341,16 +349,17 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => { (declaredApi().parse({ documentation: { description: 'd' } }) as { documentation: unknown }).documentation, ); expect(doc.description, 'the authored key survives').toBe('d'); - expect(doc.enabled, 'and the declared inner default arrives with it').toBe(true); + expect(doc.title, 'and the declared inner default arrives with it').toBe('ObjectStack API'); + // [#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. + expect(doc, 'the retired switch is not re-defaulted').not.toHaveProperty('enabled'); }); - it('THE BOUNDED DELTA: an authored `responseFormat` does the same', () => { - const rf = normalizedApi(construct({ responseFormat: { envelope: false } })) - .responseFormat as Record; - expect(rf.envelope, 'the authored key survives').toBe(false); - expect(rf.includeMetadata).toBe(true); - expect(rf.includePagination).toBe(true); - }); + // [#20295] `THE BOUNDED DELTA: an authored \`responseFormat\` does the same` + // used to live here. The whole block is a retired tombstone now, so the + // SERVER refuses it at construction instead of filling its inner defaults — + // pinned, with the prescription, in `rest-api-config-dead-keys-refused.test.ts`. it('an ABSENT optional object stays absent — the parse does not materialize it', () => { // The bound on the delta above: `.optional()` without `.default()` @@ -358,7 +367,8 @@ describe('[#14366] §D the `api` sub-object consumes the parsed output', () => { // `documentation` block would change what nothing-authored means. const api = normalizedApi(construct({})); expect(api.documentation).toBeUndefined(); - expect(api.responseFormat).toBeUndefined(); + // [#20295] the retired `responseFormat` is not threaded at all. + expect(api).not.toHaveProperty('responseFormat'); expect(api.apiPath).toBeUndefined(); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f27986fa886..abf25fa0e10 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1115,12 +1115,13 @@ type NormalizedRestServerConfig = { enableProjectScoping: boolean; projectResolution: 'required' | 'optional' | 'auto'; // [#14366] The PARSED shape, not the authored one: this block is - // built from `RestApiConfigSchema`'s output, so a `documentation` or - // `responseFormat` the caller wrote arrives with its OWN declared - // inner defaults applied (`documentation.enabled`, `.title`; - // `responseFormat.envelope`, `.includeMetadata`, `.includePagination`). + // built from `RestApiConfigSchema`'s output, so a `documentation` the + // caller wrote arrives with its OWN declared inner defaults applied + // (`.title`). [#20295] `documentation.enabled` and the whole + // `responseFormat` block are `retiredKey()` tombstones now — the parse + // REFUSES them at construction, so neither is carried here and + // neither is re-defaulted. documentation: RestApiConfigParsed['documentation']; - responseFormat: RestApiConfigParsed['responseFormat']; }; crud: { operations: { @@ -4167,6 +4168,18 @@ export class RestServer { * observes it today — but it is a real change to this structure's * contents and belongs in the record rather than in a reader's surprise. * + * [#20295] Two of those keys then left under ADR-0049 + * enforce-or-remove: `responseFormat` (the whole block) and + * `documentation.enabled` are `retiredKey()` tombstones, so this parse + * REFUSES them at construction with their prescription — the + * `crud.patterns` posture, NOT `requireAuth`'s `.omit()` below, because + * no boot path or shipped config writes either (measured in this repo, + * in objectui at its pin and in cloud) and nothing chose + * warn-and-ignore for them. The key diff stays empty with the + * tombstones counted on the schema side only: `normalizeConfig` reads + * 13 keys, the schema declares those 13 plus the `responseFormat` + * tombstone, which parses to nothing and is not threaded. + * * - the retired `api.requireAuth` key is STILL `.omit()`ed rather than enforced. * #3963 retired it with a deliberate warn-and-ignore posture * (`rest-api-plugin.ts`: "is IGNORED"), chosen in a world where nothing @@ -4267,10 +4280,13 @@ export class RestServer { return { // Keys listed rather than spread: `NormalizedRestServerConfig` - // declares `documentation` / `responseFormat` as REQUIRED (possibly - // `undefined`) while the schema declares them `.optional()`, so a - // spread would not satisfy this type — and listing them is also - // what makes the empty key diff readable at the seam it protects. + // declares `documentation` as REQUIRED (possibly `undefined`) + // while the schema declares it `.optional()`, so a spread would + // not satisfy this type — and listing them is also what makes the + // empty key diff readable at the seam it protects. [#20295] The + // retired `responseFormat` tombstone is deliberately NOT listed: + // the parse above refuses it, so there is nothing to forward and + // no default to re-apply. api: { version: api.version, basePath: api.basePath, @@ -4285,7 +4301,6 @@ export class RestServer { enableProjectScoping: api.enableProjectScoping, projectResolution: api.projectResolution, documentation: api.documentation, - responseFormat: api.responseFormat, }, crud: { // Per key, not per object: since ADR-0122 `crud.operations` is the diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index b5bb42f1c97..a35e53ea1b7 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1429,7 +1429,7 @@ "api/RestApiConfig:enableUi", "api/RestApiConfig:projectResolution", "api/RestApiConfig:requireAuth [RETIRED]", - "api/RestApiConfig:responseFormat", + "api/RestApiConfig:responseFormat [RETIRED]", "api/RestApiConfig:version", "api/RestApiEndpoint:cacheTtl [RETIRED]", "api/RestApiEndpoint:cacheTtlSeconds", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 4570cdf9684..f846cf7798c 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 14 = 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), plus the two declared containers `documentation` (drilled to ten, including its nested `contact` / `license`) and `responseFormat` (three) — normalized into `this.config.api` and read back by nothing, so `responseFormat.envelope: false` unwraps no response and `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. `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 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 | | 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/44 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 44 `dead`, re-measured at this head and partitioned so every row is counted exactly once: three declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7), `health` (15, both sub-blocks) — plus `triggers` (6, and the schema's own docblock says so: #3197), the connector's nested `webhooks` (one blanket verdict, recorded in the undrilled baseline), `status`, `metadata`, `actions.description`/`.outputSchema`, and the three top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping` and `connectionTimeoutMs`. That sums to 44, the dead count the generated `state-counts.md` row carries. ⚠️ `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. ⭐ SIX 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`, `fieldMappings.transform`, `triggers.interval` and `health.circuitBreaker.monitoringWindow` — but ⛔ that six is NOT a separate addend: the last three are already inside the `fieldMappings`, `triggers` and `health` counts above, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. 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 363a35e4a93..6175e047620 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.", + "_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.", "props": { "version": { "status": "live", @@ -108,9 +108,9 @@ "children": { "enabled": { "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. a `.default(true)` that enables nothing: the OpenAPI document's existence is decided by the sibling `enableOpenApi` at the mount, and this key is not consulted there or anywhere else. 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-27", + "evidenceScope": "cross-repo", + "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", @@ -185,26 +185,10 @@ } }, "responseFormat": { - "children": { - "envelope": { - "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 `responseFormat: api.responseFormat` straight into `this.config.api` and no site ever reads it back. `api.responseFormat.envelope: false` does not unwrap a single response — the envelope is produced unconditionally and this flag is never consulted. 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 MEAN, recorded so it is not mistaken for a small wiring job: the REST layer produces its envelope unconditionally today, so enforcing this container is not reading a flag — it is making the response envelope configurable, which every SDK, the discovery document and /openapi.json describe. That is a larger claim than `documentation`'s, which is why ⛔ the two containers do not share one verdict. Remove has the worked precedent (#14691). ⛔ This file records the measurement; it does not make the call. 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." - }, - "includeMetadata": { - "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 `responseFormat: api.responseFormat` straight into `this.config.api` and no site ever reads it back. no consumer; response metadata (timestamp, requestId) is not gated on 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 MEAN, recorded so it is not mistaken for a small wiring job: the REST layer produces its envelope unconditionally today, so enforcing this container is not reading a flag — it is making the response envelope configurable, which every SDK, the discovery document and /openapi.json describe. That is a larger claim than `documentation`'s, which is why ⛔ the two containers do not share one verdict. Remove has the worked precedent (#14691). ⛔ This file records the measurement; it does not make the call. 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." - }, - "includePagination": { - "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 `responseFormat: api.responseFormat` straight into `this.config.api` and no site ever reads it back. no consumer; list responses carry their pagination block regardless. 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 MEAN, recorded so it is not mistaken for a small wiring job: the REST layer produces its envelope unconditionally today, so enforcing this container is not reading a flag — it is making the response envelope configurable, which every SDK, the discovery document and /openapi.json describe. That is a larger claim than `documentation`'s, which is why ⛔ the two containers do not share one verdict. Remove has the worked precedent (#14691). ⛔ This file records the measurement; it does not make the call. 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." - } - } + "status": "dead", + "verifiedAt": "2026-09-27", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-27 (#20295) — tombstoned at the schema as ONE key (retiredKey carries the prescription; authoring it is a tsc error and a parse error, and RestServer construction refuses it with that prescription instead of copying it into this.config.api). 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: nothing to configure — response shapes are fixed: each route answers in the response schema packages/spec declares for it, which the client SDK parses and the served /openapi.json describes. The three child rows this container carried (`envelope` / `includeMetadata` / `includePagination`) collapse into this one row: the tombstone is a leaf, and rows for keys that left the walked shape would report ORPHAN (the `crud.patterns` precedent, #14691). Their pre-retirement verdicts, kept as the record — all three `dead`, 0 read sites at 31184e5d, the three blind shapes swept against their own controls: `envelope: false` unwrapped no response (the envelope is produced unconditionally); `includeMetadata` gated no response metadata (timestamp, requestId); `includePagination` gated nothing (list responses carry their pagination block regardless). 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." } } } diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 1400db3e142..54ac2f5bb92 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 | 14 | 0 | 26 | +| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 44 | 1 | 74 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **936** | **5** | **1** | **168** | **9** | **1119** | +| **total** | **936** | **5** | **1** | **166** | **9** | **1117** | 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 new file mode 100644 index 00000000000..907e46e5a6a --- /dev/null +++ b/packages/spec/src/api/rest-api-config-dead-keys-retirement.test.ts @@ -0,0 +1,469 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `api.responseFormat` and `api.documentation.enabled` RETIRED (#20295) — + * ADR-0049 enforce-or-remove; triage's grade, verbatim: 「Verdict: **RETIRE** + * the 4 keys, by the maintainer's criterion」. + * + * 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 + * (`liveness/rest_api.json`, re-measured before removal with lit controls in + * this repo, in objectui at its pin and in cloud). `envelope: false` unwrapped + * no response; `documentation.enabled: false` turned no document off, because + * `api.enableOpenApi` decides that mount. + * + * Bookkeeping shapes, pinned below: + * 1. `responseFormat` retires WHOLE — one `retiredKey()` tombstone for the + * container (the `crud.patterns` precedent): its three members were its + * only members and none was live. `documentation.enabled` is a tombstone + * INSIDE the live `documentation` block, whose other members keep parsing + * (they are a separate decision). + * 2. Tombstones, not bare deletions: both objects are non-strict + * `z.object()`s, so a bare deletion would strip the key in silence + * (ADR-0104). + * 3. No D2 conversion: a `RestServerConfig` is plugin TS configuration, never + * a stack collection member or a stored row. `RETIRED_KEYS_BY_MAJOR[18]` + * carries both keys; the D3 entry `rest-api-config-dead-keys-retired` + * carries the prescription to `os migrate meta` and the upgrade guide. + * 4. The REST server's own construction-time refusal is pinned where it + * lives, in `packages/rest` (`rest-api-config-dead-keys-refused.test.ts`). + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { RestApiConfigSchema, RestServerConfigSchema, type RestApiConfig } from './rest-server.zod'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +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; + +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" + // something, and the empty block — the container itself is the tombstone. + const FORMER_VALUES = [ + { envelope: false }, + { envelope: true, includeMetadata: true, includePagination: true }, + { includePagination: false }, + {}, + ]; + + for (const value of FORMER_VALUES) { + it(`RestApiConfigSchema refuses \`responseFormat: ${JSON.stringify(value)}\` at its path, with the prescription`, () => { + const r = RestApiConfigSchema.safeParse({ responseFormat: value }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'responseFormat'); + expect(issue, 'the refusal must name `responseFormat`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['responseFormat']); + expect(issue!.message).toMatch(RESPONSE_FORMAT_PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`api.responseFormat` was removed')).toBe(true); + }); + } + + it('the whole-config door refuses it THROUGH `api`, located at `api.responseFormat`', () => { + const r = RestServerConfigSchema.safeParse({ api: { responseFormat: { envelope: false } } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === 'api.responseFormat'); + expect(issue, 'the refusal must locate `api.responseFormat`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch(RESPONSE_FORMAT_PRESCRIPTION); + }); + + it('fails tsc at the authoring site: the input type is `never`', () => { + const authored: RestApiConfig = { + version: 'v1', + // @ts-expect-error — `responseFormat` is a retiredKey() tombstone: its input type is `never`. + responseFormat: { envelope: false }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => RestApiConfigSchema.parse(authored)).toThrow(RESPONSE_FORMAT_PRESCRIPTION); + }); +}); + +describe('rest_api retirement — `api.documentation.enabled`, a tombstone inside a live block', () => { + for (const enabled of [false, true]) { + it(`RestApiConfigSchema refuses \`documentation.enabled: ${enabled}\` — the old default included`, () => { + const r = RestApiConfigSchema.safeParse({ documentation: { enabled, title: 'My API' } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === 'documentation.enabled'); + expect(issue, 'the refusal must locate `documentation.enabled`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['documentation', 'enabled']); + expect(issue!.message).toMatch(DOCS_ENABLED_PRESCRIPTION); + expect(issue!.message.startsWith('`api.documentation.enabled` was removed')).toBe(true); + }); + } + + it('the whole-config door refuses it THROUGH `api`, located at `api.documentation.enabled`', () => { + const r = RestServerConfigSchema.safeParse({ api: { documentation: { enabled: false } } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === 'api.documentation.enabled'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch(DOCS_ENABLED_PRESCRIPTION); + }); + + it('fails tsc at the authoring site: the input type is `never`', () => { + const authored: RestApiConfig = { + documentation: { + title: 'My API', + // @ts-expect-error — `documentation.enabled` is a retiredKey() tombstone: its input type is `never`. + enabled: false, + }, + }; + expect(() => RestApiConfigSchema.parse(authored)).toThrow(DOCS_ENABLED_PRESCRIPTION); + }); +}); + +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({ + enableOpenApi: false, + 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' }, + }, + }); + expect(r.success).toBe(true); + 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. + 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' }, + }); + }); + + it('absence stays absence: nothing re-defaults either retired key', () => { + const empty = RestApiConfigSchema.parse({}); + expect(empty).not.toHaveProperty('responseFormat'); + 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'); + }); +}); + +describe('rest_api retirement — ADR-0087 registration', () => { + it('declares both keys under major 18 and carries one D3 entry for the family, with no D2 conversion', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('api/RestApiConfig:responseFormat'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('api/RestApiConfig:documentation.enabled'); + const step = MIGRATIONS_BY_MAJOR[18]!; + const entry = step.semantic.find((e) => e.id === 'rest-api-config-dead-keys-retired'); + expect(entry, 'the family D3 entry must be registered in the step-18 chain').toBeDefined(); + expect(entry!.surface).toBe('restServer.api.responseFormat / restServer.api.documentation.enabled'); + expect(entry!.replacement).toContain('`api.enableOpenApi`'); + // Plugin TS configuration has no stored or stack source for a conversion to + // rewrite — the #14691 / `openApi31` shape. A conversion id naming either + // key would be a strip with nothing to strip. + expect(step.conversionIds.filter((id) => /response-format|documentation-enabled/.test(id))).toEqual([]); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types both keys `never` on +// `RestApiConfig`, so every typed authoring site fails to compile, and the +// REST server refuses both at construction. The residue is what neither +// judges before runtime: JSON, YAML, MD/MDX code fences, untyped `.js`, and TS +// literals cast through `as any` / `as never` (a config handed to the plugin +// the way cloud's hosts do). This walk covers that residue across five roots, +// each already declared for `@objectstack/spec#test` in +// `scripts/cross-package-test-inputs.mjs` and mirrored in `turbo.json` — the +// same roots and extensions the view-item and connector retirement pins walk. +// +// ⭐ `documentation` and `responseFormat` are not unique names (an OpenAPI-style +// `responseFormat` is a plausible future AI key; `documentation` is an ordinary +// word and an error-schema field), so the matcher is STRUCTURAL: an offender +// 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. +// +// The bound, stated: a block assembled by SPREAD or computed keys, and a YAML +// flow mapping (`responseFormat: { envelope: false }` on one YAML line), are +// invisible to this walk; `docs/**`, `.claude/**`, `.github/**` and the +// repo-root files are outside the radius. +describe('tree-scoped absence: no `api` block inside the declared radius still authors the retired keys', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + const RESPONSE_FORMAT_MEMBERS = ['envelope', 'includeMetadata', 'includePagination']; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired keys on an `api` block. + */ + const EXCLUDED = new Set([ + // This pin authors both keys to assert the schema refuses them. + THIS_FILE, + // The REST server's construction-time refusal pins author both keys to + // assert the SERVER refuses them — the other half of the same kit. + 'packages/rest/src/rest-api-config-dead-keys-refused.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const isOffender = (parentKey: string | undefined, keys: Set): boolean => + (parentKey === 'responseFormat' && RESPONSE_FORMAT_MEMBERS.some((k) => keys.has(k))) + || (parentKey === 'documentation' && keys.has('enabled')); + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:` — and remembering the key it is the + * VALUE of (a `{` whose previous significant token is the `:` of a key). + * Strings and comments are skipped; a single- or double-quoted string never + * spans a line, so a mis-lexed quote (a regex literal) costs at most that + * line. Returns the 1-based line of each closing brace whose frame offends. + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set; parentKey?: string }[] = []; + let lastSig = ''; + let justKey: string | undefined; + let colonKey: string | undefined; + let line = 1; + let i = 0; + const n = text.length; + const sawToken = (token: string, j: number) => { + const top = stack[stack.length - 1]; + const isKey = text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ','); + if (isKey) top!.keys.add(token); + justKey = isKey ? token : undefined; + }; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + if (c === '`') justKey = undefined; + else sawToken(token, j); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + sawToken(token, j); + lastSig = 'id'; + continue; + } + if (c === ':') { + colonKey = justKey; + justKey = undefined; + lastSig = ':'; + i += 1; + continue; + } + if (c === '{') stack.push({ kind: c, keys: new Set(), parentKey: lastSig === ':' ? colonKey : undefined }); + else if (c === '[' || c === '(') stack.push({ kind: c, keys: new Set() }); + else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffender(frame.parentKey, frame.keys)) out.push(line); + } + if (!/\s/.test(c)) { lastSig = c; justKey = undefined; } + i += 1; + } + return out; + }; + + /** + * YAML: a block mapping opened by `responseFormat:` / `documentation:` with + * no inline value; its OWN keys are the lines at its first child's column, + * until a line at or above the opener's column. Returns the 1-based line of + * each offending opener. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:/.exec(raw); + if (!m) return null; + const opensBlock = /^\s*(-\s+)?[A-Za-z_][\w]*\s*:\s*(#.*)?$/.test(raw); + return { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, opensBlock }; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row || !row.opensBlock || (row.key !== 'responseFormat' && row.key !== 'documentation')) return; + const keys = new Set(); + let childCol: number | undefined; + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col <= row.col) break; + childCol ??= r.col; + if (r.col === childCol) keys.add(r.key); + } + if (isOffender(row.key, keys)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const mentions = (text: string): boolean => text.includes('responseFormat') || text.includes('documentation'); + + const offendersIn = (ext: string, text: string): number[] => { + if (!mentions(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher finds an authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the retired spelling, in each syntax the walk reads. + expect(offendersIn('.ts', 'createRestApiPlugin({ api: { api: { responseFormat: { envelope: false } } } } as never)')).toEqual([1]); + expect(offendersIn('.ts', "const api = {\n documentation: {\n contact: { name: 'x' },\n enabled: false,\n },\n};")).toEqual([5]); + 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]); + 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. + expect(offendersIn('.ts', "const ALIASES = { output: 'structuredOutput', responseFormat: 'structuredOutput' };")).toEqual([]); + // An OpenAI-style `responseFormat` object carries none of the retired members. + expect(offendersIn('.ts', "chat({ responseFormat: { type: 'json_schema' } })")).toEqual([]); + // `documentation` without `enabled`; an `enabled` NESTED one level down belongs to another block. + expect(offendersIn('.ts', "({ documentation: { title: 'X', contact: { enabled: true } } })")).toEqual([]); + // The schema declaration itself: the `{` is `z.object(`'s argument, not the key's value. + expect(offendersIn('.ts', "documentation: z.object({ enabled: retiredKey('gone') })")).toEqual([]); + // A ternary's `:` is not a key's. + expect(offendersIn('.ts', "const documentation = 1; const v = ok ? documentation : { enabled: true };")).toEqual([]); + // Prose, comments and quoted strings are not authorings. + expect(offendersIn('.md', 'A host once wrote `responseFormat: { envelope: false }` here.')).toEqual([]); + expect(offendersIn('.ts', '// responseFormat: { envelope: false }')).toEqual([]); + 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([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no `api` block authoring `responseFormat` or `documentation.enabled` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let bearing = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (text.includes('responseFormat')) bearing += 1; + for (const lineNo of offendersIn(ext, text)) offenders.push(`${rel}:${lineNo}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk covered the tree, and the files that CAN hold an + // authoring (they spell the key at all) were really judged. + expect(visited).toBeGreaterThan(1000); + expect(bearing).toBeGreaterThan(5); + expect(offenders, 'an `api` block authoring a retired key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index d83ec5039a8..794c13260f3 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -114,22 +114,25 @@ 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. it('should accept basic documentation config', () => { const config = RestApiConfigSchema.parse({ documentation: { - enabled: true, title: 'My API', }, }); - expect(config.documentation?.enabled).toBe(true); expect(config.documentation?.title).toBe('My API'); + // The retired switch is no longer defaulted into the parsed block. + expect(config.documentation).not.toHaveProperty('enabled'); }); it('should accept complete documentation config', () => { const config = RestApiConfigSchema.parse({ documentation: { - enabled: true, title: 'ObjectStack API', description: 'Complete API for ObjectStack platform', version: '1.0.0', @@ -152,33 +155,10 @@ describe('RestApiConfigSchema', () => { }); }); - describe('Response Format Configuration', () => { - it('should accept response format config', () => { - const config = RestApiConfigSchema.parse({ - responseFormat: { - envelope: true, - includeMetadata: true, - includePagination: true, - }, - }); - - expect(config.responseFormat?.envelope).toBe(true); - expect(config.responseFormat?.includeMetadata).toBe(true); - expect(config.responseFormat?.includePagination).toBe(true); - }); - - it('should accept minimal response format', () => { - const config = RestApiConfigSchema.parse({ - responseFormat: { - envelope: false, - includeMetadata: false, - includePagination: false, - }, - }); - - expect(config.responseFormat?.envelope).toBe(false); - }); - }); + // `Response Format Configuration` — REMOVED (#20295, ADR-0049): the two + // accept cases here pinned a block nothing ever read. `responseFormat` is a + // retiredKey() tombstone now; its refusal, prescription and tsc pins live in + // `rest-api-config-dead-keys-retirement.test.ts`. }); describe('CrudOperation', () => { @@ -663,16 +643,10 @@ describe('Integration Tests', () => { enableBatch: true, enableDiscovery: true, documentation: { - enabled: true, title: 'ObjectStack API', description: 'REST API for ObjectStack platform', version: '1.0.0', }, - responseFormat: { - envelope: true, - includeMetadata: true, - includePagination: true, - }, }, crud: { dataPrefix: '/data', diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 49cdae45722..32ebfe55ec1 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -85,8 +85,8 @@ import { retiredKey } from '../shared/retired-key'; * "enableCrud": true, * "enableMetadata": true, * "enableBatch": true, + * "enableOpenApi": true, * "documentation": { - * "enabled": true, * "title": "ObjectStack API" * } * } @@ -204,7 +204,24 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * API documentation configuration */ documentation: z.object({ - enabled: z.boolean().default(true).describe('Enable API documentation'), + /** + * [REMOVED in #20295] A second on/off switch for the OpenAPI document, + * retired under ADR-0049 enforce-or-remove: `normalizeConfig` copied it + * into the server's config and no site ever read it back, while the + * document's existence was — and is — decided by the sibling + * `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. + */ + enabled: retiredKey( + '`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: z.string().default('ObjectStack API').describe('API documentation title'), description: z.string().optional().describe('API description'), version: z.string().optional().describe('Documentation version'), @@ -221,13 +238,28 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ }).optional().describe('OpenAPI/Swagger documentation config'), /** - * Response format configuration - */ - responseFormat: z.object({ - envelope: z.boolean().default(true).describe('Wrap responses in standard envelope'), - includeMetadata: z.boolean().default(true).describe('Include response metadata (timestamp, requestId)'), - includePagination: z.boolean().default(true).describe('Include pagination info in list responses'), - }).optional().describe('Response format options'), + * [REMOVED in #20295] Server-wide toggles for the response envelope + * (`envelope`, `includeMetadata`, `includePagination`), retired as ONE key + * under ADR-0049 enforce-or-remove: `normalizeConfig` copied the block into + * the server's config and no site ever read it back, so `envelope: false` + * unwrapped no response. The removal, not the enforcement, is the call + * because a response shape is a fixed contract: each route answers in the + * response schema this package declares for it, which is what the client + * SDK parses and the served /openapi.json describes — a server-wide switch + * would fork every one of them, and mainstream data APIs keep theirs fixed. + * The whole container is the tombstone (the `crud.patterns` precedent): with + * all three members retired there is no live member left to hold it open. + * Tombstoned rather than deleted because this schema is not `.strict()` + * (ADR-0104). + */ + responseFormat: retiredKey( + '`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.', + ), })); export type RestApiConfig = z.input; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.enabled.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.enabled.ts new file mode 100644 index 00000000000..85bdc2d390f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__documentation.enabled.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #20295 — ADR-0049 enforce-or-remove on the `api` sub-object of +// `RestServerConfig`, executing the `rest_api` liveness census (#14640: 0 read +// sites outside `normalizeConfig` and the normalized-config type; re-measured on +// origin/main 4e0f72e8, objectui at its pin f8a9d0fb and cloud at 96eb092 — all +// clean against lit controls). Tombstoned with `retiredKey()` inside the live +// `documentation` block — a tombstone whose siblings keep parsing, because only +// this member retires here. No D2 conversion: a `RestServerConfig` is plugin TS +// configuration, never a stack collection member or a `sys_metadata` row. D3 +// semantic entry `rest-api-config-dead-keys-retired`. Registered under 18 for the +// launch-window reason its neighbours state. +// +// `documentation.enabled` was a second on/off switch for the OpenAPI document: +// `api.enableOpenApi` decides the mount, and this key was consulted nowhere. +// Nested key of an inline block — no `authorable-surface/` line of its own. +export const entry = 'api/RestApiConfig:documentation.enabled'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__responseFormat.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__responseFormat.ts new file mode 100644 index 00000000000..9cdcd22627f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RestApiConfig__responseFormat.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// normalized-config type; re-measured on origin/main 4e0f72e8, objectui at its +// pin f8a9d0fb and cloud at 96eb092 — all clean against lit controls). +// `RestApiConfigSchema` is a non-strict `z.object()`, so the route is a +// `retiredKey()` tombstone (a bare deletion would strip the key silently), the +// ledger row stays `dead` with a REMOVED note, and there is no D2 conversion: a +// `RestServerConfig` is plugin TS configuration, never a stack collection member +// or a `sys_metadata` row — the `api/RestServerConfig:openApi31` precedent, and +// the `rest-server-config-dead-keys-retired` one on the four sibling +// sub-objects. D3 semantic entry `rest-api-config-dead-keys-retired`. Registered +// under 18 for the launch-window reason its neighbours state. +// +// `responseFormat` is retired WHOLE — `envelope`, `includeMetadata` and +// `includePagination` were its only members and none was ever read, so there is +// no live member left to hold the container open (the `crud.patterns` +// precedent). A response shape is a fixed contract — each route's declared +// response schema, which the client SDK parses — not a server-wide option. +export const entry = 'api/RestApiConfig:responseFormat'; diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-api-config-dead-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-api-config-dead-keys-retired.ts new file mode 100644 index 00000000000..121c5eb7363 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.rest-api-config-dead-keys-retired.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20295 (family `rest-api-retire`, rank 9 of the #18900 census; triage graded +// it RETIRE by the maintainer's criterion) — the D3 entry of the family (ruling +// B on #17152: one D3 entry per retirement family). Registered keys: +// `api/RestApiConfig:responseFormat` and `api/RestApiConfig:documentation.enabled` +// — four ledger keys, since `responseFormat` retires whole with its three +// members. No D2 conversion: a `RestServerConfig` is plugin TS configuration, +// never a stack collection member or a stored row (the +// `rest-server-config-dead-keys-retired` precedent on the four sibling +// sub-objects), 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-config-dead-keys-retired', + surface: 'restServer.api.responseFormat / restServer.api.documentation.enabled', + replacement: + '(removed — delete each key; neither had an effect to preserve. Whether the server publishes its ' + + 'OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. ' + + 'Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` ' + + 'declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.)', + reason: + 'The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` ' + + 'parsed them, applied their defaults and copied them into the REST server\'s config, and no site ' + + 'ever read them back. So `responseFormat.envelope: false` unwrapped no response, ' + + '`includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` ' + + 'turned no document off — the document\'s existence was, and is, decided by `api.enableOpenApi` at ' + + 'the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a ' + + 'fixed response envelope that no administrator toggles server-wide, a configurable envelope would ' + + 'fork the declared response shapes the client SDK parses and the served /openapi.json describes, ' + + 'and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` ' + + 'and its inline `documentation` ' + + 'block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row ' + + 'stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted ' + + 'default needs to be tolerated as residue: the config is a construction argument that is parsed and ' + + 'consumed in the same process. The consumer still owes the judgment because a host that WROTE ' + + '`envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or ' + + 'no document, and only that host knows which clients were built on the belief.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin carries `api.responseFormat` or ' + + '`api.documentation.enabled` — 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`). A host that meant "serve no OpenAPI document" sets `api.enableOpenApi: ' + + 'false` and sees `GET /openapi.json` and `GET /docs` unmounted. Every client that parses REST ' + + 'responses reads each route\'s declared response shape. Every LIVE key of the `api` block — ' + + 'including `documentation`\'s other members — parses byte-identically to before, and the mounted ' + + 'REST surface is unchanged: ' + + 'neither key ever reached it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4d31e801035..3466e7602b6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13455,6 +13455,53 @@ const step18: MigrationStep = { + 'label and value can still tell what the icon was meant to say. Any tooling that generated ' + 'highlight entries (a code generator, a template) no longer emits the key.', }, + // #20295 (family `rest-api-retire`, rank 9 of the #18900 census; triage graded + // it RETIRE by the maintainer's criterion) — the D3 entry of the family (ruling + // B on #17152: one D3 entry per retirement family). Registered keys: + // `api/RestApiConfig:responseFormat` and `api/RestApiConfig:documentation.enabled` + // — four ledger keys, since `responseFormat` retires whole with its three + // members. No D2 conversion: a `RestServerConfig` is plugin TS configuration, + // never a stack collection member or a stored row (the + // `rest-server-config-dead-keys-retired` precedent on the four sibling + // sub-objects), so this entry is where the prescription reaches + // `os migrate meta`, the upgrade guide and `spec-changes.json`. + { + id: 'rest-api-config-dead-keys-retired', + surface: 'restServer.api.responseFormat / restServer.api.documentation.enabled', + replacement: + '(removed — delete each key; neither had an effect to preserve. Whether the server publishes its ' + + 'OpenAPI document and the docs viewer is `api.enableOpenApi`, the switch the mount already reads. ' + + 'Response shapes are fixed — each route answers in the response schema `@objectstack/spec/api` ' + + 'declares for it — and are not a server-wide option, so there is no replacement for `responseFormat`.)', + reason: + 'The `rest_api` liveness census found every member of these two keys `dead`: `normalizeConfig` ' + + 'parsed them, applied their defaults and copied them into the REST server\'s config, and no site ' + + 'ever read them back. So `responseFormat.envelope: false` unwrapped no response, ' + + '`includeMetadata` and `includePagination` gated nothing, and `documentation.enabled: false` ' + + 'turned no document off — the document\'s existence was, and is, decided by `api.enableOpenApi` at ' + + 'the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a ' + + 'fixed response envelope that no administrator toggles server-wide, a configurable envelope would ' + + 'fork the declared response shapes the client SDK parses and the served /openapi.json describes, ' + + 'and `documentation.enabled` duplicates a switch that is already enforced. `RestApiConfigSchema` ' + + 'and its inline `documentation` ' + + 'block are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone and its ledger row ' + + 'stays `dead` with a REMOVED note. No stored or built artifact carries either key, so no emitted ' + + 'default needs to be tolerated as residue: the config is a construction argument that is parsed and ' + + 'consumed in the same process. The consumer still owes the judgment because a host that WROTE ' + + '`envelope: false` or `documentation.enabled: false` believed its clients saw a different shape or ' + + 'no document, and only that host knows which clients were built on the belief.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin carries `api.responseFormat` or ' + + '`api.documentation.enabled` — 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`). A host that meant "serve no OpenAPI document" sets `api.enableOpenApi: ' + + 'false` and sees `GET /openapi.json` and `GET /docs` unmounted. Every client that parses REST ' + + 'responses reads each route\'s declared response shape. Every LIVE key of the `api` block — ' + + 'including `documentation`\'s other members — parses byte-identically to before, and the mounted ' + + 'REST surface is unchanged: ' + + 'neither key ever reached it.', + }, { id: 'rest-api-endpoint-handler-status-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -17145,6 +17192,41 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // A nested key of an inline block, so it has no line of its own in // `authorable-surface/` (the `kernel/Manifest:contributes.routes` shape). 'api/MetadataEndpointsConfig:endpoints.schema', + // #20295 — ADR-0049 enforce-or-remove on the `api` sub-object of + // `RestServerConfig`, executing the `rest_api` liveness census (#14640: 0 read + // sites outside `normalizeConfig` and the normalized-config type; re-measured on + // origin/main 4e0f72e8, objectui at its pin f8a9d0fb and cloud at 96eb092 — all + // clean against lit controls). Tombstoned with `retiredKey()` inside the live + // `documentation` block — a tombstone whose siblings keep parsing, because only + // this member retires here. No D2 conversion: a `RestServerConfig` is plugin TS + // configuration, never a stack collection member or a `sys_metadata` row. D3 + // semantic entry `rest-api-config-dead-keys-retired`. Registered under 18 for the + // launch-window reason its neighbours state. + // + // `documentation.enabled` was a second on/off switch for the OpenAPI document: + // `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', + // #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 + // normalized-config type; re-measured on origin/main 4e0f72e8, objectui at its + // pin f8a9d0fb and cloud at 96eb092 — all clean against lit controls). + // `RestApiConfigSchema` is a non-strict `z.object()`, so the route is a + // `retiredKey()` tombstone (a bare deletion would strip the key silently), the + // ledger row stays `dead` with a REMOVED note, and there is no D2 conversion: a + // `RestServerConfig` is plugin TS configuration, never a stack collection member + // or a `sys_metadata` row — the `api/RestServerConfig:openApi31` precedent, and + // the `rest-server-config-dead-keys-retired` one on the four sibling + // sub-objects. D3 semantic entry `rest-api-config-dead-keys-retired`. Registered + // under 18 for the launch-window reason its neighbours state. + // + // `responseFormat` is retired WHOLE — `envelope`, `includeMetadata` and + // `includePagination` were its only members and none was ever read, so there is + // no live member left to hold the container open (the `crud.patterns` + // precedent). A response shape is a fixed contract — each route's declared + // response schema, which the client SDK parses — not a server-wide option. + 'api/RestApiConfig:responseFormat', // #15677 (stack card 2/6 of #14478) — ruling B; the seconds half of the pair // documented on `api/RestApiEndpoint:timeout`. Renamed to `cacheTtlSeconds`; // the value is unchanged. Tombstoned with `retiredKey()`; disposition and diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 23a523dc6b0..58ad0319534 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -23,6 +23,7 @@ "src/ai/tool-confirmation-prescription-tense.pin.test.ts", "src/api/error-catalog-docs.test.ts", "src/api/export-job-family-retirement.test.ts", + "src/api/rest-api-config-dead-keys-retirement.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts",