diff --git a/.changeset/rest-server-config-dead-keys-retired.md b/.changeset/rest-server-config-dead-keys-retired.md new file mode 100644 index 0000000000..6493a96a06 --- /dev/null +++ b/.changeset/rest-server-config-dead-keys-retired.md @@ -0,0 +1,83 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +feat(spec): retire the ten inert `RestServerConfig` keys the liveness ledger recorded as `dead` (#14691, ADR-0049 enforce-or-remove) + + + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the prescription is registered +under protocol major 18, where `os migrate meta` users will look). + +#14369 enrolled the four `RestServerConfig` sub-objects (`crud`, `metadata`, +`batch`, `routes`) in the spec liveness ledger and found 15 of their 32 rows +`dead`: parsed, defaulted and normalized into the REST server's config by +`normalizeConfig` (#11984) and never read back. This change is the +enforce-or-remove call on every one of them, taken per family, and every +family resolved to REMOVE — each promised capability either already exists at +its proper seat or would contradict a fixed contract. The closed-set cloud +sweep (#14796, `objectstack-ai/cloud` @ `9b6abe0f2fd5`) returned zero hits, +structurally: cloud never authors a `RestServerConfig`. + +**What is refused:** authoring any of the ten keys below. Each is a +`retiredKey()` tombstone (all four sub-schemas are non-strict `z.object()`s, +so a plain deletion would have silently stripped the key), so authoring it is +a `tsc` error and a parse error carrying the prescription — and, because +`RestServer` parses these sub-objects at construction (#11984), +`new RestServer(...)` / `createRestApiPlugin().start()` now refuse a config +that carries one, naming the sub-object, the key and the declaring schema. + +**FROM → TO** (delete the key in every case; none ever had an effect to preserve): + +- `crud.patterns` → the mounted CRUD paths are the contract the client SDK, the + discovery document and the served `/openapi.json` all describe; `crud.dataPrefix` + moves them deployment-wide. An endpoint on a custom path or method is a + declarative `api` endpoint (`type: 'object_operation'`). Its value schema + `CrudEndpointPatternSchema` / type `CrudEndpointPattern` are removed with it + (no other consumer; `CrudOperation` stays — `GeneratedEndpoint.operation` reads it). +- `crud.objectParamStyle` → the object name is always a path segment. +- `metadata.cacheTtl` → `metadata.enableCache` is the live switch (it selects the + protocol's `getMetaItemCached` path, which takes no TTL); a declarative `api` + endpoint's `cacheTtl` is the key that reaches the wire. The unbounded negative + TTL this key accepted goes with it. +- `metadata.endpoints.schema` → gated `GET /meta/:type/:name/schema`, which does + not exist; `endpoints.types` / `items` / `item` gate real mounts and stay. +- `batch.operations.upsertMany` → gated `POST /data/:object/upsertMany`, which + was never built; upsert is an operation type of the generic + `POST /data/:object/batch` endpoint (`BatchOperationType` `'upsert'`), gated + by `batch.enableBatchEndpoint`. +- `batch.defaultAtomic` → atomicity is the per-request `options.atomic` + (ADR-0119 D4, opt-in); a server-side default that flipped it silently would + change the failure semantics of callers who send nothing, which that ADR + refused. Callers that need all-or-nothing send `options: { atomic: true }`. +- `routes.includeObjects` / `routes.excludeObjects` / `routes.overrides` + (`enabled` / `basePath` / `operations`) → per-object API exposure is declared + ON the object and enforced by the REST data surface: `enable.apiEnabled: false` + hides it (404), `enable.apiMethods` whitelists its operations (405). The data + base path is deployment-wide (`crud.dataPrefix`). +- `routes.nameTransform` → the object `name` is the canonical id on every + surface, the REST path segment included; there is no URL transform to configure. + +**What stays, byte-identical:** every live key of the four sub-objects — +`crud.operations.*`, `crud.dataPrefix`, `metadata.prefix` / `enableCache` / +`maskObjectFields` / `endpoints.types|items|item`, `batch.maxBatchSize` / +`enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` — with +its default and its mount. The mounted REST surface does not change: none of +the ten keys ever reached it. `@objectstack/rest`'s normalized config no longer +carries the retired keys (they were written and never read), and the #11984 +pins of their accept/reject behaviour are reversed to refusal pins, by design. + +The retirement kit: `retiredKey()` tombstones on the four sub-schemas; +`RestServerConfigSchema`'s `@example` no longer advertises +`routes: { excludeObjects: [...] }`; ledger rows kept `dead` with a REMOVED +note and `evidenceScope: cross-repo` (the two container rows collapse into one +each, since their child keys left the walked shape); `RETIRED_KEYS_BY_MAJOR[18]` +× 10 and `RETIRED_DEFS_BY_MAJOR[18]` `api/CrudEndpointPattern`; D3 semantic +entry `rest-server-config-dead-keys-retired`. No D2 conversion: a +`RestServerConfig` is plugin TS configuration (REST plugin constructor / +`plugin-hono-server` `restConfig`), never a stack collection member or a +`sys_metadata` row (the `openApi31` precedent, #4579), so there is no source +for the chain to rewrite and the prescription carries no `os migrate meta` +sentence. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6990eb7383..277b5148cd 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1522`, `:1551`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1520`, `:1549`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1554` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1552` | ### 2. Write pipeline and data integrity @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1522`, `:1551`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` | --- diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 9682aa6f2c..6a94b1a51d 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -29,8 +29,8 @@ Architecture alignment: ## TypeScript Usage ```typescript -import { BatchEndpointsConfigSchema, CrudEndpointPatternSchema, CrudEndpointsConfigSchema, CrudOperation, EndpointRegistrySchema, GeneratedEndpointSchema, MetadataEndpointsConfigSchema, RestApiConfigSchema, RestServerConfigSchema, RouteGenerationConfigSchema } from '@objectstack/spec/api'; -import type { BatchEndpointsConfig, CrudEndpointPattern, CrudEndpointsConfig, CrudOperation, EndpointRegistry, GeneratedEndpoint, MetadataEndpointsConfig, RestApiConfig, RestServerConfig, RouteGenerationConfig } from '@objectstack/spec/api'; +import { BatchEndpointsConfigSchema, CrudEndpointsConfigSchema, CrudOperation, EndpointRegistrySchema, GeneratedEndpointSchema, MetadataEndpointsConfigSchema, RestApiConfigSchema, RestServerConfigSchema, RouteGenerationConfigSchema } from '@objectstack/spec/api'; +import type { BatchEndpointsConfig, CrudEndpointsConfig, CrudOperation, EndpointRegistry, GeneratedEndpoint, MetadataEndpointsConfig, RestApiConfig, RestServerConfig, RouteGenerationConfig } from '@objectstack/spec/api'; // Validate data const result = BatchEndpointsConfigSchema.parse(data); @@ -46,8 +46,8 @@ const result = BatchEndpointsConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **maxBatchSize** | `integer` | optional (default: `200`) | Maximum records per batch operation | | **enableBatchEndpoint** | `boolean` | optional (default: `true`) | Enable POST /data/:object/batch endpoint | -| **operations** | `{ createMany: boolean; updateMany: boolean; deleteMany: boolean; upsertMany: boolean }` | optional | Enable/disable specific batch operations | -| **defaultAtomic** | `boolean` | optional (default: `true`) | Default atomic/transaction mode for batch operations | +| **operations** | `{ createMany: boolean; updateMany: boolean; deleteMany: boolean }` | optional | Enable/disable specific batch operations | +| **defaultAtomic** | `never` | optional | [REMOVED] `batch.defaultAtomic` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no batch handler ever consulted it: atomicity is decided per request by `options.atomic` in the batch body (`BatchOptions`, ADR-0119 D4, opt-in), and a server-side default that flipped it silently would change the failure semantics of callers who send nothing. Delete the key; a caller that needs all-or-nothing sends `options: { atomic: true }`. | ### Nested Shape: `BatchEndpointsConfig.operations` @@ -56,21 +56,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **createMany** | `boolean` | optional (default: `true`) | Enable POST /data/:object/createMany | | **updateMany** | `boolean` | optional (default: `true`) | Enable POST /data/:object/updateMany | | **deleteMany** | `boolean` | optional (default: `true`) | Enable POST /data/:object/deleteMany | -| **upsertMany** | `boolean` | optional (default: `true`) | Enable POST /data/:object/upsertMany | - - ---- - -## CrudEndpointPattern - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method | -| **path** | `string` | ✅ | URL path pattern | -| **summary** | `string` | optional | Operation summary | -| **description** | `string` | optional | Operation description | +| **upsertMany** | `never` | optional | [REMOVED] `batch.operations.upsertMany` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that was never built: there is no `POST /data/:object/upsertMany` and no protocol member behind it, so `false` disabled nothing. Delete the key. Upsert is an operation type of the generic `POST /data/:object/batch` endpoint (`BatchOperationType` `'upsert'`, keyed by `externalId`), gated by `batch.enableBatchEndpoint`. | --- @@ -82,9 +68,9 @@ const result = BatchEndpointsConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **operations** | `{ create: boolean; read: boolean; update: boolean; delete: boolean; … }` | optional | Enable/disable operations | -| **patterns** | `Record; path: string; summary?: string; description?: string }>` | optional | Custom URL patterns for operations | +| **patterns** | `never` | optional | [REMOVED] `crud.patterns` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: every CRUD route is mounted from the fixed method/path pairs in the REST server's `registerCrudEndpoints`, so a custom pattern was validated and ignored. Delete the key. The mounted CRUD paths are the contract the client SDK, the discovery document and the served /openapi.json all describe, and `crud.dataPrefix` is the one live knob that moves them; an endpoint on a custom path or method is a declarative `api` endpoint (`type: 'object_operation'`, `ApiEndpoint` in `@objectstack/spec/api`), which is matched, executed and documented. | | **dataPrefix** | `string` | optional (default: `"/data"`) | URL prefix for data endpoints | -| **objectParamStyle** | `Enum<'path' \| 'query'>` | optional (default: `"path"`) | How object name is passed (path param or query param) | +| **objectParamStyle** | `never` | optional | [REMOVED] `crud.objectParamStyle` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: every CRUD route takes the object name as a path segment, so `'query'` was validated and mounted exactly what `'path'` mounts. Delete the key; the object name is always a path segment. | ### Nested Shape: `CrudEndpointsConfig.operations` @@ -96,15 +82,6 @@ const result = BatchEndpointsConfigSchema.parse(data); | **delete** | `boolean` | optional (default: `true`) | Enable delete operation | | **list** | `boolean` | optional (default: `true`) | Enable list operation | -### Nested Shape: `CrudEndpointsConfig.patterns[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | ✅ | HTTP method | -| **path** | `string` | ✅ | URL path pattern | -| **summary** | `string` | optional | Operation summary | -| **description** | `string` | optional | Operation description | - --- @@ -196,9 +173,9 @@ const result = BatchEndpointsConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **prefix** | `string` | optional (default: `"/meta"`) | URL prefix for metadata endpoints | | **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) | -| **cacheTtl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. | | **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields | -| **endpoints** | `{ types: boolean; items: boolean; item: boolean; schema: boolean }` | optional | Enable/disable specific endpoints | +| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints | ### Nested Shape: `MetadataEndpointsConfig.endpoints` @@ -207,7 +184,7 @@ const result = BatchEndpointsConfigSchema.parse(data); | **types** | `boolean` | optional (default: `true`) | GET /meta - List all metadata types | | **items** | `boolean` | optional (default: `true`) | GET /meta/:type - List items of type | | **item** | `boolean` | optional (default: `true`) | GET /meta/:type/:name - Get specific item | -| **schema** | `boolean` | optional (default: `true`) | GET /meta/:type/:name/schema - Get JSON schema | +| **schema** | `never` | optional | [REMOVED] `metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it gated a route that does not exist: the REST server mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete the key; `endpoints.types` / `items` / `item` are the switches that gate real mounts. | --- @@ -264,10 +241,10 @@ const result = BatchEndpointsConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **api** | `{ version: string; basePath: string; apiPath?: string; enableCrud: boolean; … }` | optional | REST API configuration | -| **crud** | `{ operations?: object; patterns?: Record; dataPrefix: string; objectParamStyle: Enum<'path' \| 'query'> }` | optional | CRUD endpoints configuration | -| **metadata** | `{ prefix: string; enableCache: boolean; cacheTtl: integer; maskObjectFields: boolean; … }` | optional | Metadata endpoints configuration | -| **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object; defaultAtomic: boolean }` | optional | Batch endpoints configuration | -| **routes** | `{ includeObjects?: string[]; excludeObjects?: string[]; nameTransform: Enum<'none' \| 'plural' \| 'kebab-case' \| 'camelCase'>; overrides?: Record }` | optional | Route generation configuration | +| **crud** | `{ operations?: object; dataPrefix: string }` | optional | CRUD endpoints configuration | +| **metadata** | `{ prefix: string; enableCache: boolean; maskObjectFields: boolean; endpoints?: object }` | optional | Metadata endpoints configuration | +| **batch** | `{ maxBatchSize: integer; enableBatchEndpoint: boolean; operations?: object }` | optional | Batch endpoints configuration | +| **routes** | `{ }` | optional | Route generation configuration | | **openApi31** | `never` | optional | [REMOVED] `RestServerConfig.openApi31` was removed in @objectstack/spec 17 (ADR-0049) — no runtime ever read it: the REST server forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated contract enriched with the live server URL and the registered objects, so webhook/callback definitions declared here never appeared in it. Delete the key. Config-driven OpenAPI 3.1 webhooks/callbacks documentation is a new capability and must arrive via the enforce route of ADR-0049 (a new ADR), not by re-declaring the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. | ### Nested Shape: `RestServerConfig.api` @@ -295,9 +272,9 @@ const result = BatchEndpointsConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **operations** | `{ create: boolean; read: boolean; update: boolean; delete: boolean; … }` | optional | Enable/disable operations | -| **patterns** | `Record; path: string; summary?: string; description?: string }>` | optional | Custom URL patterns for operations | +| **patterns** | `never` | optional | [REMOVED] `crud.patterns` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: every CRUD route is mounted from the fixed method/path pairs in the REST server's `registerCrudEndpoints`, so a custom pattern was validated and ignored. Delete the key. The mounted CRUD paths are the contract the client SDK, the discovery document and the served /openapi.json all describe, and `crud.dataPrefix` is the one live knob that moves them; an endpoint on a custom path or method is a declarative `api` endpoint (`type: 'object_operation'`, `ApiEndpoint` in `@objectstack/spec/api`), which is matched, executed and documented. | | **dataPrefix** | `string` | optional (default: `"/data"`) | URL prefix for data endpoints | -| **objectParamStyle** | `Enum<'path' \| 'query'>` | optional (default: `"path"`) | How object name is passed (path param or query param) | +| **objectParamStyle** | `never` | optional | [REMOVED] `crud.objectParamStyle` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: every CRUD route takes the object name as a path segment, so `'query'` was validated and mounted exactly what `'path'` mounts. Delete the key; the object name is always a path segment. | ### Nested Shape: `RestServerConfig.metadata` @@ -305,9 +282,9 @@ const result = BatchEndpointsConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **prefix** | `string` | optional (default: `"/meta"`) | URL prefix for metadata endpoints | | **enableCache** | `boolean` | optional (default: `true`) | Enable HTTP cache headers (ETag, Last-Modified) | -| **cacheTtl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **cacheTtl** | `never` | optional | [REMOVED] `metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. | | **maskObjectFields** | `boolean` | optional (default: `true`) | [ADR-0106 D8] Mask served object schemas to the caller's readable fields | -| **endpoints** | `{ types: boolean; items: boolean; item: boolean; schema: boolean }` | optional | Enable/disable specific endpoints | +| **endpoints** | `{ types: boolean; items: boolean; item: boolean }` | optional | Enable/disable specific endpoints | ### Nested Shape: `RestServerConfig.batch` @@ -315,17 +292,8 @@ const result = BatchEndpointsConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **maxBatchSize** | `integer` | optional (default: `200`) | Maximum records per batch operation | | **enableBatchEndpoint** | `boolean` | optional (default: `true`) | Enable POST /data/:object/batch endpoint | -| **operations** | `{ createMany: boolean; updateMany: boolean; deleteMany: boolean; upsertMany: boolean }` | optional | Enable/disable specific batch operations | -| **defaultAtomic** | `boolean` | optional (default: `true`) | Default atomic/transaction mode for batch operations | - -### Nested Shape: `RestServerConfig.routes` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **includeObjects** | `string[]` | optional | Specific objects to generate routes for (empty = all) | -| **excludeObjects** | `string[]` | optional | Objects to exclude from route generation | -| **nameTransform** | `Enum<'none' \| 'plural' \| 'kebab-case' \| 'camelCase'>` | optional (default: `"none"`) | Transform object names in URLs | -| **overrides** | `Record }>` | optional | Per-object route customization | +| **operations** | `{ createMany: boolean; updateMany: boolean; deleteMany: boolean }` | optional | Enable/disable specific batch operations | +| **defaultAtomic** | `never` | optional | [REMOVED] `batch.defaultAtomic` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no batch handler ever consulted it: atomicity is decided per request by `options.atomic` in the batch body (`BatchOptions`, ADR-0119 D4, opt-in), and a server-side default that flipped it silently would change the failure semantics of callers who send nothing. Delete the key; a caller that needs all-or-nothing sends `options: { atomic: true }`. | --- @@ -336,18 +304,10 @@ const result = BatchEndpointsConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **includeObjects** | `string[]` | optional | Specific objects to generate routes for (empty = all) | -| **excludeObjects** | `string[]` | optional | Objects to exclude from route generation | -| **nameTransform** | `Enum<'none' \| 'plural' \| 'kebab-case' \| 'camelCase'>` | optional (default: `"none"`) | Transform object names in URLs | -| **overrides** | `Record }>` | optional | Per-object route customization | - -### Nested Shape: `RouteGenerationConfig.overrides[string]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional | Enable/disable routes for this object | -| **basePath** | `string` | optional | Custom base path | -| **operations** | `Record` | optional | Enable/disable specific operations | +| **includeObjects** | `never` | optional | [REMOVED] `routes.includeObjects` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: route registration iterates every registered object, so an include list still mounted all of them. Delete the key. Per-object API exposure is declared on the object and enforced by the REST data surface: `enable.apiEnabled: false` hides the object (404) and `enable.apiMethods` whitelists its operations (405 for the rest). | +| **excludeObjects** | `never` | optional | [REMOVED] `routes.excludeObjects` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: route registration iterates every registered object, so an excluded object was still mounted. Delete the key. Per-object API exposure is declared on the object and enforced by the REST data surface: `enable.apiEnabled: false` hides the object (404) and `enable.apiMethods` whitelists its operations (405 for the rest). | +| **nameTransform** | `never` | optional | [REMOVED] `routes.nameTransform` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the enum was validated and every value mounted exactly what `'none'` mounts. Delete the key; the object `name` is the canonical id on every surface, the REST path segment included, so there is no URL transform to configure. | +| **overrides** | `never` | optional | [REMOVED] `routes.overrides` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the per-object record: `enabled`, `basePath` and `operations` were validated and turned nothing on or off. Delete the key. Per-object exposure lives on the object (`enable.apiEnabled` hides it, `enable.apiMethods` whitelists its operations — both enforced by the REST data surface); the data base path is deployment-wide (`crud.dataPrefix`); and an endpoint on a custom path is a declarative `api` endpoint. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4761aa1d2b..47b1e23769 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 69 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1590** | 14 protocol modules | +| **Total** | **199** | **1589** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 437 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 436 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -92,7 +92,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | -| [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointPattern`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | +| [`rest-server.zod.ts`](/docs/references/api/rest-server) | `BatchEndpointsConfig`, `CrudEndpointsConfig`, `CrudOperation`, `EndpointRegistry`, `GeneratedEndpoint`, `MetadataEndpointsConfig`, `RestApiConfig`, `RestServerConfig`, `RouteGenerationConfig` | | [`router.zod.ts`](/docs/references/api/router) | `ConflictResolutionStrategy`, `HttpMethod`, `RouteCategory`, `RouteDefinition`, `RouterConfig` | | [`sortability.zod.ts`](/docs/references/api/sortability) | `FieldSortability`, `ObjectSortability` | | [`storage.zod.ts`](/docs/references/api/storage) | `CompleteChunkedUploadRequest`, `CompleteChunkedUploadResponse`, `CompleteUploadRequest`, `FileDownloadUrlResponse`, `FileTypeValidation`, `FileUploadResponse`, `GetPresignedUrlRequest`, `InitiateChunkedUploadRequest`, `InitiateChunkedUploadResponse`, `PresignedUrlResponse`, `RawUploadResponse`, `UploadChunkRequest`, `UploadChunkResponse`, `UploadProgress` | 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 f3ed147475..e5a1b380b8 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/` | 77 | -| `api/` | 452 | +| `api/` | 450 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f4f2db0dc7..1778442c8e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -71,7 +71,7 @@ import { refuseRepeatedQueryParams, assertFilterParamSuppliedOnce } from './quer // ignored filter is the one wrong answer a caller cannot detect. import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; -import { RestServerConfig, RestApiConfig, CrudEndpointsConfigParsed, RouteGenerationConfigParsed } from '@objectstack/spec/api'; +import { RestServerConfig, RestApiConfig } from '@objectstack/spec/api'; // [#11683] The catalog's own floor for "a required `code` and no more specific // one" — see its use in `registerSharingEndpoints`, where the nested ADR-0112 // envelope declares `code` REQUIRED while the flat classification it re-dresses @@ -745,14 +745,11 @@ type NormalizedRestServerConfig = { delete: boolean; list: boolean; }; - patterns: CrudEndpointsConfigParsed['patterns']; dataPrefix: string; - objectParamStyle: 'path' | 'query'; }; metadata: { prefix: string; enableCache: boolean; - cacheTtl: number; /** * [ADR-0106 D8] Per-caller FLS masking of served object schemas. * Default **on**; `false` opts a deployment out of the metadata-plane @@ -763,7 +760,6 @@ type NormalizedRestServerConfig = { types: boolean; items: boolean; item: boolean; - schema: boolean; }; }; batch: { @@ -773,16 +769,18 @@ type NormalizedRestServerConfig = { createMany: boolean; updateMany: boolean; deleteMany: boolean; - upsertMany: boolean; }; - defaultAtomic: boolean; - }; - routes: { - includeObjects: string[] | undefined; - excludeObjects: string[] | undefined; - nameTransform: 'none' | 'plural' | 'kebab-case' | 'camelCase'; - overrides: RouteGenerationConfigParsed['overrides']; }; + /** + * [#14691] Every key of `RouteGenerationConfigSchema` is a `retiredKey()` + * tombstone (ADR-0049 enforce-or-remove — nothing here ever read + * `includeObjects` / `excludeObjects` / `nameTransform` / `overrides`; the + * #14369 census). The sub-object is still PARSED, so an authored key is + * refused at construction with its prescription rather than stripped, but + * nothing is threaded: per-object exposure is the object's own + * `enable.apiEnabled` / `enable.apiMethods`, enforced by `enforceApiAccess`. + */ + routes: Record; }; /** @@ -882,7 +880,7 @@ function parseDeclaredSubConfig( * - Metadata API endpoints (/meta) * - Batch operation endpoints (/batch, /createMany, /updateMany, /deleteMany) * - Discovery endpoint - * - Configurable path prefixes and patterns + * - Configurable path prefixes * * @example * const restServer = new RestServer(httpServer, protocolProvider, { @@ -3569,14 +3567,15 @@ export class RestServer { delete: crud.operations?.delete ?? true, list: crud.operations?.list ?? true, }, - patterns: crud.patterns, + // `patterns` / `objectParamStyle` are tombstones since #14691 — + // refused by the parse above, never threaded. dataPrefix: crud.dataPrefix, - objectParamStyle: crud.objectParamStyle, }, metadata: { prefix: metadata.prefix, enableCache: metadata.enableCache, - cacheTtl: metadata.cacheTtl, + // `cacheTtl` is a tombstone since #14691 (`enableCache` selects the + // protocol's cached read path, which takes no TTL). // [ADR-0106 D8] Default ON — masking is the platform default and // ships with the current major. The key has a declared seat // (`MetadataEndpointsConfigSchema.maskObjectFields` in @@ -3592,7 +3591,8 @@ export class RestServer { types: metadata.endpoints?.types ?? true, items: metadata.endpoints?.items ?? true, item: metadata.endpoints?.item ?? true, - schema: metadata.endpoints?.schema ?? true, + // `schema` is a tombstone since #14691: it gated a route that + // does not exist. }, }, batch: { @@ -3603,16 +3603,16 @@ export class RestServer { createMany: batch.operations?.createMany ?? true, updateMany: batch.operations?.updateMany ?? true, deleteMany: batch.operations?.deleteMany ?? true, - upsertMany: batch.operations?.upsertMany ?? true, + // `upsertMany` is a tombstone since #14691: there is no upsertMany + // route to gate (upsert is an operation type of the generic batch + // endpoint). So is `defaultAtomic`: atomicity is the per-request + // `options.atomic` (ADR-0119 D4). }, - defaultAtomic: batch.defaultAtomic, - }, - routes: { - includeObjects: routes.includeObjects, - excludeObjects: routes.excludeObjects, - nameTransform: routes.nameTransform, - overrides: routes.overrides, }, + // [#14691] Parsed for the refusal, threaded as nothing — every key of + // the sub-object is a tombstone (see the type above). `routes` is kept + // as a key so the normalized shape still has one seat per sub-object. + routes: routes as Record, }; } diff --git a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts index 8480db2195..7d0507c687 100644 --- a/packages/rest/src/rest-sub-config-parse-not-cast.test.ts +++ b/packages/rest/src/rest-sub-config-parse-not-cast.test.ts @@ -42,6 +42,17 @@ * the normalized config is built from and the schema's own defaults are the * defaults. `api` keeps #11637's validate-only posture — its `.omit()`ed * tombstone is the reason — and is not this file's subject. + * + * [#14691] Ten of the keys these pins originally exercised were RETIRED under + * ADR-0049 enforce-or-remove (the #14369 liveness census found them normalized + * and never read): `crud.patterns` / `objectParamStyle`, `metadata.cacheTtl` / + * `endpoints.schema`, `batch.operations.upsertMany` / `defaultAtomic`, and all + * of `routes.*`. Each is now a `retiredKey()` tombstone, so the pins below that + * used to assert "accepted, read back" for those keys are REVERSED — by design, + * not by regression — into refusal pins: the SERVER refuses the key at + * construction with the retirement prescription (§E). The regression guards on + * the LIVE keys are unchanged, which is what makes this file still measure that + * the narrowing is exactly the declared one. */ import { describe, it, expect, vi } from 'vitest'; @@ -86,25 +97,19 @@ function refusal(config: RestServerConfig): string { type NormalizedView = { crud: { operations: Record<'create' | 'read' | 'update' | 'delete' | 'list', boolean>; - patterns: Record | undefined; dataPrefix: string; - objectParamStyle: string; }; metadata: { prefix: string; - cacheTtl: number; + enableCache: boolean; maskObjectFields: boolean; - endpoints: Record<'types' | 'items' | 'item' | 'schema', boolean>; + endpoints: Record<'types' | 'items' | 'item', boolean>; }; batch: { maxBatchSize: number; - defaultAtomic: boolean; - operations: Record<'createMany' | 'updateMany' | 'deleteMany' | 'upsertMany', boolean>; - }; - routes: { - includeObjects: string[] | undefined; - nameTransform: string; + operations: Record<'createMany' | 'updateMany' | 'deleteMany', boolean>; }; + routes: Record; }; /** Read the normalized config back off a constructed server. */ @@ -134,54 +139,24 @@ describe('[#11984] §A RestServer construction runs the four sibling schemas', ( expect(refusal({ batch: { maxBatchSize: 2.5 } })).toContain('batch.maxBatchSize'); }); - it('refuses `routes.nameTransform: "snake_case"` — an option outside the declared enum', () => { - const message = refusal({ routes: { nameTransform: 'snake_case' as never } }); - expect(message).toContain('routes.nameTransform'); - expect(message).toContain('RouteGenerationConfigSchema'); - expect(message, 'the declared vocabulary is part of the prescription').toContain('kebab-case'); - }); - - it('refuses `crud.objectParamStyle: "header"` — an option outside the declared enum', () => { - const message = refusal({ crud: { objectParamStyle: 'header' as never } }); - expect(message).toContain('crud.objectParamStyle'); - expect(message).toContain('CrudEndpointsConfigSchema'); - }); - - it('refuses `metadata.cacheTtl: 2.5` — declared `.int()`', () => { - const message = refusal({ metadata: { cacheTtl: 2.5 } }); - expect(message).toContain('metadata.cacheTtl'); - expect(message).toContain('MetadataEndpointsConfigSchema'); - }); + // `routes.nameTransform`, `crud.objectParamStyle` and `metadata.cacheTtl` used + // to be pinned here as "refuses the OUT-OF-CONTRACT value". Since #14691 the + // keys themselves are tombstones and EVERY value is refused — see §E. it('refuses a declared key written with the wrong type', () => { expect(refusal({ crud: { dataPrefix: 42 as never } })).toContain('crud.dataPrefix'); expect(refusal({ metadata: { enableCache: 'yes' as never } })).toContain('metadata.enableCache'); - expect(refusal({ routes: { includeObjects: 'account' as never } })).toContain('routes.includeObjects'); + expect(refusal({ batch: { enableBatchEndpoint: 'yes' as never } })).toContain('batch.enableBatchEndpoint'); }); - it('refuses `crud.patterns` keyed by an operation the CRUD vocabulary does not contain', () => { - // `patterns` is `z.record(CrudOperation, ...)`: an enum-keyed record, - // which zod validates key by key — so a pattern for an operation that - // does not exist is refused, not carried along and never matched. - const message = refusal({ crud: { patterns: { bogus: { method: 'GET', path: '/x' } } as never } }); - expect(message).toContain('crud.patterns'); - expect(message).toContain('bogus'); - }); - - it('refuses a partial `routes.overrides..operations` — the declared record is exhaustive over the five operations', () => { - // Same enum-keyed record, with a NON-optional value: zod requires - // every declared operation, so the missing ones are named one by one. - // The input TYPE already demanded all five at typed authoring sites; - // this is the day the runtime agrees with `tsc`. - const message = refusal({ routes: { overrides: { account: { operations: { list: false } as never } } } }); - expect(message).toContain('routes.overrides.account.operations.create'); - expect(message).toContain('routes.overrides.account.operations.read'); - }); + // `crud.patterns` (an enum-keyed record) and `routes.overrides..operations` + // (an exhaustive one) used to be pinned here for their key-by-key refusals. + // Both records are tombstones since #14691 — see §E. it('lists every failing key of the sub-object in one refusal', () => { - const message = refusal({ batch: { maxBatchSize: 0, defaultAtomic: 'yes' as never } }); + const message = refusal({ batch: { maxBatchSize: 0, enableBatchEndpoint: 'yes' as never } }); expect(message).toContain('batch.maxBatchSize'); - expect(message).toContain('batch.defaultAtomic'); + expect(message).toContain('batch.enableBatchEndpoint'); }); it('a sibling refusal never diagnoses `api.version` — a key this config did not write', () => { @@ -233,10 +208,13 @@ describe('[#11984] §B the refusal survives the plugin path', () => { ).rejects.toThrow(/batch\.maxBatchSize/); }); - it('rejects `createRestApiPlugin({ api: { routes: { nameTransform: "snake_case" } } }).start()`', async () => { + it('rejects `createRestApiPlugin({ api: { routes: { nameTransform: "none" } } }).start()` — a #14691 tombstone, through the plugin path', async () => { + // Before #14691 this case drove `'snake_case'`, the out-of-enum value. + // The key is retired now, so its former DEFAULT is refused too, with the + // prescription rather than the enum text. await expect( - createRestApiPlugin({ api: { routes: { nameTransform: 'snake_case' as never } } }).start!(bootCtx()), - ).rejects.toThrow(/routes\.nameTransform/); + createRestApiPlugin({ api: { routes: { nameTransform: 'none' as never } } }).start!(bootCtx()), + ).rejects.toThrow(/routes\.nameTransform.*was removed/s); }); }); @@ -248,11 +226,15 @@ describe('[#11984] §C regression guards — the narrowing is exactly the declar it('an empty config still constructs, with the declared defaults', () => { const cfg = normalized({}); expect(cfg.batch.maxBatchSize).toBe(200); - expect(cfg.metadata.cacheTtl).toBe(3600); expect(cfg.metadata.prefix).toBe('/meta'); expect(cfg.crud.dataPrefix).toBe('/data'); - expect(cfg.crud.objectParamStyle).toBe('path'); - expect(cfg.routes.nameTransform).toBe('none'); + // [#14691] the retired keys materialize NO default any more — the + // normalized config simply does not carry them. + expect(cfg.metadata).not.toHaveProperty('cacheTtl'); + expect(cfg.crud).not.toHaveProperty('objectParamStyle'); + expect(cfg.crud).not.toHaveProperty('patterns'); + expect(cfg.batch).not.toHaveProperty('defaultAtomic'); + expect(cfg.routes).toEqual({}); }); it('accepts the declared `maxBatchSize` bounds inclusively', () => { @@ -260,27 +242,15 @@ describe('[#11984] §C regression guards — the narrowing is exactly the declar expect(normalized({ batch: { maxBatchSize: 1000 } }).batch.maxBatchSize).toBe(1000); }); - it('accepts every option the declared enums contain, and reads each back', () => { - for (const nameTransform of ['none', 'plural', 'kebab-case', 'camelCase'] as const) { - expect(normalized({ routes: { nameTransform } }).routes.nameTransform, nameTransform).toBe(nameTransform); - } - for (const objectParamStyle of ['path', 'query'] as const) { - expect(normalized({ crud: { objectParamStyle } }).crud.objectParamStyle, objectParamStyle).toBe(objectParamStyle); - } - }); - - it('KEEPS a negative `metadata.cacheTtl` — declared `.int()` only, with no lower bound', () => { - // The bound on the narrowing: the card that filed this defect listed - // "a negative TTL" among the values the parse would refuse, and the - // schema declares no such rule. This seam enforces the contract as - // written; a lower bound is `packages/spec`'s to declare. - expect(normalized({ metadata: { cacheTtl: -1 } }).metadata.cacheTtl).toBe(-1); - expect(normalized({ metadata: { cacheTtl: 0 } }).metadata.cacheTtl).toBe(0); - }); + // The two enum read-backs (`routes.nameTransform`, `crud.objectParamStyle`) + // and the "KEEPS a negative `metadata.cacheTtl`" bound used to live here. + // All three keys are tombstones since #14691, so those pins are reversed + // in §E; the negative-TTL bound the card once argued about is moot — no + // TTL of any sign is accepted. it('STRIPS an unknown key inside a sub-object rather than refusing it — the schemas are non-strict', () => { expect(() => construct({ batch: { bogus: 1 } as never })).not.toThrow(); - expect(() => construct({ routes: { overrides: { account: { enabled: false, bogus: 1 } as never } } })).not.toThrow(); + expect(() => construct({ metadata: { endpoints: { types: true, bogus: 1 } as never } })).not.toThrow(); }); it('KEEPS the retired top-level `openApi31` key at its ignore posture — the whole-config tombstone is not run here', () => { @@ -305,9 +275,9 @@ describe('[#11984] §C regression guards — the narrowing is exactly the declar describe('[#11984] §D the four siblings consume the parsed output', () => { it('a declared in-range value is preserved, not replaced by the default', () => { expect(normalized({ batch: { maxBatchSize: 500 } }).batch.maxBatchSize).toBe(500); - expect(normalized({ metadata: { cacheTtl: 60 } }).metadata.cacheTtl).toBe(60); + expect(normalized({ metadata: { prefix: '/metadata' } }).metadata.prefix).toBe('/metadata'); expect(normalized({ crud: { dataPrefix: '/records' } }).crud.dataPrefix).toBe('/records'); - expect(normalized({ routes: { includeObjects: ['account'] } }).routes.includeObjects).toEqual(['account']); + expect(normalized({ metadata: { enableCache: false } }).metadata.enableCache).toBe(false); }); it('KEEPS `metadata.maskObjectFields: false` — the ADR-0106 D8 opt-out survives the parse', () => { @@ -328,28 +298,89 @@ describe('[#11984] §D the four siblings consume the parsed output', () => { }); it('KEEPS a partial `batch.operations` and `metadata.endpoints` the same way', () => { + // [#14691] `upsertMany` and `schema` left both shapes: the three live + // switches per block are exactly what the normalized config carries. expect(normalized({ batch: { operations: { deleteMany: false } } }).batch.operations).toEqual({ - createMany: true, updateMany: true, deleteMany: false, upsertMany: true, + createMany: true, updateMany: true, deleteMany: false, }); - expect(normalized({ metadata: { endpoints: { schema: false } } }).metadata.endpoints).toEqual({ - types: true, items: true, item: true, schema: false, + expect(normalized({ metadata: { endpoints: { item: false } } }).metadata.endpoints).toEqual({ + types: true, items: true, item: false, }); }); - it('KEEPS a partial `crud.patterns` — the written pattern survives, and no pattern is invented', () => { - // `patterns` is `z.record(CrudOperation, CrudEndpointPatternSchema.optional())`, - // which zod 4 reads as an EXHAUSTIVE record: the parse walks all five - // operations and writes each one's value into the output, so the four - // an author did not write come back as explicit `undefined` entries — - // exactly the declared shape (`Record`, - // which is also why this fixture needs `as never`: the input TYPE - // demands all five keys while the runtime accepts a partial). That - // key-enumeration quirk is the spec's to settle (`z.partialRecord`), - // filed separately, so this pin asserts only what the contract - // promises whichever way that lands: the one written pattern is - // preserved, and no operation gains a pattern it was not given. - const cfg = normalized({ crud: { patterns: { list: { method: 'GET', path: '/x' } } as never } }); - expect(cfg.crud.patterns?.list).toEqual({ method: 'GET', path: '/x' }); - expect(Object.values(cfg.crud.patterns ?? {}).filter((pattern) => pattern !== undefined)).toHaveLength(1); + // The `crud.patterns` preservation pin (and the `z.partialRecord` question it + // deferred to #14365) is gone with the key — #14691 retired the record. +}); + +// --------------------------------------------------------------------------- +// §E — [#14691] the retired keys are REFUSED at construction, with the +// prescription, whatever the value. These are the #11984 pins above, reversed: +// the SERVER is still what is measured (the schema-level pins live in +// `packages/spec`'s `rest-server.test.ts`), and `refusal()`'s `''`-on-success +// keeps every `toContain` its own positive control. +// --------------------------------------------------------------------------- + +describe('[#14691] §E the retired sub-config keys are refused at construction', () => { + it('refuses `crud.patterns` and `crud.objectParamStyle` — both former enum values included', () => { + const patterns = refusal({ crud: { patterns: { list: { method: 'GET', path: '/x' } } } } as never); + expect(patterns).toContain('crud.patterns'); + expect(patterns).toContain('CrudEndpointsConfigSchema'); + expect(patterns).toMatch(/was removed in @objectstack\/spec 17/); + for (const objectParamStyle of ['path', 'query']) { + const message = refusal({ crud: { objectParamStyle } } as never); + expect(message, objectParamStyle).toContain('crud.objectParamStyle'); + expect(message, objectParamStyle).toMatch(/was removed/); + } + }); + + it('refuses `metadata.cacheTtl` — the old default, zero and the negative value the old contract accepted', () => { + for (const cacheTtl of [3600, 0, -1]) { + const message = refusal({ metadata: { cacheTtl } } as never); + expect(message, String(cacheTtl)).toContain('metadata.cacheTtl'); + expect(message, String(cacheTtl)).toContain('MetadataEndpointsConfigSchema'); + expect(message, String(cacheTtl)).toMatch(/was removed/); + } + }); + + it('refuses `metadata.endpoints.schema` — a nested tombstone inside a live block', () => { + const message = refusal({ metadata: { endpoints: { schema: false } } } as never); + expect(message).toContain('metadata.endpoints.schema'); + expect(message).toMatch(/does not exist/); + }); + + it('refuses `batch.operations.upsertMany` and `batch.defaultAtomic`', () => { + const upsert = refusal({ batch: { operations: { upsertMany: false } } } as never); + expect(upsert).toContain('batch.operations.upsertMany'); + expect(upsert).toMatch(/never built/); + for (const defaultAtomic of [true, false]) { + const message = refusal({ batch: { defaultAtomic } } as never); + expect(message, String(defaultAtomic)).toContain('batch.defaultAtomic'); + expect(message, String(defaultAtomic)).toMatch(/options\.atomic/); + } + }); + + it('refuses every `routes.*` key — the whole sub-object is tombstones', () => { + for (const [key, routes] of Object.entries({ + includeObjects: { includeObjects: ['account'] }, + excludeObjects: { excludeObjects: ['system_log'] }, + nameTransform: { nameTransform: 'none' }, + overrides: { overrides: { account: { enabled: false } } }, + })) { + const message = refusal({ routes } as never); + expect(message, key).toContain(`routes.${key}`); + expect(message, key).toContain('RouteGenerationConfigSchema'); + expect(message, key).toMatch(/was removed/); + } + }); + + it('an empty `routes` sub-object still constructs — the tombstones refuse keys, not the block', () => { + expect(() => construct({ routes: {} })).not.toThrow(); + expect(normalized({ routes: {} }).routes).toEqual({}); + }); + + it('the plugin path refuses the same keys (both cast hops)', async () => { + await expect( + createRestApiPlugin({ api: { batch: { defaultAtomic: false } } } as never).start!(bootCtx()), + ).rejects.toThrow(/batch\.defaultAtomic.*was removed/s); }); }); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index b538837362..c3bf12734a 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -219,8 +219,6 @@ "CrossObjectBatchRequestSchema (const)", "CrossObjectBatchResponse (type)", "CrossObjectBatchResponseSchema (const)", - "CrudEndpointPattern (type)", - "CrudEndpointPatternSchema (const)", "CrudEndpointsConfig (type)", "CrudEndpointsConfigParsed (type)", "CrudEndpointsConfigSchema (const)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 185d5003c8..759a0d8a3d 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -28,7 +28,6 @@ "api/AuthProviderInfo:type = \"social\"", "api/BatchConfig:enabled = true", "api/BatchConfig:maxRecordsPerBatch = 200", - "api/BatchEndpointsConfig:defaultAtomic = true", "api/BatchEndpointsConfig:enableBatchEndpoint = true", "api/BatchEndpointsConfig:maxBatchSize = 200", "api/BatchLoadingStrategy:associationLoading = \"batch\"", @@ -53,7 +52,6 @@ "api/CrossObjectBatchOperation:action = \"create\"", "api/CrossObjectBatchRequest:atomic = true", "api/CrudEndpointsConfig:dataPrefix = \"/data\"", - "api/CrudEndpointsConfig:objectParamStyle = \"path\"", "api/DataLoaderConfig:batchScheduleFn = \"microtask\"", "api/DataLoaderConfig:cacheEnabled = true", "api/DataLoaderConfig:coalesceRequests = true", @@ -98,7 +96,6 @@ "api/MetadataBulkRegisterRequest:continueOnError = false", "api/MetadataBulkRegisterRequest:validate = true", "api/MetadataCacheResponse:notModified = false", - "api/MetadataEndpointsConfig:cacheTtl = 3600", "api/MetadataEndpointsConfig:enableCache = true", "api/MetadataEndpointsConfig:maskObjectFields = true", "api/MetadataEndpointsConfig:prefix = \"/meta\"", @@ -175,7 +172,6 @@ "api/RestQueryAdapter:filterStyle = \"bracket\"", "api/RouteDefinition:category = \"api\"", "api/RouteDefinition:public = false", - "api/RouteGenerationConfig:nameTransform = \"none\"", "api/RouterConfig:basePath = \"/api\"", "api/RouterConfig:mounts = {\"ai\":\"/ai\",\"analytics\":\"/analytics\",\"auth\":\"/auth\",\"automation\":\"/automation\",\"data\":\"/data\",\"i18n\":\"/i18n\",\"metadata\":\"/meta\",\"notifications\":\"/notifications\",\"packages\":\"/packages\",\"realtime\":\"/realtime\",\"storage\":\"/storage\",\"ui\":\"/ui\"}", "api/ScheduleExportRequest:format = \"csv\"", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 82433e0da8..0026219134 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -244,7 +244,7 @@ "api/BatchDataResponse:succeeded", "api/BatchDataResponse:success", "api/BatchDataResponse:total", - "api/BatchEndpointsConfig:defaultAtomic", + "api/BatchEndpointsConfig:defaultAtomic [RETIRED]", "api/BatchEndpointsConfig:enableBatchEndpoint", "api/BatchEndpointsConfig:maxBatchSize", "api/BatchEndpointsConfig:operations", @@ -417,14 +417,10 @@ "api/CrossObjectBatchRequest:operations", "api/CrossObjectBatchResponse:droppedFields", "api/CrossObjectBatchResponse:results", - "api/CrudEndpointPattern:description", - "api/CrudEndpointPattern:method", - "api/CrudEndpointPattern:path", - "api/CrudEndpointPattern:summary", "api/CrudEndpointsConfig:dataPrefix", - "api/CrudEndpointsConfig:objectParamStyle", + "api/CrudEndpointsConfig:objectParamStyle [RETIRED]", "api/CrudEndpointsConfig:operations", - "api/CrudEndpointsConfig:patterns", + "api/CrudEndpointsConfig:patterns [RETIRED]", "api/CursorMessage:cursor", "api/CursorMessage:messageId", "api/CursorMessage:timestamp", @@ -1087,7 +1083,7 @@ "api/MetadataDependentsResponse:error", "api/MetadataDependentsResponse:meta", "api/MetadataDependentsResponse:success", - "api/MetadataEndpointsConfig:cacheTtl", + "api/MetadataEndpointsConfig:cacheTtl [RETIRED]", "api/MetadataEndpointsConfig:enableCache", "api/MetadataEndpointsConfig:endpoints", "api/MetadataEndpointsConfig:maskObjectFields", @@ -1521,10 +1517,10 @@ "api/RouteDefinition:rateLimit", "api/RouteDefinition:summary", "api/RouteDefinition:timeout", - "api/RouteGenerationConfig:excludeObjects", - "api/RouteGenerationConfig:includeObjects", - "api/RouteGenerationConfig:nameTransform", - "api/RouteGenerationConfig:overrides", + "api/RouteGenerationConfig:excludeObjects [RETIRED]", + "api/RouteGenerationConfig:includeObjects [RETIRED]", + "api/RouteGenerationConfig:nameTransform [RETIRED]", + "api/RouteGenerationConfig:overrides [RETIRED]", "api/RouteHealthEntry:declared", "api/RouteHealthEntry:handlerRegistered", "api/RouteHealthEntry:healthStatus", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 315a3bca85..b089f3fbed 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -175,8 +175,6 @@ "CrossObjectBatchRequestSchema": "api/CrossObjectBatchRequest", "CrossObjectBatchResponse": "api/CrossObjectBatchResponse", "CrossObjectBatchResponseSchema": "api/CrossObjectBatchResponse", - "CrudEndpointPattern": "api/CrudEndpointPattern", - "CrudEndpointPatternSchema": "api/CrudEndpointPattern", "CrudEndpointsConfig": "api/CrudEndpointsConfig", "CrudEndpointsConfigSchema": "api/CrudEndpointsConfig", "CrudOperation": "api/CrudOperation", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 2d4387df9a..8b30d5cd81 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -219,8 +219,6 @@ "CrossObjectBatchRequestSchema": "src/api/batch.zod.ts#CrossObjectBatchRequestSchema (const)", "CrossObjectBatchResponse": "src/api/batch.zod.ts#CrossObjectBatchResponse (type)", "CrossObjectBatchResponseSchema": "src/api/batch.zod.ts#CrossObjectBatchResponseSchema (const)", - "CrudEndpointPattern": "src/api/rest-server.zod.ts#CrudEndpointPattern (type)", - "CrudEndpointPatternSchema": "src/api/rest-server.zod.ts#CrudEndpointPatternSchema (const)", "CrudEndpointsConfig": "src/api/rest-server.zod.ts#CrudEndpointsConfig (type)", "CrudEndpointsConfigParsed": "src/api/rest-server.zod.ts#CrudEndpointsConfigParsed (type)", "CrudEndpointsConfigSchema": "src/api/rest-server.zod.ts#CrudEndpointsConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 0b59138f15..26ba5096f0 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -93,7 +93,6 @@ "api/CrossObjectBatchOperation", "api/CrossObjectBatchRequest", "api/CrossObjectBatchResponse", - "api/CrudEndpointPattern", "api/CrudEndpointsConfig", "api/CrudOperation", "api/CursorMessage", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 0fce0ebcf6..ab66b16b4c 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -893,10 +893,10 @@ marker where the Notes cell goes, never a guess at what belongs there. | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | | capability | seeded 2026-08-08 (#5961; PR #6540) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — PR #6540 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and PR #6540 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowOrgOverride: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | | manifest | seeded 2026-08-23 (#10728) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (#10627), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 20 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity` is security-shaped and not retired here**: it declares per-file digests the runtime is documented to re-verify at unpack while nothing computes or checks them (#11331 — cloud persists the map, nothing verifies it). `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (#11330) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (#10812; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and #10724 then executed — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. #10726 then executed too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete | -| crud_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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 5 = `objectParamStyle` (every CRUD route takes the object name as a PATH segment; `'query'` is validated against the enum and mounts exactly what `'path'` mounts) and the four members of `patterns` (custom URL patterns are normalized and never read — every route is mounted from hard-coded method/path pairs in `registerCrudEndpoints`). Live 6 = the five `operations.*` switches, each gating a route mount, and `dataPrefix`, which has five independent consumers and moves the mounted paths and the advertised discovery document together. #14365 asks a different question about `patterns` (its `z.record` input type demands all five operations) — a declaration defect, not a liveness one, not re-derived here | -| 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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 | -| 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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 | -| 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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 | +| crud_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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 5 = `objectParamStyle` (every CRUD route takes the object name as a PATH segment; `'query'` is validated against the enum and mounts exactly what `'path'` mounts) and the four members of `patterns` (custom URL patterns are normalized and never read — every route is mounted from hard-coded method/path pairs in `registerCrudEndpoints`). Live 6 = the five `operations.*` switches, each gating a route mount, and `dataPrefix`, which has five independent consumers and moves the mounted paths and the advertised discovery document together. #14365 asks a different question about `patterns` (its `z.record` input type demands all five operations) — a declaration defect, not a liveness one, not re-derived here **#14691 RETIRED the dead set (2026-09-03, ADR-0049 enforce-or-remove)**: `patterns` and `objectParamStyle` are `retiredKey()` tombstones (the schema is a non-strict `z.object`, so the rows STAY `dead` with a REMOVED note — the rls.priority precedent) and the four `patterns.*` child rows collapse into the one `patterns` row because the value def `CrudEndpointPattern` left the shape with its key (RETIRED_DEFS_BY_MAJOR[18]). REMOVE, not ENFORCE, because the mounted CRUD paths are the contract the client SDK, the discovery document and /openapi.json all describe — a per-operation pattern knob could only make them lie — and an endpoint on a custom path is a declarative `api` endpoint. `evidenceScope` widened to `cross-repo` on the cloud reading (#14796 @9b6abe0f2fd5: zero, structural). #14365's `z.partialRecord` question closes with the record | +| 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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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 drills exactly ONE level, 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 deliberately not enrolled: its consumption seam is still validate-only and is the subject of its own card, so a census of it would record a half that is about to move. **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) | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/batch_endpoints.json b/packages/spec/liveness/batch_endpoints.json index 7cda6f0dc7..a51ab7f248 100644 --- a/packages/spec/liveness/batch_endpoints.json +++ b/packages/spec/liveness/batch_endpoints.json @@ -46,17 +46,17 @@ }, "upsertMany": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Its three siblings each gate a route mount; this one gates nothing, because there is no upsertMany route to gate — the switch was declared for a batch operation that was never built (`this.protocol` has createManyData / updateManyData / deleteManyData and no upsert counterpart). `operations.upsertMany: false` therefore disables nothing and `true` enables nothing." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); there is no `POST /data/:object/upsertMany` and no protocol member behind it (createManyData / updateManyData / deleteManyData only); upsert is an operation TYPE of the generic `POST /data/:object/batch` endpoint (`BatchOperationType` 'upsert', keyed by `externalId`), which `enableBatchEndpoint` gates. Pre-retirement census note: 0 read sites at 2514d49f3. Its three siblings each gate a route mount; this one gates nothing, because there is no upsertMany route to gate — the switch was declared for a batch operation that was never built (`this.protocol` has createManyData / updateManyData / deleteManyData and no upsert counterpart). `operations.upsertMany: false` therefore disables nothing and `true` enables nothing. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." } } }, "defaultAtomic": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. The key is normalized into `this.config.batch.defaultAtomic` and no batch handler consults it, so `batch.defaultAtomic: false` changes no batch's transaction mode. A switch whose describe() promises a transaction default while the transaction default is decided elsewhere is the false-compliance shape this ledger exists to surface, which is why it is recorded rather than left to a reader's grep." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); atomicity is decided per request by `options.atomic` in the batch body (`BatchOptionsSchema`, ADR-0119 D4 — opt-in, default false, deliberately aligned to what every caller already got); a server-side default that flipped it silently would change the failure semantics of callers who send nothing, the move ADR-0119 D4 refused, which is why this family resolved to REMOVE rather than ENFORCE. Pre-retirement census note: 0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. The key is normalized into `this.config.batch.defaultAtomic` and no batch handler consults it, so `batch.defaultAtomic: false` changes no batch's transaction mode. A switch whose describe() promises a transaction default while the transaction default is decided elsewhere is the false-compliance shape this ledger exists to surface, which is why it is recorded rather than left to a reader's grep. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." } } } diff --git a/packages/spec/liveness/crud_endpoints.json b/packages/spec/liveness/crud_endpoints.json index 32bb370d82..dcb17095d2 100644 --- a/packages/spec/liveness/crud_endpoints.json +++ b/packages/spec/liveness/crud_endpoints.json @@ -47,32 +47,10 @@ } }, "patterns": { - "children": { - "method": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. The `patterns` record is normalized into `this.config.crud.patterns` and never read: every CRUD route is mounted from the hard-coded method/path pairs in rest-server.ts#registerCrudEndpoints, so a custom pattern changes nothing. The four rows here are that one container's members — `patterns` is dead as a whole, and each member is recorded so the verdict is falsifiable per key rather than inherited. Related: #14365 asks a different question about this same key (its z.record input type demands all five operations); that is a declaration defect, not a liveness one, and is not re-derived here." - }, - "path": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. See `patterns.method`: the container is never read, so a custom URL path pattern is accepted, validated and ignored." - }, - "summary": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. See `patterns.method`. Additionally documentation-shaped: nothing in the served OpenAPI document reads a configured per-operation summary — that document is the pre-generated @objectstack/spec contract enriched at request time." - }, - "description": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. See `patterns.summary`." - } - } + "status": "dead", + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); every CRUD route is mounted from the fixed method/path pairs in rest-server.ts#registerCrudEndpoints, which the client SDK, the discovery document and the served /openapi.json all describe, so a per-operation method/path knob was never enforceable without making those surfaces lie; `crud.dataPrefix` is the live knob that moves them, and an endpoint on a custom path or method is a declarative `api` endpoint (`type: 'object_operation'`). The four child rows this container carried (`method` / `path` / `summary` / `description` — the members of `CrudEndpointPatternSchema`) collapse into this one row: the tombstone is a leaf, the value def left the shape with it (RETIRED_DEFS_BY_MAJOR[18] `api/CrudEndpointPattern`), and rows for keys that left the walked shape would report ORPHAN. Closes #14365's question about the record's input type — there is no record left to reshape. Pre-retirement census note: 0 read sites at 2514d49f3. The `patterns` record is normalized into `this.config.crud.patterns` and never read: every CRUD route is mounted from the hard-coded method/path pairs in rest-server.ts#registerCrudEndpoints, so a custom pattern changes nothing. The four rows here are that one container's members — `patterns` is dead as a whole, and each member is recorded so the verdict is falsifiable per key rather than inherited. Related: #14365 asks a different question about this same key (its z.record input type demands all five operations); that is a declaration defect, not a liveness one, and is not re-derived here. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." }, "dataPrefix": { "status": "live", @@ -84,9 +62,9 @@ }, "objectParamStyle": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. Every CRUD route takes the object name as a PATH segment; `'query'` is accepted, validated against the enum since #11984, and mounts exactly what `'path'` mounts. Enforcing it would be a second routing shape for every data route, which is why the call is a follow-up and not a ledger decision." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); every CRUD route takes the object name as a PATH segment, so `'query'` was validated (since #11984) and mounted exactly what `'path'` mounts; there is no second routing shape to select. Pre-retirement census note: 0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. Every CRUD route takes the object name as a PATH segment; `'query'` is accepted, validated against the enum since #11984, and mounts exactly what `'path'` mounts. Enforcing it would be a second routing shape for every data route, which is why the call is a follow-up and not a ledger decision. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." } } } diff --git a/packages/spec/liveness/metadata_endpoints.json b/packages/spec/liveness/metadata_endpoints.json index ec9aa34185..560963358f 100644 --- a/packages/spec/liveness/metadata_endpoints.json +++ b/packages/spec/liveness/metadata_endpoints.json @@ -20,9 +20,9 @@ }, "cacheTtl": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. `enableCache` is live and `cacheTtl` is not: the cached branch delegates to the protocol's own `getMetaItemCached`, which does not take a TTL from this config, and no ETag / Cache-Control / Last-Modified header is built from this value anywhere. `metadata.cacheTtl: 60` changes no header and no cache lifetime. Travelling with this row rather than as a separate defect (triage ruling, 2026-09-02): the schema declares `z.number().int()` with no lower bound, so a NEGATIVE TTL is accepted — #11984 pins `-1` as accepted precisely because that is what the contract says. If this key survives its enforce-or-remove call, that bound is part of enforcing it; if it goes, the pin goes with it. Either way it is one key's story, not two." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); `metadata.enableCache` is the live switch — it selects the protocol's `getMetaItemCached` read path, which takes no TTL — and no Cache-Control / ETag / Last-Modified header was ever built from this value; a declarative `api` endpoint's `cacheTtl` is the key that does reach the wire. The negative-bound observation this row carried (no lower bound, `-1` accepted, #11984 pinned it as accepted) dies with the key; the #11984 pin is reversed to a refusal pin. Pre-retirement census note: 0 read sites at 2514d49f3. `enableCache` is live and `cacheTtl` is not: the cached branch delegates to the protocol's own `getMetaItemCached`, which does not take a TTL from this config, and no ETag / Cache-Control / Last-Modified header is built from this value anywhere. `metadata.cacheTtl: 60` changes no header and no cache lifetime. Travelling with this row rather than as a separate defect (triage ruling, 2026-09-02): the schema declares `z.number().int()` with no lower bound, so a NEGATIVE TTL is accepted — #11984 pins `-1` as accepted precisely because that is what the contract says. If this key survives its enforce-or-remove call, that bound is part of enforcing it; if it goes, the pin goes with it. Either way it is one key's story, not two. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." }, "maskObjectFields": { "status": "live", @@ -60,9 +60,9 @@ }, "schema": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. The odd one out of the four: its three siblings each gate a route mount and this one gates nothing, because the route its describe() names — `GET /meta/:type/:name/schema` — does not exist. packages/rest/src mounts no path ending in `/schema` at all, so `endpoints.schema: false` removes nothing and `true` adds nothing; the switch was declared for a route that was never built, the same shape as `batch.operations.upsertMany`. This is also the sharpest case in the family for why this container may not carry one blanket verdict: three live members and one dead one under a single `endpoints` key would have hidden exactly this." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the route it gated — `GET /meta/:type/:name/schema` — does not exist (packages/rest/src mounts no path ending in `/schema`), so there is nothing to enforce; `endpoints.types` / `items` / `item` are the switches that gate real mounts and stay live. Pre-retirement census note: 0 read sites at 2514d49f3. The odd one out of the four: its three siblings each gate a route mount and this one gates nothing, because the route its describe() names — `GET /meta/:type/:name/schema` — does not exist. packages/rest/src mounts no path ending in `/schema` at all, so `endpoints.schema: false` removes nothing and `true` adds nothing; the switch was declared for a route that was never built, the same shape as `batch.operations.upsertMany`. This is also the sharpest case in the family for why this container may not carry one blanket verdict: three live members and one dead one under a single `endpoints` key would have hidden exactly this. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." } } } diff --git a/packages/spec/liveness/route_generation.json b/packages/spec/liveness/route_generation.json index 5290eb1161..52512de5cc 100644 --- a/packages/spec/liveness/route_generation.json +++ b/packages/spec/liveness/route_generation.json @@ -4,43 +4,27 @@ "props": { "includeObjects": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Normalized into `this.config.routes.includeObjects` and never consulted: route registration iterates the registered objects with no include filter, so an author who names three objects still gets routes for all of them. Dead at the CONSUMER — the authored value arrives intact and is simply never read." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); per-object API exposure is declared ON the object and enforced by the REST data surface (rest-server.ts#enforceApiAccess: `enable.apiEnabled: false` → 404, an `enable.apiMethods` whitelist → 405 for unlisted operations), so the capability this key promised already exists at its proper seat and the key was a second, unread dialect of it (Prime Directive #12: one strict contract, not N). Pre-retirement census note: 0 read sites at 2514d49f3. Normalized into `this.config.routes.includeObjects` and never consulted: route registration iterates the registered objects with no include filter, so an author who names three objects still gets routes for all of them. Dead at the CONSUMER — the authored value arrives intact and is simply never read. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." }, "excludeObjects": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Same shape as `includeObjects`: normalized, never consulted, so an excluded object is still mounted. This key is the one customer-visible member of the ten: RestServerConfigSchema's own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, and the generated reference page (content/docs/references/api/rest-server.mdx) repeats the declaration — published prose promising a capability the runtime does not deliver. Fixing that example belongs to whichever limb this key lands on, not here." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); per-object API exposure is declared ON the object and enforced by the REST data surface (rest-server.ts#enforceApiAccess: `enable.apiEnabled: false` → 404, an `enable.apiMethods` whitelist → 405 for unlisted operations), so the capability this key promised already exists at its proper seat and the key was a second, unread dialect of it (Prime Directive #12: one strict contract, not N). The `@example` on RestServerConfigSchema that advertised `routes: { excludeObjects: ['system_log'] }` is corrected in the same change. Pre-retirement census note: 0 read sites at 2514d49f3. Same shape as `includeObjects`: normalized, never consulted, so an excluded object is still mounted. This key is the one customer-visible member of the ten: RestServerConfigSchema's own `@example` advertises `routes: { excludeObjects: ['system_log'] }`, and the generated reference page (content/docs/references/api/rest-server.mdx) repeats the declaration — published prose promising a capability the runtime does not deliver. Fixing that example belongs to whichever limb this key lands on, not here. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." }, "nameTransform": { "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. The enum is enforced at the door since #11984 — `'snake_case'` is refused at construction — but no route builder transforms a name, so `'plural'`, `'kebab-case'` and `'camelCase'` all mount exactly what `'none'` mounts. An enum that is validated and then ignored is the clearest case in this file of accept/reject and liveness being different questions." + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); the object `name` is the canonical id on every surface, the REST path segment included (Prime Directive #6), so a URL transform contradicts the one-name rule and was never a candidate for enforcement; the enum was validated (since #11984) and every value mounted what `'none'` mounts. Pre-retirement census note: 0 read sites at 2514d49f3. Repo-wide there are no reads outside packages/spec and rest-server.ts either. The enum is enforced at the door since #11984 — `'snake_case'` is refused at construction — but no route builder transforms a name, so `'plural'`, `'kebab-case'` and `'camelCase'` all mount exactly what `'none'` mounts. An enum that is validated and then ignored is the clearest case in this file of accept/reject and liveness being different questions. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." }, "overrides": { - "children": { - "enabled": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. The per-object `overrides` record is normalized and never read, so no object's routes can be turned off through it. ⚠️ Do not read the `overrides` hits in packages/rest/src as consumers of this key: rest-server.ts#registerDataActionEndpoints reads `body.overrides` off a REQUEST BODY, and http-request-test-builder.ts takes an `overrides` argument — different keys with the same name." - }, - "basePath": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. Inherits the container's fate: the record is never read, so a per-object custom base path changes no mounted path. The live base path is `crud.dataPrefix` (see the crud_endpoints ledger), which is deployment-wide rather than per-object." - }, - "operations": { - "status": "dead", - "verifiedAt": "2026-09-02", - "evidenceScope": "in-repo", - "note": "0 read sites at 2514d49f3. The per-object operation switches are never read. The operation switches that ARE read are the deployment-wide `crud.operations.*` (crud_endpoints ledger) — this key is their per-object counterpart and it does nothing." - } - } + "status": "dead", + "verifiedAt": "2026-09-03", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-09-03 (#14691) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error). 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-server-config-dead-keys-retired` carries the prescription. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); `enabled` and `operations` duplicated the object's own enforced exposure keys (`enable.apiEnabled` / `enable.apiMethods`, rest-server.ts#enforceApiAccess) and `basePath` per object would contradict the one deployment-wide data base (`crud.dataPrefix`) the discovery document advertises as `routes.data` — so the family resolved to REMOVE, not to the ENFORCE split triage held open for it. The three child rows this container carried (`enabled` / `basePath` / `operations`) collapse into this one row: the tombstone is a leaf, and rows for keys that left the walked shape would report ORPHAN. Closes #14365's question about `overrides.*.operations` — no record left. Pre-retirement census note: 0 read sites at 2514d49f3. The per-object `overrides` record is normalized and never read, so no object's routes can be turned off through it. ⚠️ Do not read the `overrides` hits in packages/rest/src as consumers of this key: rest-server.ts#registerDataActionEndpoints reads `body.overrides` off a REQUEST BODY, and http-request-test-builder.ts takes an `overrides` argument — different keys with the same name. Scope widened 2026-09-03: in-repo census at 2514d49f3 (0 read sites) + objectui @d4c6a86 clean + cloud @9b6abe0f2fd5 clean STRUCTURALLY (#14796: cloud never authors a `RestServerConfig`), so `evidenceScope` is `cross-repo`." } } } diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 6bad11dca1..b33daf0eea 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -58,8 +58,8 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | -| `crud_endpoints` | 6 | 0 | 0 | 5 | 0 | 11 | +| `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | | `metadata_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | -| `route_generation` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **843** | **5** | **1** | **89** | **10** | **948** | +| `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | +| **total** | **843** | **5** | **1** | **84** | **10** | **943** | diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts index f303b5f5c5..2179bf544a 100644 --- a/packages/spec/scripts/liveness/empty-state-registry.mts +++ b/packages/spec/scripts/liveness/empty-state-registry.mts @@ -148,13 +148,10 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [ semantics: 'scope', rationale: 'Replay filter for a dead-letter/replay operation. Selects work to redo; grants nothing.', }, - { - file: 'packages/spec/src/api/rest-server.zod.ts', - property: 'includeObjects', - semantics: 'scope', - rationale: - 'Which objects get generated REST routes. Route existence is not authorization — the generated routes still run the full permission stack, so a wider set is not a wider grant.', - }, + // `packages/spec/src/api/rest-server.zod.ts` `includeObjects` used to sit here + // as `scope`. Retired in #14691 (ADR-0049 enforce-or-remove — nothing ever read + // it), so its "(empty = all)" statement is gone and the entry went with it; + // the tombstone's prescription carries no permissive empty-state claim. { file: 'packages/spec/src/data/object.zod.ts', property: 'stageField', diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index 88b974dcb4..370d4fd9e5 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { RestApiConfigSchema, CrudOperation, - CrudEndpointPatternSchema, CrudEndpointsConfigSchema, MetadataEndpointsConfigSchema, BatchEndpointsConfigSchema, @@ -196,36 +195,17 @@ describe('CrudOperation', () => { }); }); -describe('CrudEndpointPatternSchema', () => { - it('should accept basic pattern', () => { - const pattern = CrudEndpointPatternSchema.parse({ - method: 'GET', - path: '/data/{object}', - }); - - expect(pattern.method).toBe('GET'); - expect(pattern.path).toBe('/data/{object}'); - }); - - it('should accept pattern with documentation', () => { - const pattern = CrudEndpointPatternSchema.parse({ - method: 'POST', - path: '/data/{object}', - summary: 'Create record', - description: 'Creates a new record in the specified object', - }); - - expect(pattern.summary).toBe('Create record'); - expect(pattern.description).toBeDefined(); - }); -}); +// `CrudEndpointPatternSchema` tests were removed with the schema (#14691, ADR-0049 +// enforce-or-remove): its only consumer, `crud.patterns`, is tombstoned below. describe('CrudEndpointsConfigSchema', () => { it('should accept default config', () => { const config = CrudEndpointsConfigSchema.parse({}); expect(config.dataPrefix).toBe('/data'); - expect(config.objectParamStyle).toBe('path'); + // `objectParamStyle` is a tombstone since #14691: the parsed output carries + // no default for it any more. + expect(config).not.toHaveProperty('objectParamStyle'); }); it('should accept custom operations config', () => { @@ -242,15 +222,12 @@ describe('CrudEndpointsConfigSchema', () => { expect(config.operations?.delete).toBe(false); }); - it('should accept custom patterns', () => { - const config = CrudEndpointsConfigSchema.parse({ + it('[#14691] REJECTS `patterns` with the retirement prescription — the key never customized a route', () => { + expect(() => CrudEndpointsConfigSchema.parse({ patterns: { create: { method: 'POST', path: '/objects/{object}' }, - read: { method: 'GET', path: '/objects/{object}/:id' }, }, - }); - - expect(config.patterns?.create.path).toBe('/objects/{object}'); + })).toThrow(/`crud\.patterns` was removed.*nothing ever read it.*Delete the key.*declarative `api` endpoint/s); }); it('should accept custom data prefix', () => { @@ -261,12 +238,22 @@ describe('CrudEndpointsConfigSchema', () => { expect(config.dataPrefix).toBe('/objects'); }); - it('should accept query param style', () => { + it('[#14691] REJECTS `objectParamStyle` — both former enum values, with the prescription', () => { + for (const objectParamStyle of ['path', 'query']) { + expect(() => CrudEndpointsConfigSchema.parse({ objectParamStyle }), objectParamStyle) + .toThrow(/`crud\.objectParamStyle` was removed.*path segment.*Delete the key/s); + } + }); + + it('[#14691] the tombstones reject one key each, not the config — every live key still parses', () => { const config = CrudEndpointsConfigSchema.parse({ - objectParamStyle: 'query', + operations: { list: false }, + dataPrefix: '/records', }); - - expect(config.objectParamStyle).toBe('query'); + expect(config.dataPrefix).toBe('/records'); + expect(config.operations?.list).toBe(false); + expect(config).not.toHaveProperty('patterns'); + expect(config).not.toHaveProperty('objectParamStyle'); }); }); @@ -276,7 +263,8 @@ describe('MetadataEndpointsConfigSchema', () => { expect(config.prefix).toBe('/meta'); expect(config.enableCache).toBe(true); - expect(config.cacheTtl).toBe(3600); + // `cacheTtl` is a tombstone since #14691: no default is materialized. + expect(config).not.toHaveProperty('cacheTtl'); }); it('should accept custom prefix', () => { @@ -287,27 +275,37 @@ describe('MetadataEndpointsConfigSchema', () => { expect(config.prefix).toBe('/metadata'); }); - it('should accept cache config', () => { + it('should accept the cache switch', () => { const config = MetadataEndpointsConfigSchema.parse({ enableCache: false, - cacheTtl: 7200, }); expect(config.enableCache).toBe(false); - expect(config.cacheTtl).toBe(7200); }); - it('should accept endpoints config', () => { + it('[#14691] REJECTS `cacheTtl` — including the negative value the old contract accepted', () => { + for (const cacheTtl of [7200, 0, -1]) { + expect(() => MetadataEndpointsConfigSchema.parse({ cacheTtl }), String(cacheTtl)) + .toThrow(/`metadata\.cacheTtl` was removed.*takes no TTL.*Delete the key/s); + } + }); + + it('should accept endpoints config — the three switches that gate real mounts', () => { const config = MetadataEndpointsConfigSchema.parse({ endpoints: { types: true, items: true, - item: true, - schema: false, + item: false, }, }); - expect(config.endpoints?.schema).toBe(false); + expect(config.endpoints?.item).toBe(false); + expect(config.endpoints).not.toHaveProperty('schema'); + }); + + it('[#14691] REJECTS `endpoints.schema` — it gated a route that does not exist', () => { + expect(() => MetadataEndpointsConfigSchema.parse({ endpoints: { schema: false } })) + .toThrow(/`metadata\.endpoints\.schema` was removed.*does not exist.*Delete the key/s); }); it('[ADR-0106 D8] maskObjectFields defaults to true — the mask is ON unless opted out', () => { @@ -366,7 +364,8 @@ describe('BatchEndpointsConfigSchema', () => { expect(config.maxBatchSize).toBe(200); expect(config.enableBatchEndpoint).toBe(true); - expect(config.defaultAtomic).toBe(true); + // `defaultAtomic` is a tombstone since #14691: no default is materialized. + expect(config).not.toHaveProperty('defaultAtomic'); }); it('should accept custom max batch size', () => { @@ -387,87 +386,60 @@ describe('BatchEndpointsConfigSchema', () => { })).toThrow(); }); - it('should accept operations config', () => { + it('should accept operations config — the three switches that gate real mounts', () => { const config = BatchEndpointsConfigSchema.parse({ operations: { createMany: true, updateMany: true, deleteMany: false, - upsertMany: true, }, }); expect(config.operations?.deleteMany).toBe(false); + expect(config.operations).not.toHaveProperty('upsertMany'); }); - it('should accept non-atomic mode', () => { - const config = BatchEndpointsConfigSchema.parse({ - defaultAtomic: false, - }); + it('[#14691] REJECTS `operations.upsertMany` — it gated a route that was never built', () => { + expect(() => BatchEndpointsConfigSchema.parse({ operations: { upsertMany: false } })) + .toThrow(/`batch\.operations\.upsertMany` was removed.*never built.*Delete the key.*'upsert'/s); + }); - expect(config.defaultAtomic).toBe(false); + it('[#14691] REJECTS `defaultAtomic` — atomicity is the per-request `options.atomic`', () => { + for (const defaultAtomic of [true, false]) { + expect(() => BatchEndpointsConfigSchema.parse({ defaultAtomic }), String(defaultAtomic)) + .toThrow(/`batch\.defaultAtomic` was removed.*options\.atomic.*Delete the key/s); + } }); }); describe('RouteGenerationConfigSchema', () => { - it('should accept minimal config', () => { + it('should accept the empty sub-object — and materialize nothing, every key being a tombstone (#14691)', () => { const config = RouteGenerationConfigSchema.parse({}); - expect(config.nameTransform).toBe('none'); - }); - - it('should accept include objects', () => { - const config = RouteGenerationConfigSchema.parse({ - includeObjects: ['account', 'contact', 'opportunity'], - }); - - expect(config.includeObjects).toHaveLength(3); + expect(config).toEqual({}); }); - it('should accept exclude objects', () => { - const config = RouteGenerationConfigSchema.parse({ - excludeObjects: ['system_log', 'audit_trail'], - }); - - expect(config.excludeObjects).toHaveLength(2); + it('[#14691] REJECTS `includeObjects` / `excludeObjects` — the object declares its own exposure', () => { + expect(() => RouteGenerationConfigSchema.parse({ includeObjects: ['account', 'contact'] })) + .toThrow(/`routes\.includeObjects` was removed.*Delete the key.*enable\.apiEnabled.*enable\.apiMethods/s); + expect(() => RouteGenerationConfigSchema.parse({ excludeObjects: ['system_log'] })) + .toThrow(/`routes\.excludeObjects` was removed.*Delete the key.*enable\.apiEnabled.*enable\.apiMethods/s); }); - it('should accept name transform', () => { - const transforms = ['none', 'plural', 'kebab-case', 'camelCase'] as const; - - transforms.forEach(transform => { - const config = RouteGenerationConfigSchema.parse({ - nameTransform: transform, - }); - expect(config.nameTransform).toBe(transform); - }); + it('[#14691] REJECTS `nameTransform` — every former enum value, `none` included', () => { + for (const nameTransform of ['none', 'plural', 'kebab-case', 'camelCase']) { + expect(() => RouteGenerationConfigSchema.parse({ nameTransform }), nameTransform) + .toThrow(/`routes\.nameTransform` was removed.*Delete the key.*canonical id/s); + } }); - it('should accept overrides', () => { - const config = RouteGenerationConfigSchema.parse({ + it('[#14691] REJECTS `overrides` — the per-object record turned nothing on or off', () => { + expect(() => RouteGenerationConfigSchema.parse({ overrides: { - account: { - enabled: true, - basePath: '/accounts', - }, - contact: { - enabled: false, - }, - task: { - operations: { - create: true, - read: true, - update: true, - delete: false, - list: true, - }, - }, + account: { enabled: true, basePath: '/accounts' }, + contact: { enabled: false }, }, - }); - - expect(config.overrides?.account?.basePath).toBe('/accounts'); - expect(config.overrides?.contact?.enabled).toBe(false); - expect(config.overrides?.task?.operations?.delete).toBe(false); + })).toThrow(/`routes\.overrides` was removed.*Delete the key.*enable\.apiEnabled.*crud\.dataPrefix/s); }); }); @@ -504,16 +476,24 @@ describe('RestServerConfigSchema', () => { batch: { maxBatchSize: 200, }, - routes: { - excludeObjects: ['system_log'], - }, + routes: {}, }); expect(config.api?.version).toBe('v1'); expect(config.crud?.dataPrefix).toBe('/data'); expect(config.metadata?.prefix).toBe('/meta'); expect(config.batch?.maxBatchSize).toBe(200); - expect(config.routes?.excludeObjects).toContain('system_log'); + expect(config.routes).toEqual({}); + }); + + it('[#14691] a retired sub-object key is refused THROUGH the whole config, with its own prescription', () => { + // The tombstones live on the sub-schemas; this pins that composing them + // under `RestServerConfigSchema` changes nothing — the same authored key is + // refused at the whole-config parse with the same string. + expect(() => RestServerConfigSchema.parse({ routes: { excludeObjects: ['system_log'] } })) + .toThrow(/`routes\.excludeObjects` was removed.*Delete the key/s); + expect(() => RestServerConfigSchema.parse({ crud: { patterns: {} } })) + .toThrow(/`crud\.patterns` was removed.*Delete the key/s); }); }); @@ -699,17 +679,14 @@ describe('Integration Tests', () => { delete: true, list: true, }, - objectParamStyle: 'path', }, metadata: { prefix: '/meta', enableCache: true, - cacheTtl: 3600, endpoints: { types: true, items: true, item: true, - schema: true, }, }, batch: { @@ -719,20 +696,15 @@ describe('Integration Tests', () => { createMany: true, updateMany: true, deleteMany: true, - upsertMany: true, }, - defaultAtomic: true, - }, - routes: { - excludeObjects: ['system_log'], - nameTransform: 'none', }, + routes: {}, }; const result = RestServerConfigSchema.parse(serverConfig); expect(result.api?.version).toBe('v1'); expect(result.crud?.dataPrefix).toBe('/data'); - expect(result.metadata?.cacheTtl).toBe(3600); + expect(result.metadata?.enableCache).toBe(true); expect(result.batch?.maxBatchSize).toBe(200); }); }); @@ -774,7 +746,7 @@ describe('[#4579] `RestServerConfig.openApi31` retirement', () => { crud: { dataPrefix: '/data' }, metadata: { prefix: '/meta' }, batch: { maxBatchSize: 200 }, - routes: { excludeObjects: ['system_log'] }, + routes: {}, }); expect(parsed.api?.version).toBe('v1'); expect(parsed.crud?.dataPrefix).toBe('/data'); diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index a2e0306915..55530a2524 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -197,42 +197,19 @@ export const CrudOperation = z.enum([ export type CrudOperation = z.input; -/** - * CRUD Endpoint Pattern Schema - * Defines the URL pattern for CRUD operations - * - * @example - * { - * "create": { "method": "POST", "path": "/data/{object}" }, - * "read": { "method": "GET", "path": "/data/{object}/:id" }, - * "update": { "method": "PATCH", "path": "/data/{object}/:id" }, - * "delete": { "method": "DELETE", "path": "/data/{object}/:id" }, - * "list": { "method": "GET", "path": "/data/{object}" } - * } - */ -export const CrudEndpointPatternSchema = lazySchema(() => z.object({ - /** - * HTTP method - */ - method: HttpMethod.describe('HTTP method'), - - /** - * URL path pattern (relative to API base) - */ - path: z.string().describe('URL path pattern'), - - /** - * Operation summary for documentation - */ - summary: z.string().optional().describe('Operation summary'), - - /** - * Operation description - */ - description: z.string().optional().describe('Operation description'), -})); - -export type CrudEndpointPattern = z.input; +// `CrudEndpointPatternSchema` — REMOVED (#14691) +// +// The per-operation `{ method, path, summary, description }` pattern shape was +// the value type of `crud.patterns`, retired below under ADR-0049 +// enforce-or-remove (the #14369 liveness census: every CRUD route is mounted +// from fixed method/path pairs in `packages/rest`'s `registerCrudEndpoints`, +// so a custom pattern was validated and never read). With its carrier key +// tombstoned the def had no consumer left, and an exported schema nothing +// reads is read as a capability by whoever finds it (#3950), so it leaves with +// the key — declared in RETIRED_DEFS_BY_MAJOR[18] (`api/CrudEndpointPattern`). +// `CrudOperation` above stays: `GeneratedEndpointSchema.operation` still reads +// it. An endpoint on a custom path or method is a declarative `api` endpoint +// (`endpoint.zod.ts`, `type: 'object_operation'`). /** * CRUD Endpoints Configuration Schema @@ -251,10 +228,23 @@ export const CrudEndpointsConfigSchema = lazySchema(() => z.object({ }).optional().describe('Enable/disable operations'), /** - * Custom endpoint patterns (override defaults) - */ - patterns: z.record(CrudOperation, CrudEndpointPatternSchema.optional()).optional() - .describe('Custom URL patterns for operations'), + * [REMOVED in #14691] Per-operation custom URL patterns. Tombstoned rather + * than deleted: this schema is not `.strict()`, so a plain deletion would + * silently strip the key and an author would keep a config that "customizes" + * routes the server mounts from fixed pairs (ADR-0104, #3733). The mounted + * CRUD paths are the contract the client SDK, the discovery document and the + * served /openapi.json all describe; a configurable per-operation method or + * path would make every one of them lie (Route & surface ownership §4). + */ + patterns: retiredKey( + '`crud.patterns` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: every CRUD route is mounted from the fixed method/path pairs in the REST ' + + "server's `registerCrudEndpoints`, so a custom pattern was validated and ignored. Delete the key. " + + 'The mounted CRUD paths are the contract the client SDK, the discovery document and the served ' + + '/openapi.json all describe, and `crud.dataPrefix` is the one live knob that moves them; an ' + + "endpoint on a custom path or method is a declarative `api` endpoint (`type: 'object_operation'`, " + + '`ApiEndpoint` in `@objectstack/spec/api`), which is matched, executed and documented.', + ), /** * Path prefix for data operations @@ -262,10 +252,17 @@ export const CrudEndpointsConfigSchema = lazySchema(() => z.object({ dataPrefix: z.string().default('/data').describe('URL prefix for data endpoints'), /** - * Object name parameter style + * [REMOVED in #14691] The object-name parameter style. Every CRUD route takes + * the object name as a PATH segment; `'query'` was validated against the enum + * and mounted exactly what `'path'` mounts. Tombstoned, not deleted — the + * schema is not `.strict()` (see `patterns` above). */ - objectParamStyle: z.enum(['path', 'query']).default('path') - .describe('How object name is passed (path param or query param)'), + objectParamStyle: retiredKey( + '`crud.objectParamStyle` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: every CRUD route takes the object name as a path segment, so ' + + "`'query'` was validated and mounted exactly what `'path'` mounts. Delete the key; the object " + + 'name is always a path segment.', + ), })); export type CrudEndpointsConfig = z.input; @@ -303,9 +300,20 @@ export const MetadataEndpointsConfigSchema = lazySchema(() => z.object({ enableCache: z.boolean().default(true).describe('Enable HTTP cache headers (ETag, Last-Modified)'), /** - * Cache TTL in seconds + * [REMOVED in #14691] The metadata cache TTL. `enableCache` selects the + * protocol's `getMetaItemCached` read path, which takes no TTL, and no + * `Cache-Control` / `ETag` / `Last-Modified` header was ever built from this + * value — `cacheTtl: 60` changed no header and no cache lifetime (and, having + * no lower bound, accepted `-1`). Tombstoned, not deleted — the schema is not + * `.strict()`. */ - cacheTtl: z.number().int().default(3600).describe('Cache TTL in seconds'), + cacheTtl: retiredKey( + '`metadata.cacheTtl` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + "nothing ever read it: `metadata.enableCache` selects the protocol's `getMetaItemCached` read " + + 'path, which takes no TTL, and no Cache-Control / ETag header was ever built from this value. ' + + 'Delete the key; `metadata.enableCache` is the live switch, and a declarative `api` ' + + "endpoint's `cacheTtl` is the key that does reach the wire.", + ), /** * [ADR-0106 D8] Per-caller field-level masking of the OBJECT SCHEMAS this @@ -337,7 +345,17 @@ export const MetadataEndpointsConfigSchema = lazySchema(() => z.object({ types: z.boolean().default(true).describe('GET /meta - List all metadata types'), items: z.boolean().default(true).describe('GET /meta/:type - List items of type'), item: z.boolean().default(true).describe('GET /meta/:type/:name - Get specific item'), - schema: z.boolean().default(true).describe('GET /meta/:type/:name/schema - Get JSON schema'), + /** + * [REMOVED in #14691] Gated a route that does not exist: the REST server + * mounts no `GET /meta/:type/:name/schema`, so `false` removed nothing and + * `true` added nothing. Its three siblings each gate a real mount. + */ + schema: retiredKey( + '`metadata.endpoints.schema` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — it gated a route that does not exist: the REST server mounts no ' + + '`GET /meta/:type/:name/schema`, so `false` removed nothing and `true` added nothing. Delete ' + + 'the key; `endpoints.types` / `items` / `item` are the switches that gate real mounts.', + ), }).optional().describe('Enable/disable specific endpoints'), })); @@ -382,14 +400,39 @@ export const BatchEndpointsConfigSchema = lazySchema(() => z.object({ createMany: z.boolean().default(true).describe('Enable POST /data/:object/createMany'), updateMany: z.boolean().default(true).describe('Enable POST /data/:object/updateMany'), deleteMany: z.boolean().default(true).describe('Enable POST /data/:object/deleteMany'), - upsertMany: z.boolean().default(true).describe('Enable POST /data/:object/upsertMany'), + /** + * [REMOVED in #14691] Gated a route that was never built: there is no + * `POST /data/:object/upsertMany` and no protocol member behind it (the + * protocol carries `createManyData` / `updateManyData` / `deleteManyData` + * and no upsert counterpart). Upsert is an operation TYPE of the generic + * batch endpoint (`BatchOperationType` `'upsert'`, `batch.zod.ts`). + */ + upsertMany: retiredKey( + '`batch.operations.upsertMany` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — it gated a route that was never built: there is no ' + + '`POST /data/:object/upsertMany` and no protocol member behind it, so `false` disabled ' + + 'nothing. Delete the key. Upsert is an operation type of the generic `POST /data/:object/batch` ' + + "endpoint (`BatchOperationType` `'upsert'`, keyed by `externalId`), gated by " + + '`batch.enableBatchEndpoint`.', + ), }).optional().describe('Enable/disable specific batch operations'), - + /** - * Transaction mode default - */ - defaultAtomic: z.boolean().default(true) - .describe('Default atomic/transaction mode for batch operations'), + * [REMOVED in #14691] A server-side default for batch atomicity. No batch + * handler ever consulted it: atomicity is decided per request by + * `options.atomic` in the batch body (`BatchOptionsSchema`, ADR-0119 D4 — + * opt-in, default `false`, aligned to what every caller already gets). A + * deployment default that flipped it silently would change the failure + * semantics of callers who send nothing, the move ADR-0119 D4 refused. + * Tombstoned, not deleted — the schema is not `.strict()`. + */ + defaultAtomic: retiredKey( + '`batch.defaultAtomic` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'no batch handler ever consulted it: atomicity is decided per request by `options.atomic` in the ' + + 'batch body (`BatchOptions`, ADR-0119 D4, opt-in), and a server-side default that flipped it ' + + 'silently would change the failure semantics of callers who send nothing. Delete the key; a ' + + 'caller that needs all-or-nothing sends `options: { atomic: true }`.', + ), })); export type BatchEndpointsConfig = z.input; @@ -402,36 +445,52 @@ export type BatchEndpointsConfigParsed = z.infer z.object({ - /** - * Objects to include (if empty, include all) - */ - includeObjects: z.array(z.string()).optional() - .describe('Specific objects to generate routes for (empty = all)'), - - /** - * Objects to exclude - */ - excludeObjects: z.array(z.string()).optional() - .describe('Objects to exclude from route generation'), - - /** - * Object name transformations - */ - nameTransform: z.enum(['none', 'plural', 'kebab-case', 'camelCase']).default('none') - .describe('Transform object names in URLs'), - - /** - * Custom route overrides per object - */ - overrides: z.record(z.string(), z.object({ - enabled: z.boolean().optional().describe('Enable/disable routes for this object'), - basePath: z.string().optional().describe('Custom base path'), - operations: z.record(CrudOperation, z.boolean()).optional() - .describe('Enable/disable specific operations'), - })).optional().describe('Per-object route customization'), + includeObjects: retiredKey( + '`routes.includeObjects` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: route registration iterates every registered object, so an include list ' + + 'still mounted all of them. Delete the key. Per-object API exposure is declared on the object ' + + 'and enforced by the REST data surface: `enable.apiEnabled: false` hides the object (404) and ' + + '`enable.apiMethods` whitelists its operations (405 for the rest).', + ), + excludeObjects: retiredKey( + '`routes.excludeObjects` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: route registration iterates every registered object, so an excluded ' + + 'object was still mounted. Delete the key. Per-object API exposure is declared on the object ' + + 'and enforced by the REST data surface: `enable.apiEnabled: false` hides the object (404) and ' + + '`enable.apiMethods` whitelists its operations (405 for the rest).', + ), + nameTransform: retiredKey( + '`routes.nameTransform` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read it: the enum was validated and every value mounted exactly what ' + + "`'none'` mounts. Delete the key; the object `name` is the canonical id on every surface, the " + + 'REST path segment included, so there is no URL transform to configure.', + ), + overrides: retiredKey( + '`routes.overrides` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'nothing ever read the per-object record: `enabled`, `basePath` and `operations` were validated ' + + 'and turned nothing on or off. Delete the key. Per-object exposure lives on the object ' + + '(`enable.apiEnabled` hides it, `enable.apiMethods` whitelists its operations — both enforced by ' + + 'the REST data surface); the data base path is deployment-wide (`crud.dataPrefix`); and an ' + + 'endpoint on a custom path is a declarative `api` endpoint.', + ), })); export type RouteGenerationConfig = z.input; @@ -487,11 +546,12 @@ export type RouteGenerationConfigParsed = z.infer z.object({ /** diff --git a/packages/spec/src/migrations/entries/retired-defs/18.api__CrudEndpointPattern.ts b/packages/spec/src/migrations/entries/retired-defs/18.api__CrudEndpointPattern.ts new file mode 100644 index 0000000000..2f17c6d8c6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.api__CrudEndpointPattern.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — `api/CrudEndpointPattern` (the `{ method, path, summary, description }` +// value shape of `crud.patterns`) leaves with its carrier key: its ONLY consumer +// was `CrudEndpointsConfigSchema.patterns`, tombstoned in the same change under +// ADR-0049 enforce-or-remove, and an exported value schema with no consumer +// reads as a capability (#3950). `api/CrudOperation` stays — +// `GeneratedEndpointSchema.operation` still reads it. See +// `retired-keys/18.api__CrudEndpointsConfig__patterns.ts` for the retirement record. +export const entry = 'api/CrudEndpointPattern'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__defaultAtomic.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__defaultAtomic.ts new file mode 100644 index 0000000000..6db59aaa24 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__defaultAtomic.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `batch.defaultAtomic`: no batch handler consulted it. Atomicity is a +// per-request option (`BatchOptionsSchema.atomic`, ADR-0119 D4 — opt-in, +// default `false`, deliberately aligned to what every caller already got); a +// server-side default that flipped it silently would change the failure +// semantics of callers who send nothing, the exact move ADR-0119 D4 refused, +// so this is a remove, not an enforce. +export const entry = 'api/BatchEndpointsConfig:defaultAtomic'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__operations.upsertMany.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__operations.upsertMany.ts new file mode 100644 index 0000000000..1eca43feaf --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__BatchEndpointsConfig__operations.upsertMany.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `batch.operations.upsertMany`: gated a route that was never built — no +// `POST /data/:object/upsertMany`, no protocol member behind it (the protocol +// carries createManyData / updateManyData / deleteManyData only). Upsert is an +// operation TYPE of the generic batch endpoint (`BatchOperationType` 'upsert'), +// gated by `enableBatchEndpoint`. Nested key of an inline block — no +// `authorable-surface/` line of its own. +export const entry = 'api/BatchEndpointsConfig:operations.upsertMany'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__objectParamStyle.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__objectParamStyle.ts new file mode 100644 index 0000000000..ea4b133be5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__objectParamStyle.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `crud.objectParamStyle`: every CRUD route takes the object name as a PATH +// segment; `'query'` was validated against the enum and mounted exactly what +// `'path'` mounts. +export const entry = 'api/CrudEndpointsConfig:objectParamStyle'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__patterns.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__patterns.ts new file mode 100644 index 0000000000..6baaa110e6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__CrudEndpointsConfig__patterns.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `crud.patterns`: every CRUD route is mounted from the fixed method/path +// pairs in `registerCrudEndpoints`; a custom pattern was validated and never +// read. Not enforced, because the mounted paths ARE the contract the client +// SDK, the discovery document and the served /openapi.json describe — a +// per-operation method/path knob would make every one of them lie. The live +// door for a custom path or method is a declarative `api` endpoint. Its value +// def `api/CrudEndpointPattern` leaves with it (RETIRED_DEFS_BY_MAJOR[18]); its +// four ledger child rows collapse into the one `patterns` row. Closes #14365's +// question about the record's input type — there is no record left to reshape. +export const entry = 'api/CrudEndpointsConfig:patterns'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__cacheTtl.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__cacheTtl.ts new file mode 100644 index 0000000000..81547e1093 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__cacheTtl.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `metadata.cacheTtl`: `enableCache` selects the protocol's `getMetaItemCached` +// read path, which takes no TTL, and no Cache-Control / ETag / Last-Modified +// header was ever built from the value. Its negative-bound defect (no lower +// bound, `-1` accepted — #11984 pinned it as accepted because that was the +// contract) dies with the key. +export const entry = 'api/MetadataEndpointsConfig:cacheTtl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__endpoints.schema.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__endpoints.schema.ts new file mode 100644 index 0000000000..9003c189dd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__MetadataEndpointsConfig__endpoints.schema.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `metadata.endpoints.schema`: gated a route that does not exist — the REST +// server mounts no `GET /meta/:type/:name/schema` — so `false` removed nothing +// and `true` added nothing, while its three siblings each gate a real mount. +// A nested key of an inline block, so it has no line of its own in +// `authorable-surface/` (the `kernel/Manifest:contributes.routes` shape). +export const entry = 'api/MetadataEndpointsConfig:endpoints.schema'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__excludeObjects.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__excludeObjects.ts new file mode 100644 index 0000000000..b0094b22a1 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__excludeObjects.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `routes.excludeObjects`: same shape as `includeObjects` — an excluded +// object was still mounted. The one customer-visible member of the family: +// `RestServerConfigSchema`'s own `@example` advertised +// `routes: { excludeObjects: ['system_log'] }`; corrected in the same change. +export const entry = 'api/RouteGenerationConfig:excludeObjects'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__includeObjects.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__includeObjects.ts new file mode 100644 index 0000000000..eaeb2b7c97 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__includeObjects.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `routes.includeObjects`: route registration iterates every registered +// object with no include filter. Per-object API exposure is declared ON the +// object and enforced by the REST data surface (`enable.apiEnabled` → 404, +// `enable.apiMethods` → 405), so the capability exists where the contract +// belongs and this key was a second, unread dialect of it. +export const entry = 'api/RouteGenerationConfig:includeObjects'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__nameTransform.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__nameTransform.ts new file mode 100644 index 0000000000..ec65d5f462 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__nameTransform.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `routes.nameTransform`: the enum was validated (since #11984) and every value +// mounted exactly what `'none'` mounts. Not enforced, because the object +// `name` is the canonical id on every surface including the REST path segment +// (Prime Directive #6) — a URL transform would contradict the one-name rule. +export const entry = 'api/RouteGenerationConfig:nameTransform'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__overrides.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__overrides.ts new file mode 100644 index 0000000000..e5465f7020 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__RouteGenerationConfig__overrides.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, +// executing the #14369 liveness census (15 `dead` rows across the `crud` / +// `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` +// outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 +// clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a +// `RestServerConfig` at all, #14796). All four sub-schemas are non-strict +// `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion +// would strip the key silently), the ledger rows stay `dead` with a REMOVED +// note, and there is no D2 conversion: a `RestServerConfig` is plugin TS +// configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), +// never a stack collection member or a `sys_metadata` row — the +// `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry +// `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// `routes.overrides`: the per-object record (`enabled` / `basePath` / +// `operations`) was normalized and never read. `enabled` and `operations` +// duplicate the object's own enforced exposure keys (`enable.apiEnabled` / +// `enable.apiMethods`); `basePath` per object would contradict the one +// deployment-wide data base (`crud.dataPrefix`) the discovery document +// advertises. Its three ledger child rows collapse into the one `overrides` +// row. Closes #14365's question about `overrides.*.operations` — no record left. +export const entry = 'api/RouteGenerationConfig:overrides'; diff --git a/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts new file mode 100644 index 0000000000..ad623108ec --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.rest-server-config-dead-keys-retired.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'rest-server-config-dead-keys-retired', + surface: 'restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / ' + + 'restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / ' + + 'restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / ' + + 'restServer.routes.nameTransform / restServer.routes.overrides', + replacement: + '(removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on ' + + 'the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and ' + + '`enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, ' + + 'deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / ' + + '`description` / `cacheTtl` — is a declarative `api` endpoint (`type: \'object_operation\'`). ' + + 'Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of ' + + 'the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)', + reason: + 'The #14369 liveness census enrolled the four `RestServerConfig` sub-objects and found 15 of their ' + + '32 rows `dead`: parsed, defaulted and normalized into the REST server\'s config by `normalizeConfig` ' + + '(#11984) and never read back. `crud.patterns` and `routes.overrides` described route customization ' + + 'the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` ' + + '/ `operations` duplicated the object\'s own enforced exposure keys; `nameTransform` and ' + + '`objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and ' + + '`batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache ' + + 'and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 ' + + 'D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each ' + + 'promised capability either already exists at its proper seat (the object, the declarative endpoint, ' + + 'the batch request) or would contradict a fixed contract (the client SDK, the discovery document and ' + + 'the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path ' + + 'segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone ' + + 'and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of ' + + '`crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, ' + + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent, #4579). Cloud ' + + 'sweep #14796 @9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`. #14691.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries ' + + 'any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` ' + + 'with the retirement prescription (naming the sub-object, the key and the declaring schema) instead of ' + + 'being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). Every LIVE key of the ' + + 'four sub-objects parses byte-identically to before: `crud.operations.*`, `crud.dataPrefix`, ' + + '`metadata.prefix` / `enableCache` / `maskObjectFields` / `endpoints.types|items|item`, ' + + '`batch.maxBatchSize` / `enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` keep ' + + 'their defaults and their mounts. The mounted REST surface is byte-identical before and after — none ' + + 'of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from ' + + '`@objectstack/spec/api` (TS2305 after upgrade).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index cdbfd50a74..39740497ef 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7522,6 +7522,50 @@ const step18: MigrationStep = { + 'route registrations (getDefaultRouteRegistrations) never carried the ' + 'key and still parse.', }, + { + id: 'rest-server-config-dead-keys-retired', + surface: 'restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / ' + + 'restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / ' + + 'restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / ' + + 'restServer.routes.nameTransform / restServer.routes.overrides', + replacement: + '(removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on ' + + 'the object: `enable.apiEnabled: false` hides it from the REST data surface (404) and ' + + '`enable.apiMethods` whitelists its operations (405). The data base path is `crud.dataPrefix`, ' + + 'deployment-wide. An endpoint on a custom path or method — or one that needs its own `summary` / ' + + '`description` / `cacheTtl` — is a declarative `api` endpoint (`type: \'object_operation\'`). ' + + 'Batch atomicity is the per-request `options.atomic` (ADR-0119 D4); upsert is an operation type of ' + + 'the generic `POST /data/:object/batch` endpoint, gated by `batch.enableBatchEndpoint`.)', + reason: + 'The #14369 liveness census enrolled the four `RestServerConfig` sub-objects and found 15 of their ' + + '32 rows `dead`: parsed, defaulted and normalized into the REST server\'s config by `normalizeConfig` ' + + '(#11984) and never read back. `crud.patterns` and `routes.overrides` described route customization ' + + 'the server mounts from fixed pairs; `routes.includeObjects` / `excludeObjects` and `overrides.enabled` ' + + '/ `operations` duplicated the object\'s own enforced exposure keys; `nameTransform` and ' + + '`objectParamStyle` were enums validated and then ignored; `metadata.endpoints.schema` and ' + + '`batch.operations.upsertMany` gated routes that were never built; `metadata.cacheTtl` fed no cache ' + + 'and no header; `batch.defaultAtomic` would have silently overridden a per-request contract ADR-0119 ' + + 'D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each ' + + 'promised capability either already exists at its proper seat (the object, the declarative endpoint, ' + + 'the batch request) or would contradict a fixed contract (the client SDK, the discovery document and ' + + 'the served /openapi.json all describe the mounted CRUD paths; the object `name` is the REST path ' + + 'segment). All four schemas are non-strict `z.object()`s, so each key is a `retiredKey()` tombstone ' + + 'and its ledger row stays `dead` with a REMOVED note; `api/CrudEndpointPattern`, the value def of ' + + '`crud.patterns`, leaves with it. No D2 conversion: a `RestServerConfig` is plugin TS configuration, ' + + 'never a stack collection member or a `sys_metadata` row (the `openApi31` precedent, #4579). Cloud ' + + 'sweep #14796 @9b6abe0f2fd5: zero hits, structural — cloud never authors a `RestServerConfig`. #14691.', + acceptanceCriteria: + 'No `RestServerConfig` value passed to the REST plugin (or `plugin-hono-server` `restConfig`) carries ' + + 'any of the ten keys — a config that does now fails `new RestServer(...)` / `createRestApiPlugin().start()` ' + + 'with the retirement prescription (naming the sub-object, the key and the declaring schema) instead of ' + + 'being accepted and ignored; `tsc` refuses the key at the authoring site (`never`). Every LIVE key of the ' + + 'four sub-objects parses byte-identically to before: `crud.operations.*`, `crud.dataPrefix`, ' + + '`metadata.prefix` / `enableCache` / `maskObjectFields` / `endpoints.types|items|item`, ' + + '`batch.maxBatchSize` / `enableBatchEndpoint` / `operations.createMany|updateMany|deleteMany` keep ' + + 'their defaults and their mounts. The mounted REST surface is byte-identical before and after — none ' + + 'of the ten keys ever reached it. No code imports `CrudEndpointPattern(Schema)` from ' + + '`@objectstack/spec/api` (TS2305 after upgrade).', + }, { id: 'scim-provider-object-retired', surface: @@ -8357,6 +8401,142 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `batch.defaultAtomic`: no batch handler consulted it. Atomicity is a + // per-request option (`BatchOptionsSchema.atomic`, ADR-0119 D4 — opt-in, + // default `false`, deliberately aligned to what every caller already got); a + // server-side default that flipped it silently would change the failure + // semantics of callers who send nothing, the exact move ADR-0119 D4 refused, + // so this is a remove, not an enforce. + 'api/BatchEndpointsConfig:defaultAtomic', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `batch.operations.upsertMany`: gated a route that was never built — no + // `POST /data/:object/upsertMany`, no protocol member behind it (the protocol + // carries createManyData / updateManyData / deleteManyData only). Upsert is an + // operation TYPE of the generic batch endpoint (`BatchOperationType` 'upsert'), + // gated by `enableBatchEndpoint`. Nested key of an inline block — no + // `authorable-surface/` line of its own. + 'api/BatchEndpointsConfig:operations.upsertMany', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `crud.objectParamStyle`: every CRUD route takes the object name as a PATH + // segment; `'query'` was validated against the enum and mounted exactly what + // `'path'` mounts. + 'api/CrudEndpointsConfig:objectParamStyle', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `crud.patterns`: every CRUD route is mounted from the fixed method/path + // pairs in `registerCrudEndpoints`; a custom pattern was validated and never + // read. Not enforced, because the mounted paths ARE the contract the client + // SDK, the discovery document and the served /openapi.json describe — a + // per-operation method/path knob would make every one of them lie. The live + // door for a custom path or method is a declarative `api` endpoint. Its value + // def `api/CrudEndpointPattern` leaves with it (RETIRED_DEFS_BY_MAJOR[18]); its + // four ledger child rows collapse into the one `patterns` row. Closes #14365's + // question about the record's input type — there is no record left to reshape. + 'api/CrudEndpointsConfig:patterns', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `metadata.cacheTtl`: `enableCache` selects the protocol's `getMetaItemCached` + // read path, which takes no TTL, and no Cache-Control / ETag / Last-Modified + // header was ever built from the value. Its negative-bound defect (no lower + // bound, `-1` accepted — #11984 pinned it as accepted because that was the + // contract) dies with the key. + 'api/MetadataEndpointsConfig:cacheTtl', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `metadata.endpoints.schema`: gated a route that does not exist — the REST + // server mounts no `GET /meta/:type/:name/schema` — so `false` removed nothing + // and `true` added nothing, while its three siblings each gate a real mount. + // 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', // #13823 — ADR-0049 enforce-or-remove on `RestApiEndpointSchema.handlerStatus` // (maintainer ruling 2026-09-01, director decision batch #27, verbatim // 「同意」: remove). The key (`implemented` / `stub` / `planned`) was declared @@ -8393,6 +8573,94 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #11846 / #12428 grading). 'api/RestApiEndpoint:handlerStatus', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `routes.excludeObjects`: same shape as `includeObjects` — an excluded + // object was still mounted. The one customer-visible member of the family: + // `RestServerConfigSchema`'s own `@example` advertised + // `routes: { excludeObjects: ['system_log'] }`; corrected in the same change. + 'api/RouteGenerationConfig:excludeObjects', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `routes.includeObjects`: route registration iterates every registered + // object with no include filter. Per-object API exposure is declared ON the + // object and enforced by the REST data surface (`enable.apiEnabled` → 404, + // `enable.apiMethods` → 405), so the capability exists where the contract + // belongs and this key was a second, unread dialect of it. + 'api/RouteGenerationConfig:includeObjects', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `routes.nameTransform`: the enum was validated (since #11984) and every value + // mounted exactly what `'none'` mounts. Not enforced, because the object + // `name` is the canonical id on every surface including the REST path segment + // (Prime Directive #6) — a URL transform would contradict the one-name rule. + 'api/RouteGenerationConfig:nameTransform', + // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, + // executing the #14369 liveness census (15 `dead` rows across the `crud` / + // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` + // outside `normalizeConfig` and the normalized-config type; objectui @d4c6a86 + // clean; cloud @9b6abe0f2fd5 clean STRUCTURALLY — cloud never authors a + // `RestServerConfig` at all, #14796). All four sub-schemas are non-strict + // `z.object()`s, so the route is a `retiredKey()` tombstone (a bare deletion + // would strip the key silently), the ledger rows stay `dead` with a REMOVED + // note, and there is no D2 conversion: a `RestServerConfig` is plugin TS + // configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), + // never a stack collection member or a `sys_metadata` row — the + // `api/RestServerConfig:openApi31` (#4579) precedent. D3 semantic entry + // `rest-server-config-dead-keys-retired`. Registered under 18, not 17: the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // `routes.overrides`: the per-object record (`enabled` / `basePath` / + // `operations`) was normalized and never read. `enabled` and `operations` + // duplicate the object's own enforced exposure keys (`enable.apiEnabled` / + // `enable.apiMethods`); `basePath` per object would contradict the one + // deployment-wide data base (`crud.dataPrefix`) the discovery document + // advertises. Its three ledger child rows collapse into the one `overrides` + // row. Closes #14365's question about `overrides.*.operations` — no record left. + 'api/RouteGenerationConfig:overrides', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a @@ -9708,6 +9976,14 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #14691 — `api/CrudEndpointPattern` (the `{ method, path, summary, description }` + // value shape of `crud.patterns`) leaves with its carrier key: its ONLY consumer + // was `CrudEndpointsConfigSchema.patterns`, tombstoned in the same change under + // ADR-0049 enforce-or-remove, and an exported value schema with no consumer + // reads as a capability (#3950). `api/CrudOperation` stays — + // `GeneratedEndpointSchema.operation` still reads it. See + // `retired-keys/18.api__CrudEndpointsConfig__patterns.ts` for the retirement record. + 'api/CrudEndpointPattern', // #13823 — `api/HandlerStatus` (the `implemented` / `stub` / `planned` enum) // left with its two carriers: `RestApiEndpoint.handlerStatus` is tombstoned // in this same major (`RETIRED_KEYS_BY_MAJOR[18]`) and diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 8ec67c341b..dc84566ec5 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 832 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 831 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -546,7 +546,6 @@ export type Iso186 = Assert, z.inf // api/rest-server.zod.ts export type Iso187 = Assert, z.infer< typeof M32.CrudOperation > >>; -export type Iso188 = Assert, z.infer< typeof M32.CrudEndpointPatternSchema > >>; export type Iso189 = Assert, z.infer< typeof M32.GeneratedEndpointSchema > >>; export type Iso190 = Assert, z.infer< typeof M32.EndpointRegistrySchema > >>; @@ -1683,7 +1682,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 832 isomorphic pins', () => { + it('still declares all 831 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2088,9 +2087,17 @@ describe('ADR-0122 type-alias convention', () => { // `api/plugin-rest-api.handler-status-retirement.test.ts` asserts the // absence of all six retired names on every public entry. -3 removed; the // Iso numbers stay vacant (ids are claims about pins, not positions). + // + // 832 -> 831 is #14691's ADR-0049 retirement of `crud.patterns` on + // `CrudEndpointsConfigSchema` (api/rest-server.zod.ts): its value def + // `CrudEndpointPatternSchema` had no other consumer and left the module + // whole (RETIRED_DEFS_BY_MAJOR[18] `api/CrudEndpointPattern`), so its pin + // `Iso188` left with it. `CrudOperation` (`Iso187`) stays — the enum is + // still read by `GeneratedEndpointSchema.operation`. -1 removed; the Iso + // number stays vacant. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert