diff --git a/.changeset/api-duration-keys-unit-in-key-name.md b/.changeset/api-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..cbea5238b1 --- /dev/null +++ b/.changeset/api-duration-keys-unit-in-key-name.md @@ -0,0 +1,95 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": patch +--- + +feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + +**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. +Shipped as `minor` under the repo's launch-window convention for breaking +changes; the hand-migration prescriptions are registered under protocol major +18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions; this card clears the `api/` directory against it. Measured with the +gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree +count falls **48 → 36**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | +| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | +| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | +| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | +| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | +| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | +| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | +| `RouteDefinition.timeout` | `timeoutMs` | milliseconds | +| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | +| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | +| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | +| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + +**Every value is unchanged** — only key names move. Every old spelling is a +`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type +`never`) and fails the parse with the rename prescription rather than a bare +unrecognized-key error. + +## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + +Ruling B put this key explicitly in scope with its own BREAKING note: the +runtime-emitted measurements are read by humans and agents even though nobody +authors them. A consumer meets two retry-after values on one 429 — this +ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` +header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. +Spelled identically they read as one value in two places. + +**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its +name is fixed outside this repo and nothing here touches it. Do not "fix" the +header to match the envelope, and do not read a surviving `retry-after` in +transport code as leftover work. + +## Dispositions — one D2 conversion, five semantic entries + +Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only +one of the twelve that gets an ADR-0087 D2 conversion** +(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack +collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered +metadata kind stored as a row, so the conversion chain has a seam that sees it. +`os migrate meta --from 17` lists the mechanical edits. + +The other eleven are wire payloads and construction arguments — a device-flow +response body, an error envelope, REST-plugin route registration, a batch-loader +config, a router registration, WebSocket client/server configuration. None is +ever a stack collection member or a `sys_metadata` row, so no conversion seam +runs on them and each carries a **semantic** entry instead: this is the +disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these +very shapes, and what ruling B prescribes for a runtime-emitted key. + +## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + +Attributed to RFC 8628 by the campaign card; the attribution fails against the +schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as +a set — `code` is not `device_code`, `verificationUrl` is not +`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a +different type, an ISO-8601 instant where the RFC carries a relative lifetime). +A schema that already renames every RFC field it carries into house style cannot +claim the standard fixes the one name it left bare. Renamed rather than marked +deliberately: a wrongly marked key is exempted permanently and silently, while a +wrongly renamed one is visible. + +## Readers moved in the same PR, at the same magnitude + +`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads +`endpoint.cacheTtlSeconds`), the publish gate's issue path +(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase +example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` +tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is +retargeted onto the live key — an alias must point at a key the schema really +accepts, and `cacheTtl` now accepts nothing. diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index 4d59c14167..e4ae65983a 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -72,7 +72,7 @@ export default defineStack({ // `type: 'flow'`, as `acme_lead_intake` below shows. objectParams: { object: 'acme_lead', operation: 'find' }, // Omitting `authRequired` is the safe spelling — it defaults to `true`. - cacheTtl: 30, + cacheTtlSeconds: 30, }, { name: 'acme_lead_intake', @@ -95,7 +95,7 @@ refused is refused before you deploy. A declared endpoint is **not a registered route**. It is matched in the dispatcher's unmatched-request seam, which is what makes it structurally impossible for your declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `authRequired` → -`cacheTtl`) → delegation to an existing pipeline: +`cacheTtlSeconds`) → delegation to an existing pipeline: | `type` | Delegates to | Request shape | |:---|:---|:---| @@ -188,12 +188,13 @@ mapping refusals out of this family rather than nesting them. - An **armed** budget must be usable: `maxRequests` above `0` and `windowMs` above `0`. A zero-or-negative allowance rejects every request including your own health checks, and the runtime fails closed on it rather than serving unmetered. -- `cacheTtl` is seconds and cannot be negative. -- `cacheTtl` is **GET-only**. It becomes a `Cache-Control` header on a successful answer, +- `cacheTtlSeconds` cannot be negative — the key carries its unit, so there is no + second place for it to disagree with. +- `cacheTtlSeconds` is **GET-only**. It becomes a `Cache-Control` header on a successful answer, and a non-GET answer is not a cacheable representation, so on any other method the key could never take effect and is refused instead of ignored. -`cacheTtl: 0` is not the same as omitting the key: `0` emits `Cache-Control: no-store` +`cacheTtlSeconds: 0` is not the same as omitting the key: `0` emits `Cache-Control: no-store` (saying "never store this"), while omitting it sends no caching header at all. A positive ttl emits `private, max-age=` — `private` is a security rule and not a tuning choice, because any answer can be RLS-trimmed for its caller and a shared cache must never hand @@ -243,7 +244,7 @@ export const partnerWebhook: ApiEndpoint = { What that budget buys you, and what it does not: -- **Metering runs before the auth gate** (`rateLimit` → `authRequired` → `cacheTtl`). That +- **Metering runs before the auth gate** (`rateLimit` → `authRequired` → `cacheTtlSeconds`). That order is deliberate: the traffic that most needs a budget — credential stuffing, scraping — is exactly the traffic that ends in a `401`, so a denied request still spends a token. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index dc208c30e3..c272559f2c 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -44,7 +44,7 @@ ObjectStack uses a structured error system with **9 error categories** and **50 | `no_retry` | Do not retry the request | Validation errors, permission denied | | `retry_immediate` | Retry immediately | Transient network errors | | `retry_backoff` | Retry with exponential backoff | Rate limits, server errors | -| `retry_after` | Wait the specified `retryAfter` seconds | Rate limit with explicit cooldown | +| `retry_after` | Wait the `retryAfterSeconds` the envelope carries | Rate limit with explicit cooldown | --- @@ -387,12 +387,12 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping). ### `RATE_LIMIT_EXCEEDED` **Cause:** Too many requests in the current time window. -**Fix:** Reduce request frequency. Check the `retryAfter` field for the wait time. +**Fix:** Reduce request frequency. Check the `retryAfterSeconds` field for the wait. **Retry:** `retry_after` ### `QUOTA_EXCEEDED` **Cause:** The API usage quota for the current period has been exhausted. -**Fix:** Wait for the quota to reset (check `retryAfter`), or upgrade the plan. +**Fix:** Wait for the quota to reset (check `retryAfterSeconds`), or upgrade the plan. **Retry:** `retry_after` ### `CONCURRENT_LIMIT_EXCEEDED` @@ -648,7 +648,7 @@ interface EnhancedApiError { httpStatus?: number; // HTTP status code retryable: boolean; // Whether retry may succeed retryStrategy?: RetryStrategy; // Recommended retry approach - retryAfter?: number; // Seconds to wait (for rate limits) + retryAfterSeconds?: number; // Wait before retrying (for rate limits) details?: unknown; // Additional error context fields?: FieldError[]; // One entry per offending value timestamp?: string; // ISO 8601 timestamp @@ -714,7 +714,7 @@ reading a field no server sent — move to `error.fields`. The `@objectstack/client` SDK's built-in fetch error handling attaches `code`, `category`, `httpStatus`, `retryable`, `details`, and — for validation -failures — `fields`. It does **not** attach `retryAfter` or `requestId` +failures — `fields`. It does **not** attach `retryAfterSeconds` or `requestId` directly; if your server populates those on the response body, read them from `apiError.details` until the client surfaces them at the top level. @@ -766,7 +766,7 @@ async function handleApiCall() { case 'rate_limit': // Wait and retry - const waitTime = apiError.retryAfter ?? 60; + const waitTime = apiError.retryAfterSeconds ?? 60; await sleep(waitTime * 1000); return handleApiCall(); diff --git a/content/docs/api/error-handling-client.mdx b/content/docs/api/error-handling-client.mdx index 1b726b4bda..8601015e6f 100644 --- a/content/docs/api/error-handling-client.mdx +++ b/content/docs/api/error-handling-client.mdx @@ -28,7 +28,7 @@ interface ErrorResponse { httpStatus?: number; // HTTP status code retryable?: boolean; // Whether the request can be retried retryStrategy?: string; - retryAfter?: number; // Seconds to wait before retrying (rate limits) + retryAfterSeconds?: number; // Wait before retrying (rate limits) // One entry per offending value. Named `fields` on the wire AND in the spec // contract since ADR-0114 D4; the old `fieldErrors` is tombstoned. fields?: Array<{ @@ -69,7 +69,7 @@ import type { ErrorResponse } from '@objectstack/spec/api'; class ObjectStackError extends Error { code: string; status: number; - retryAfter?: number; + retryAfterSeconds?: number; fields: ErrorResponse['error']['fields']; details: ErrorResponse['error']['details']; requestId?: string; @@ -79,7 +79,7 @@ class ObjectStackError extends Error { this.name = 'ObjectStackError'; this.code = response.error.code; this.status = response.error.httpStatus ?? 0; - this.retryAfter = response.error.retryAfter; + this.retryAfterSeconds = response.error.retryAfterSeconds; this.fields = response.error.fields; this.details = response.error.details; this.requestId = response.error.requestId ?? response.meta?.requestId; @@ -241,7 +241,7 @@ Not all errors should be retried. Use this decision matrix: | Authentication errors | 401 | ⚠️ Once | Refresh token, then retry | | Permission errors | 403 | ❌ No | Show access denied message | | Not found | 404 | ❌ No | Show not found message | -| Rate limited | 429 | ✅ Yes | Wait for `retryAfter` seconds, then retry | +| Rate limited | 429 | ✅ Yes | Wait `retryAfterSeconds`, then retry | | Server errors | 500 | ✅ Yes | Exponential backoff | | Network errors | 0 | ✅ Yes | Exponential backoff | @@ -266,12 +266,12 @@ async function withRetry( } // Rate limited: honor the server's retry hint. - // `retryAfter` is in seconds; some responses instead carry an + // `retryAfterSeconds` names its own unit; some responses instead carry an // absolute `details.resetAt` timestamp. if (error.isRateLimit) { const resetAt = (error.details as any)?.resetAt; - const waitMs = error.retryAfter != null - ? error.retryAfter * 1000 + const waitMs = error.retryAfterSeconds != null + ? error.retryAfterSeconds * 1000 : resetAt ? Math.max(new Date(resetAt).getTime() - Date.now(), 1000) : baseDelay; diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 24a14a7dfc..47ac15fcc6 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -310,7 +310,7 @@ is gated at publish and, once it passes, serves real traffic. Full contract: | **Types that execute** | `object_operation` (needs `objectParams.object` + `.operation`) and `flow` (needs `target`). `script` / `proxy` are rejected at publish | | **`authRequired`** | defaults to `true` — **omitting it is safe**. An explicit `false` is the only thing that opens anonymous access | | **`authRequired: false`** | REQUIRES an armed budget, `rateLimit: { enabled: true, windowMs, maxRequests }` (ADR-0121 D6) — `enabled` itself defaults to `false`, so a budget without it meters nothing | -| **`cacheTtl`** | seconds, GET-only, applied to successful answers only (`Cache-Control: private, max-age=`) | +| **`cacheTtlSeconds`** | GET-only, applied to successful answers only (`Cache-Control: private, max-age=`) | {/* os:check */} ```typescript @@ -326,7 +326,7 @@ export const leadFeed: ApiEndpoint = { // `target` is required (at publish) only for `type: 'flow'`. objectParams: { object: 'acme_lead', operation: 'find' }, // `authRequired` omitted → defaults to true (a session is required). - cacheTtl: 30, + cacheTtlSeconds: 30, }; ``` diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 1cf488689c..1e3a46a755 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1198,8 +1198,8 @@ export default defineStack({ objectParams: { object: 'acme_lead', operation: 'find' }, // Defaults to `true`. Omitting it is safe; see the policy table below. authRequired: true, - // Seconds. GET-only, and only ever on a successful answer. - cacheTtl: 30, + // GET-only, and only ever on a successful answer. The unit is in the key. + cacheTtlSeconds: 30, }, ], }); @@ -1213,7 +1213,7 @@ declaration to shadow a built-in route: 1. **Match** — the request path must be under `/apps/`, and `METHOD` + path (one trailing slash trimmed) must hit exactly one declaration. -2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtl`, in that order. Metering +2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtlSeconds`, in that order. Metering runs *before* the auth gate on purpose: the traffic that most needs a budget (credential stuffing, scraping) is exactly the traffic that ends in a 401, so a denied request still spends a token. @@ -1228,8 +1228,8 @@ declaration to shadow a built-in route: | `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder, the same `execute` call, and **the same response contract**: a refused or failed run is classified into the same real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 422 `FLOW_INPUT_SCHEMA_INVALID` / 400 `FLOW_FAILED`), from one shared definition all three flow doors read. Branch on the status and `error.code`, never on an inner success flag | | `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers | | `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive | -| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | -| `cacheTtl: 0` | `Cache-Control: no-store` | +| `cacheTtlSeconds: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | +| `cacheTtlSeconds: 0` | `Cache-Control: no-store` | | an error answer (401/429/5xx) | never carries `Cache-Control`, and `outputMapping` is never applied to it | ### What an unmatched request answers @@ -1263,7 +1263,7 @@ runs the same gates your publish path does: | **Namespace** (ADR-0121 D1/D2) | a `path` outside `/api/v1/apps//`, or a stack declaring `apis:` with no explicit `manifest.namespace` | | **Supported target** | `type: 'script'` / `'proxy'` (neither executes in 17.x), an `object_operation` missing `objectParams.object` or `.operation`, a `flow` naming no `target` | | **Mapping** | a mapping `transform` (there is no transformation registry), an unusable dot path (empty segment, `__proto__`), two entries writing the same target path, or `inputMapping` on a `find` / `get` / `delete` operation that never reads a body | -| **Policy** | `authRequired: false` without `rateLimit.enabled: true` (ADR-0121 D6), an unusable armed budget, a negative `cacheTtl`, or `cacheTtl` on a non-GET method | +| **Policy** | `authRequired: false` without `rateLimit.enabled: true` (ADR-0121 D6), an unusable armed budget, a negative `cacheTtlSeconds`, or `cacheTtlSeconds` on a non-GET method | | **Uniqueness** | two endpoints in one stack claiming the same `METHOD` + path | diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 14cb4740b0..eab09d4411 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -88,7 +88,8 @@ const result = AuthEndpointSchema.parse(data); | **code** | `string` | ✅ | Short-lived device code used for polling | | **verificationUrl** | `string` | ✅ | URL the user should open in a browser | | **expiresAt** | `string` | ✅ | ISO timestamp when the code expires | -| **interval** | `number` | optional (default: `2`) | Recommended polling interval in seconds | +| **intervalSeconds** | `number` | optional (default: `2`) | Recommended polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the polling cadence is a duration and its unit lived only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. This response is not an RFC 8628 device-authorization payload — it renames every RFC field it carries — so the standard does not fix the bare spelling here. | --- diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b5fb06793a..e5821b7a49 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -452,7 +452,8 @@ const result = ApiErrorSchema.parse(data); | **batchScheduleFn** | `Enum<'microtask' \| 'timeout' \| 'manual'>` | optional (default: `"microtask"`) | Scheduling strategy for collecting batch keys | | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | -| **cacheTtl** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | @@ -665,7 +666,8 @@ const result = ApiErrorSchema.parse(data); | **batchScheduleFn** | `Enum<'microtask' \| 'timeout' \| 'manual'>` | optional (default: `"microtask"`) | Scheduling strategy for collecting batch keys | | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | -| **cacheTtl** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index ca852a8c5c..82f94e4d8d 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -39,7 +39,8 @@ const result = ApiEndpointSchema.parse(data); | **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body | | **authRequired** | `boolean` | optional (default: `true`) | Require authentication | | **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Rate limiting policy | -| **cacheTtl** | `number` | optional | Response cache TTL in seconds | +| **cacheTtlSeconds** | `number` | optional | Response cache TTL in seconds | +| **cacheTtl** | `never` | optional | [REMOVED] `ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged, and it stays GET-only. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 914c21b94e..1aabfa964f 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -47,7 +47,8 @@ const result = EnhancedApiErrorSchema.parse(data); | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | -| **retryAfter** | `number` | optional | Seconds to wait before retrying | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | @@ -162,7 +163,8 @@ const result = EnhancedApiErrorSchema.parse(data); | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | -| **retryAfter** | `number` | optional | Seconds to wait before retrying | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx index cb4f5a3f6c..07d3fca8ff 100644 --- a/content/docs/references/api/plugin-rest-api.mdx +++ b/content/docs/references/api/plugin-rest-api.mdx @@ -182,10 +182,12 @@ const result = ErrorHandlingConfigSchema.parse(data); | **tags** | `string[]` | optional | OpenAPI tags for grouping | | **requestSchema** | `string` | optional | Request schema name (for validation) | | **responseSchema** | `string` | optional | Response schema name (for documentation) | -| **timeout** | `integer` | optional | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional | Request timeout in milliseconds | | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | -| **cacheTtl** | `integer` | optional | Cache TTL in seconds | +| **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | @@ -207,7 +209,7 @@ const result = ErrorHandlingConfigSchema.parse(data); | **openApi** | `{ enabled: boolean; version: Enum<'3.0.0' \| '3.0.1' \| '3.0.2' \| '3.0.3' \| '3.1.0'>; title: string; description?: string; … }` | optional | OpenAPI documentation configuration | | **globalMiddleware** | `{ name: string; type: Enum<'authentication' \| 'authorization' \| 'logging' \| 'validation' \| 'transformation' \| 'error' \| 'custom'>; enabled: boolean; order: integer; … }[]` | optional | Global middleware stack | | **cors** | `{ enabled: boolean; origins?: string[]; methods?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>[]; credentials: boolean }` | optional | CORS configuration | -| **performance** | `{ enableCompression: boolean; enableETag: boolean; enableCaching: boolean; defaultCacheTtl: integer }` | optional | Performance optimization settings | +| **performance** | `{ enableCompression: boolean; enableETag: boolean; enableCaching: boolean; defaultCacheTtlSeconds: integer }` | optional | Performance optimization settings | ### Nested Shape: `RestApiPluginConfig.routes[number]` @@ -302,7 +304,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **enableCompression** | `boolean` | optional (default: `true`) | Enable response compression | | **enableETag** | `boolean` | optional (default: `true`) | Enable ETag generation | | **enableCaching** | `boolean` | optional (default: `true`) | Enable HTTP caching | -| **defaultCacheTtl** | `integer` | optional (default: `300`) | Default cache TTL in seconds | +| **defaultCacheTtlSeconds** | `integer` | optional (default: `300`) | Default cache TTL in seconds | +| **defaultCacheTtl** | `never` | optional | [REMOVED] `RestApiPluginConfig.performance.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged. | --- @@ -357,10 +360,12 @@ const result = ErrorHandlingConfigSchema.parse(data); | **tags** | `string[]` | optional | OpenAPI tags for grouping | | **requestSchema** | `string` | optional | Request schema name (for validation) | | **responseSchema** | `string` | optional | Response schema name (for documentation) | -| **timeout** | `integer` | optional | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional | Request timeout in milliseconds | | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | -| **cacheTtl** | `integer` | optional | Cache TTL in seconds | +| **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | ### Nested Shape: `RestApiRouteRegistration.middleware[number]` diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index bfe175a5e8..b38ec27f03 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -78,7 +78,8 @@ HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST- | **description** | `string` | optional | OpenAPI description | | **public** | `boolean` | optional (default: `false`) | Is publicly accessible | | **permissions** | `string[]` | optional | Required permissions | -| **timeout** | `integer` | optional | Execution timeout in ms | +| **timeoutMs** | `integer` | optional | Execution timeout in ms | +| **timeout** | `never` | optional | [REMOVED] `RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **rateLimit** | `string` | optional | Rate limit policy name | diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index 0d01598748..7820ee5ea9 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -433,10 +433,13 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **url** | `string` | ✅ | WebSocket server URL | | **protocols** | `string[]` | optional | WebSocket sub-protocols | | **reconnect** | `boolean` | optional (default: `true`) | Enable automatic reconnection | -| **reconnectInterval** | `integer` | optional (default: `1000`) | Reconnection interval in milliseconds | +| **reconnectIntervalMs** | `integer` | optional (default: `1000`) | Reconnection interval in milliseconds | | **maxReconnectAttempts** | `integer` | optional (default: `5`) | Maximum reconnection attempts | -| **pingInterval** | `integer` | optional (default: `30000`) | Ping interval in milliseconds | -| **timeout** | `integer` | optional (default: `5000`) | Message timeout in milliseconds | +| **pingIntervalMs** | `integer` | optional (default: `30000`) | Ping interval in milliseconds | +| **timeoutMs** | `integer` | optional (default: `5000`) | Message timeout in milliseconds | +| **reconnectInterval** | `never` | optional | [REMOVED] `WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; the value (milliseconds) is unchanged. | +| **pingInterval** | `never` | optional | [REMOVED] `WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **headers** | `Record` | optional | Custom headers for WebSocket handshake | @@ -719,10 +722,11 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable WebSocket server | | **path** | `string` | optional (default: `"/ws"`) | WebSocket endpoint path | -| **heartbeatInterval** | `number` | optional (default: `30000`) | Heartbeat interval in milliseconds | +| **heartbeatIntervalMs** | `number` | optional (default: `30000`) | Heartbeat interval in milliseconds | | **reconnectAttempts** | `number` | optional (default: `5`) | Maximum reconnection attempts for clients | | **presence** | `boolean` | optional (default: `false`) | Enable presence tracking | | **cursorSharing** | `boolean` | optional (default: `false`) | Enable collaborative cursor sharing | +| **heartbeatInterval** | `never` | optional | [REMOVED] `WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; the value (milliseconds) is unchanged. | --- diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index a326196156..3212d62015 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -842,7 +842,7 @@ "object_operation target — the task feed (authed read)", "flow target — delegates to a flow", "policy: authRequired (default true)", - "policy: cacheTtl → Cache-Control", + "policy: cacheTtlSeconds → Cache-Control", "script/proxy target — answer 501 in the open framework" ], "steps": [ @@ -854,7 +854,7 @@ ], "acceptance": [ { - "clause": "the object_operation endpoint answers 200 authed with the delegated data, and carries the declared cache policy (Cache-Control: private, max-age=30 when cacheTtl is set)", + "clause": "the object_operation endpoint answers 200 authed with the delegated data, and carries the declared cache policy (Cache-Control: private, max-age=30 when cacheTtlSeconds is set)", "oracle": "api", "verify": "authed GET status 200 + body + Cache-Control header vs the declared policy", "evidence": "response + headers" diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 11d36f5040..57fa637984 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -124,7 +124,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/apis/index.ts'], notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtl: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', + 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', }, translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] }, email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] }, diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 71b4e88feb..afb47a89d9 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -30,7 +30,7 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; * target delegation, mapping keys, OpenAPI enrichment) and E7 narrowed the * blanket refusal to per-endpoint publish gates. So the premise of the comment * is gone, and these come back **unchanged in intent** — same names, same - * targets, same `authRequired`, same `cacheTtl` — with the ONE edit ADR-0121 + * targets, same `authRequired`, same `cacheTtlSeconds` — with the ONE edit ADR-0121 * D1 requires: the paths move under this app's namespace carve-out. * * ## The namespace carve-out (ADR-0121 D1/D2) @@ -88,11 +88,11 @@ export const TaskFeedEndpoint: ApiEndpoint = { // RLS-trimmed for its caller and a shared cache must never store one and // hand it to somebody else. `computeCacheControl` in // `packages/runtime/src/endpoint-policy.ts` states that rule and the rest of - // the ladder with it — including `cacheTtl: 0`, which is `no-store` rather + // the ladder with it — including `cacheTtlSeconds: 0`, which is `no-store` rather // than "no header": writing 0 says something, and saying nothing is spelled - // by omitting the key. GET-only by rule: publish rejects `cacheTtl` on any + // by omitting the key. GET-only by rule: publish rejects `cacheTtlSeconds` on any // other method rather than parsing it and ignoring it. - cacheTtl: 30, + cacheTtlSeconds: 30, }; /** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */ diff --git a/packages/metadata/src/endpoint-matcher.test.ts b/packages/metadata/src/endpoint-matcher.test.ts index 7f57ff4e7f..c5872e9734 100644 --- a/packages/metadata/src/endpoint-matcher.test.ts +++ b/packages/metadata/src/endpoint-matcher.test.ts @@ -343,8 +343,8 @@ describe('#5189 — publish gates re-applied at load (identity-free subset)', () endpoint({ name: 'proxied', type: 'proxy', target: 'https://x.test' }), endpoint({ name: 'no_params', objectParams: undefined }), endpoint({ name: 'mapped', outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }), - endpoint({ name: 'neg_cache', cacheTtl: -1 }), - endpoint({ name: 'post_cache', method: 'POST', cacheTtl: 30 }), + endpoint({ name: 'neg_cache', cacheTtlSeconds: -1 }), + endpoint({ name: 'post_cache', method: 'POST', cacheTtlSeconds: 30 }), ]) { const logger = makeLogger(); expect(buildEndpointIndex([bad], logger).size).toBe(0); diff --git a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts index 42e593deeb..268ca519ad 100644 --- a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts +++ b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts @@ -91,7 +91,7 @@ describe('[#5112] ADR-0121 D6 — anonymous is served, and metered', () => { expect(body.success).toBe(true); }); - it('carries the declared cacheTtl on the anonymous success', async () => { + it('carries the declared cacheTtlSeconds on the anonymous success', async () => { const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); expect(res.headers.get('cache-control')).toMatch(/max-age=15/); }); diff --git a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts index 080e26d82d..8e7f61fb7d 100644 --- a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts @@ -22,7 +22,7 @@ // anonymous-without-armed-budget combination for that reason. The budget is // deliberately tiny (2 requests per minute) so a test can exhaust it without // sleeping. -// • `cacheTtl` on the GET — proves the success-only `Cache-Control` on a +// • `cacheTtlSeconds` on the GET — proves the success-only `Cache-Control` on a // second, independent stack. // // The paths sit under `/api/v1/apps/e8policy/…` because ADR-0121 D1 confines a @@ -59,7 +59,7 @@ export const AnonymousMeteredEndpoint: ApiEndpoint = { objectParams: { object: 'e8policy_note', operation: 'find' }, authRequired: false, rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 2 }, - cacheTtl: 15, + cacheTtlSeconds: 15, }; /** The control: same object, same operation, session-gated, unmetered. */ diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index 7b0c108957..4d21af3f6b 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -22,7 +22,7 @@ // built-in route gives for the same operation (#5040 §4's red line); // 2. does `authRequired` actually gate (401 for anonymous), rather than // parsing green and gating nothing as it did before #4936; -// 3. does `cacheTtl` reach the wire as `Cache-Control`; +// 3. does `cacheTtlSeconds` reach the wire as `Cache-Control`; // 4. does an UNDECLARED path under the mount still answer the transport's // own bare 404, byte for byte — the seam must cost non-endpoint traffic // nothing (#5090); @@ -272,7 +272,7 @@ describe('[#5112] object_operation endpoint: same pipeline, same answer', () => expect(a.data).toEqual(b); }); - it('carries the declared cacheTtl as a Cache-Control header — `private` included', async () => { + it('carries the declared cacheTtlSeconds as a Cache-Control header — `private` included', async () => { // Both halves of this header are pinned, and the FIRST one is the reason // this assertion exists at all (#5396). // @@ -290,7 +290,7 @@ describe('[#5112] object_operation endpoint: same pipeline, same answer', () => const res = await stack.apiAs(adminToken, 'GET', TASKS); expect( res.headers.get('cache-control'), - 'cacheTtl: 30 must reach the wire, and reach it as `private`', + 'cacheTtlSeconds: 30 must reach the wire, and reach it as `private`', ).toMatch(/^private, max-age=30$/); }, 60_000); diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index 4b3b171952..8c725643c7 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -232,13 +232,13 @@ describe('the policy chain runs between the match and the answer', () => { expect(hint).toContain('#5040'); }); - it('never puts the cacheTtl header on the 501 — but the verdict still carries it', async () => { + it('never puts the cacheTtlSeconds header on the 501 — but the verdict still carries it', async () => { // Exposure, not application: `Cache-Control` describes a successful body // that does not exist yet (execution is E5), and telling a client to // cache a 501 for 30s would be worse than saying nothing. The header // lives on the policy verdict, which is what the executor will read — // asserted directly in `endpoint-policy.test.ts`. - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); const answer = await policedStep([cached], policyContext()); expect(answer?.status).toBe(501); expect(answer?.headers).toBeUndefined(); @@ -276,7 +276,7 @@ describe('the policy chain runs between the match and the answer', () => { * The delegation itself is `endpoint-executor.test.ts`'s subject; what is * asserted here is the JOIN — that a passing request reaches the executor with * the request's own coordinates and identity, that a denial never does, and - * that `cacheTtl`'s header lands on a success and on nothing else. + * that `cacheTtlSeconds`'s header lands on a success and on nothing else. */ describe('execution runs on the far side of the policy chain', () => { const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ ...TASKS, name: 'showcase_open', authRequired: false }); @@ -343,8 +343,8 @@ describe('execution runs on the far side of the policy chain', () => { ]]); }); - it('puts the cacheTtl Cache-Control on a SUCCESS answer', async () => { - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + it('puts the cacheTtlSeconds Cache-Control on a SUCCESS answer', async () => { + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); const answer = await wiredStep([cached], { deps: { callData: callDataSpy().fn as never } }); expect(answer?.status).toBe(200); @@ -352,7 +352,7 @@ describe('execution runs on the far side of the policy chain', () => { }); it('never puts it on an ERROR answer, however the failure arose', async () => { - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); // A delegated pipeline that throws — the executor maps it to a 4xx/5xx // answer, and a client must not be told to reuse a failure for 30s. const answer = await wiredStep([cached], { @@ -365,7 +365,7 @@ describe('execution runs on the far side of the policy chain', () => { // Same for a declaration this runtime does not execute (501 from the // executor's own `unsupported` arm, not from the no-wiring branch). const proxied = ApiEndpointSchema.parse({ - ...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtl: 30, + ...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtlSeconds: 30, }); const unsupported = await wiredStep([proxied], { deps: { callData: async () => ({}) } }); expect(unsupported?.status).toBe(501); @@ -505,8 +505,8 @@ describe('the mapping keys apply on the two sides of the delegation', () => { expect(JSON.stringify(answer?.body)).not.toContain('internal_note'); }); - it('keeps the cacheTtl header on a mapped success', async () => { - // `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the + it('keeps the cacheTtlSeconds header on a mapped success', async () => { + // `cacheTtlSeconds` is GET-only (#5040 §3.3), so this is a read endpoint: the // point is that the two keys compose — the projection replaces the body // and the policy verdict's header still rides with it. const mapped = ApiEndpointSchema.parse({ @@ -514,7 +514,7 @@ describe('the mapping keys apply on the two sides of the delegation', () => { name: 'showcase_cached_map', method: 'GET', objectParams: { object: 'showcase_inquiry', operation: 'find' }, - cacheTtl: 30, + cacheTtlSeconds: 30, outputMapping: [{ source: 'total', target: 'count' }], }); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index 36b4156b2b..24950f017d 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -31,7 +31,7 @@ * * ## The chain, in the one order it can run in * - * `authRequired` / `rateLimit` / `cacheTtl` are enforced by + * `authRequired` / `rateLimit` / `cacheTtlSeconds` are enforced by * {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the * caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the * answer. A pass reaches {@link executeEndpointTarget} — and NOTHING else can: @@ -40,7 +40,7 @@ * forgot the policies" is therefore not a mistake a future change can make by * omission; there is nowhere else to put the call. * - * `verdict.responseHeaders` (the `Cache-Control` computed from `cacheTtl`) is + * `verdict.responseHeaders` (the `Cache-Control` computed from `cacheTtlSeconds`) is * merged into SUCCESS answers only. An error answer never carries it: the * header describes a body the caller should be willing to reuse, and telling a * client to cache a 401 / 429 / 500 for a minute is worse than saying nothing. @@ -133,7 +133,7 @@ export interface AppEndpointStepAnswer { * Headers that are part of THIS answer and must be written with it: * `Retry-After` on a rate-limit denial (the one piece of information a * throttled client needs to behave), and `Cache-Control` on a SUCCESSFUL - * execution result (from `cacheTtl`). + * execution result (from `cacheTtlSeconds`). * * The asymmetry is deliberate and enforced below — `Cache-Control` rides * only on a success, never on an error answer. @@ -242,7 +242,7 @@ export async function runAppEndpointStep( // hint says which keys were NOT evaluated — a report that is wrong // about what ran is worse than no report. return notImplemented(match, method, path, - 'This request reached the step without a policy context, so authRequired / rateLimit / cacheTtl ' + 'This request reached the step without a policy context, so authRequired / rateLimit / cacheTtlSeconds ' + 'were not evaluated — and nothing was executed either. The composed runtime always threads one ' + '(#5040 E5b), so reaching this answer means a host mounted the step by hand and omitted it.'); } @@ -261,7 +261,7 @@ export async function runAppEndpointStep( // without a policy context, and a denial short-circuits before it. if (!input.execution) { return notImplemented(match, method, path, - 'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them, but no ' + 'Policies (authRequired / rateLimit / cacheTtlSeconds) were enforced and this request passed them, but no ' + 'execution wiring was supplied, so the target was not run. The composed runtime always supplies ' + 'it (#5040 E5b).'); } @@ -295,7 +295,7 @@ export async function runAppEndpointStep( deps, ); - // `Cache-Control` (from `cacheTtl`) applies to a SUCCESS and nothing else. + // `Cache-Control` (from `cacheTtlSeconds`) applies to a SUCCESS and nothing else. // `executeEndpointTarget` never throws — a delegated failure is already an // error answer here — so the status is the whole test, and an endpoint whose // execution failed cannot hand the client a cache directive for the failure. diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index 43763ae934..aabcbaea34 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -14,7 +14,7 @@ * Since #5129 the seam serves the whole chain — policies (E4) then target * delegation (E5) — so the cases below drive REAL `callData` and a REAL * automation slot, and pin the two things only a socket can prove: that a 429 - * carries its `Retry-After` ON THE WIRE, and that a `cacheTtl` `Cache-Control` + * carries its `Retry-After` ON THE WIRE, and that a `cacheTtlSeconds` `Cache-Control` * rides a success and never an error. * * The load-bearing assertion in most of these is a NEGATIVE one: that adding @@ -349,10 +349,10 @@ const EXECUTABLE: ApiEndpoint[] = [ target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'find' }, authRequired: false, - cacheTtl: 30, + cacheTtlSeconds: 30, }), ApiEndpointSchema.parse({ - // Same `cacheTtl`, but a shape whose execution FAILS: `get` with no + // Same `cacheTtlSeconds`, but a shape whose execution FAILS: `get` with no // `?id=` is a 400 from the executor. The pair is the whole point — // one key, two outcomes, only one of them cacheable. name: 'showcase_cached_get', @@ -362,7 +362,7 @@ const EXECUTABLE: ApiEndpoint[] = [ target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'get' }, authRequired: false, - cacheTtl: 30, + cacheTtlSeconds: 30, }), ]; @@ -513,12 +513,12 @@ describe('the wired chain — policies, then the real pipeline (#5129)', () => { expect(body.error.details?.retryAfterSeconds).toBe(Number(retryAfter)); }); - it('sends cacheTtl\'s Cache-Control on a success and on nothing else', async () => { + it('sends cacheTtlSeconds\'s Cache-Control on a success and on nothing else', async () => { const ok = await fetch(`${baseUrl}/api/v1/apps/showcase/cached`); expect(ok.status).toBe(200); expect(ok.headers.get('Cache-Control')).toBe('private, max-age=30'); - // Same endpoint family, same `cacheTtl: 30`, but the execution fails + // Same endpoint family, same `cacheTtlSeconds: 30`, but the execution fails // (a `get` with no `?id=`). Telling the client to reuse a 400 for 30 // seconds would make an author's typo sticky. const failed = await fetch(`${baseUrl}/api/v1/apps/showcase/cached-get`); diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 038ecbfa40..937495264b 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -485,7 +485,7 @@ async function executeObjectOperation( * applied to the `200`-wrapped FAILURE body and could present it as data. The * policy chain is upstream of this function and is untouched: a refusal here * is reached only by a request that already passed `rateLimit` / - * `authRequired`, and `Cache-Control` from `cacheTtl` rides success only, + * `authRequired`, and `Cache-Control` from `cacheTtlSeconds` rides success only, * again on the same `status < 400` test. */ async function executeFlow( diff --git a/packages/runtime/src/endpoint-policy.test.ts b/packages/runtime/src/endpoint-policy.test.ts index 8fe10a071a..55e2d25b51 100644 --- a/packages/runtime/src/endpoint-policy.test.ts +++ b/packages/runtime/src/endpoint-policy.test.ts @@ -5,7 +5,7 @@ * * Three keys, and for each of them the three cases that matter: declared and * hit, declared and not hit, and the boundary (`authRequired: false`, a budget - * that is present but disarmed, `cacheTtl: 0`). A policy key that is only ever + * that is present but disarmed, `cacheTtlSeconds: 0`). A policy key that is only ever * tested in its "allow" direction is indistinguishable from a key nobody read. * * Everything is driven with stubs — a counter store, a principal resolver, a @@ -298,7 +298,7 @@ describe('rateLimit — #5006 primitives, an endpoint-scoped keyspace', () => { }); // ───────────────────────────────────────────────────────────────────────────── -describe('cacheTtl — response-header semantics only', () => { +describe('cacheTtlSeconds — response-header semantics only', () => { it('says nothing when the key is absent', async () => { expect(computeCacheControl(declare(), 'GET')).toBeUndefined(); const verdict = await run(declare({ authRequired: false }), harness({})); @@ -306,38 +306,38 @@ describe('cacheTtl — response-header semantics only', () => { }); it('sets `private, max-age=` for a positive ttl', async () => { - const verdict = await run(declare({ authRequired: false, cacheTtl: 30 }), harness({})); + const verdict = await run(declare({ authRequired: false, cacheTtlSeconds: 30 }), harness({})); expect(verdict.verdict).toBe('pass'); if (verdict.verdict !== 'pass') return; expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'private, max-age=30' }); }); it('is `private` even on an anonymous endpoint — a shared cache must never hold a per-caller answer', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: 60 }, 'GET')).toBe('private, max-age=60'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 60 }, 'GET')).toBe('private, max-age=60'); }); it('reads 0 as "do not cache" rather than as silence', async () => { - // The boundary #5091 asks for. `cacheTtl: 0` is a sentence the author + // The boundary #5091 asks for. `cacheTtlSeconds: 0` is a sentence the author // wrote; answering it identically to an absent key would make writing it // a no-op, which is the failure mode this program exists to remove. - expect(computeCacheControl({ name: 'e', cacheTtl: 0 }, 'GET')).toBe('no-store'); - const verdict = await run(declare({ authRequired: false, cacheTtl: 0 }), harness({})); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 0 }, 'GET')).toBe('no-store'); + const verdict = await run(declare({ authRequired: false, cacheTtlSeconds: 0 }), harness({})); expect(verdict.verdict).toBe('pass'); if (verdict.verdict !== 'pass') return; expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'no-store' }); }); it('truncates a fractional ttl rather than emitting a fractional max-age', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: 30.7 }, 'GET')).toBe('private, max-age=30'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 30.7 }, 'GET')).toBe('private, max-age=30'); }); it('refuses to invent a meaning for a negative ttl', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: -5 }, 'GET')).toBe('no-store'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: -5 }, 'GET')).toBe('no-store'); }); it('sends no header on a non-GET endpoint, and says so out loud', () => { const warnings: string[] = []; - const header = computeCacheControl({ name: 'purge', cacheTtl: 30 }, 'POST', { + const header = computeCacheControl({ name: 'purge', cacheTtlSeconds: 30 }, 'POST', { warn: (m: string) => { warnings.push(m); }, }); expect(header).toBeUndefined(); @@ -348,7 +348,7 @@ describe('cacheTtl — response-header semantics only', () => { it('is not computed for a denied request', async () => { // Nothing to cache, nothing to say: the denial carries its own headers // (`Retry-After`) and no cache directive at all. - const denied = await run(declare({ cacheTtl: 30 }), harness({})); + const denied = await run(declare({ cacheTtlSeconds: 30 }), harness({})); expect(denied.verdict).toBe('deny'); if (denied.verdict !== 'deny') return; expect(denied.headers).toBeUndefined(); diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index edf42522f6..3cafa6221d 100644 --- a/packages/runtime/src/endpoint-policy.ts +++ b/packages/runtime/src/endpoint-policy.ts @@ -2,7 +2,7 @@ /** * The POLICY KEYS of a declarative `apis:` endpoint — `authRequired`, - * `rateLimit`, `cacheTtl` (#5040 E4). + * `rateLimit`, `cacheTtlSeconds` (#5040 E4). * * ## What this is, and what it deliberately is not * @@ -16,9 +16,9 @@ * |---|---|---| * | `authRequired` | `shouldDenyAnonymous` + the `ANONYMOUS_DENY_*` constants | `/meta`, `/ai`, `/security` (#2567, #3963) | * | `rateLimit` | `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter` | the server-level inbound limiter (#5006, #4910 Q3=C / Q4=B) | - * | `cacheTtl` | a `Cache-Control` response header, nothing more | — | + * | `cacheTtlSeconds` | a `Cache-Control` response header, nothing more | — | * - * `cacheTtl` is header semantics ONLY. #5091 narrowed the design's original + * `cacheTtlSeconds` is header semantics ONLY. #5091 narrowed the design's original * server-side cache out of scope on purpose: a cache needs an invalidation * story, and inventing one for a key whose vocabulary says four words * ("Response cache TTL in seconds") is how a runtime dialect is born. A header @@ -33,7 +33,7 @@ * * ## Order: rate limit BEFORE auth. This is not an accident. * - * #5040 §3 fixes the order as `rateLimit → authRequired → cacheTtl`, and the + * #5040 §3 fixes the order as `rateLimit → authRequired → cacheTtlSeconds`, and the * rationale is worth restating where the code is: the traffic that most needs * metering — credential stuffing, token spraying, scraping — is exactly the * traffic that will be answered 401. Gating first and metering second would let @@ -53,7 +53,7 @@ * traffic: `api-endpoint-step.ts` applies this chain on every match, and the * showcase's two declared endpoints exercise it over a socket * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts` - * pins that `authRequired` denies anonymous and `cacheTtl` reaches the wire). + * pins that `authRequired` denies anonymous and `cacheTtlSeconds` reaches the wire). * The tests below still drive this module directly — a pure function over * explicit deps is worth testing as one. */ @@ -209,7 +209,7 @@ export type EndpointPolicyVerdict = principalId?: string; /** * Headers for the endpoint's eventual SUCCESS answer — today only - * `Cache-Control`, from `cacheTtl`. Handed back rather than applied + * `Cache-Control`, from `cacheTtlSeconds`. Handed back rather than applied * because the thing being described (the response body) does not exist * yet: execution lands with #5040 E5, and telling a client to cache a * 501 for a minute would be worse than saying nothing. @@ -225,7 +225,7 @@ export type EndpointPolicyVerdict = }; /** - * `cacheTtl` → the `Cache-Control` header for a successful response. + * `cacheTtlSeconds` → the `Cache-Control` header for a successful response. * * The vocabulary fixes the unit (seconds) and nothing else, so the rest is * stated here, tested, and documented rather than left to a reader's guess: @@ -239,28 +239,28 @@ export type EndpointPolicyVerdict = * shared cache must never store one and hand it to somebody else. (The * design's per-principal cache key, #5040 §3.3, is the same rule one layer * down; with no server-side cache this is where it survives.) - * - **0 or negative** → `no-store`. An author who writes `cacheTtl: 0` said + * - **0 or negative** → `no-store`. An author who writes `cacheTtlSeconds: 0` said * something; making it identical to saying nothing is exactly the silent * no-op this program exists to remove. (E7's publish gate should reject a * NEGATIVE ttl outright — noted on #5111 — but the runtime still has to * answer coherently if one arrives.) - * - **non-GET** → no header, plus a `warn` naming the endpoint. `cacheTtl` is + * - **non-GET** → no header, plus a `warn` naming the endpoint. `cacheTtlSeconds` is * GET-only (#5040 §3.3) and E7 rejects the combination at publish; until * then the runtime refuses to invent a meaning for it, and says so out loud * instead of dropping it silently. */ export function computeCacheControl( - endpoint: Pick, + endpoint: Pick, method: string, logger?: RateLimitLogger, ): string | undefined { - const ttl = endpoint.cacheTtl; + const ttl = endpoint.cacheTtlSeconds; if (ttl === undefined || ttl === null) return undefined; if (method.toUpperCase() !== 'GET') { logger?.warn?.( - `[dispatcher] endpoint '${endpoint.name}' declares \`cacheTtl\` on a ${method.toUpperCase()} endpoint. ` - + '`cacheTtl` is GET-only (#5040 §3.3) and no Cache-Control header will be sent. Remove the key, or ' + `[dispatcher] endpoint '${endpoint.name}' declares \`cacheTtlSeconds\` on a ${method.toUpperCase()} endpoint. ` + + '`cacheTtlSeconds` is GET-only (#5040 §3.3) and no Cache-Control header will be sent. Remove the key, or ' + 'declare the endpoint as GET.', ); return undefined; @@ -378,7 +378,7 @@ export async function applyEndpointPolicies(input: EndpointPolicyInput): Promise } } - // ── ③ cacheTtl ────────────────────────────────────────────────────── + // ── ③ cacheTtlSeconds ────────────────────────────────────────────────────── const cacheControl = computeCacheControl(endpoint, method, logger); return { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 56b7f93fd0..7cd7bbbd03 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -497,7 +497,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + '`IHttpServer.setFallbackHandler` (Hono `app.notFound`) that runs only after every registered ' + 'route has missed, and for paths under this prefix resolves the request\'s environment + ' + 'identity, probes `metadata.matchEndpoint`, and on a match runs the full chain (#5040 E5b): ' - + 'the policy keys authRequired / rateLimit / cacheTtl (E4), then target delegation (E5) — ' + + 'the policy keys authRequired / rateLimit / cacheTtlSeconds (E4), then target delegation (E5) — ' + '`object_operation` through the same `callData` as /data, `flow` through the automation ' + 'service. `script` / `proxy` targets and the inputMapping / outputMapping keys are NOT ' + 'executed and answer 501. A miss (or an occupant of the metadata slot with no matchEndpoint, ' diff --git a/packages/spec/REST_API_PLUGIN.md b/packages/spec/REST_API_PLUGIN.md index b9eab75873..41fba2c800 100644 --- a/packages/spec/REST_API_PLUGIN.md +++ b/packages/spec/REST_API_PLUGIN.md @@ -315,7 +315,7 @@ const config: RestApiPluginConfig = { enableCompression: true, enableETag: true, enableCaching: true, - defaultCacheTtl: 300, + defaultCacheTtlSeconds: 300, }, }; ``` diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index a0956ec1b2..1d2c8c6c73 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -56,7 +56,7 @@ "api/DataLoaderConfig:cacheEnabled = true", "api/DataLoaderConfig:coalesceRequests = true", "api/DataLoaderConfig:maxBatchSize = 100", - "api/DeviceRequestResponse:interval = 2", + "api/DeviceRequestResponse:intervalSeconds = 2", "api/DispatcherConfig:fallback = \"404\"", "api/DispatcherRoute:authRequired = true", "api/DispatcherRoute:criticality = \"optional\"", @@ -185,13 +185,13 @@ "api/VersioningConfig:strategy = \"urlPath\"", "api/VersioningConfig:urlPrefix = \"/api\"", "api/WebSocketConfig:maxReconnectAttempts = 5", - "api/WebSocketConfig:pingInterval = 30000", + "api/WebSocketConfig:pingIntervalMs = 30000", "api/WebSocketConfig:reconnect = true", - "api/WebSocketConfig:reconnectInterval = 1000", - "api/WebSocketConfig:timeout = 5000", + "api/WebSocketConfig:reconnectIntervalMs = 1000", + "api/WebSocketConfig:timeoutMs = 5000", "api/WebSocketServerConfig:cursorSharing = false", "api/WebSocketServerConfig:enabled = false", - "api/WebSocketServerConfig:heartbeatInterval = 30000", + "api/WebSocketServerConfig:heartbeatIntervalMs = 30000", "api/WebSocketServerConfig:path = \"/ws\"", "api/WebSocketServerConfig:presence = false", "api/WebSocketServerConfig:reconnectAttempts = 5" diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index e03db2020e..a032679976 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -117,7 +117,8 @@ "api/ApiEndpoint:_packageVersion", "api/ApiEndpoint:_provenance", "api/ApiEndpoint:authRequired", - "api/ApiEndpoint:cacheTtl", + "api/ApiEndpoint:cacheTtl [RETIRED]", + "api/ApiEndpoint:cacheTtlSeconds", "api/ApiEndpoint:description", "api/ApiEndpoint:inputMapping", "api/ApiEndpoint:method", @@ -447,7 +448,8 @@ "api/DataLoaderConfig:batchScheduleFn", "api/DataLoaderConfig:cacheEnabled", "api/DataLoaderConfig:cacheKeyFn", - "api/DataLoaderConfig:cacheTtl", + "api/DataLoaderConfig:cacheTtl [RETIRED]", + "api/DataLoaderConfig:cacheTtlSeconds", "api/DataLoaderConfig:coalesceRequests", "api/DataLoaderConfig:maxBatchSize", "api/DataLoaderConfig:maxConcurrency", @@ -493,7 +495,8 @@ "api/DeleteResponse:success", "api/DeviceRequestResponse:code", "api/DeviceRequestResponse:expiresAt", - "api/DeviceRequestResponse:interval", + "api/DeviceRequestResponse:interval [RETIRED]", + "api/DeviceRequestResponse:intervalSeconds", "api/DeviceRequestResponse:verificationUrl", "api/DiffMetaItemResponse:added", "api/DiffMetaItemResponse:changed", @@ -579,7 +582,8 @@ "api/EnhancedApiError:httpStatus", "api/EnhancedApiError:message", "api/EnhancedApiError:requestId", - "api/EnhancedApiError:retryAfter", + "api/EnhancedApiError:retryAfter [RETIRED]", + "api/EnhancedApiError:retryAfterSeconds", "api/EnhancedApiError:retryStrategy", "api/EnhancedApiError:retryable", "api/EnhancedApiError:timestamp", @@ -1449,7 +1453,8 @@ "api/RestApiConfig:requireAuth [RETIRED]", "api/RestApiConfig:responseFormat", "api/RestApiConfig:version", - "api/RestApiEndpoint:cacheTtl", + "api/RestApiEndpoint:cacheTtl [RETIRED]", + "api/RestApiEndpoint:cacheTtlSeconds", "api/RestApiEndpoint:cacheable", "api/RestApiEndpoint:category", "api/RestApiEndpoint:description", @@ -1464,7 +1469,8 @@ "api/RestApiEndpoint:responseSchema", "api/RestApiEndpoint:summary", "api/RestApiEndpoint:tags", - "api/RestApiEndpoint:timeout", + "api/RestApiEndpoint:timeout [RETIRED]", + "api/RestApiEndpoint:timeoutMs", "api/RestApiPluginConfig:basePath", "api/RestApiPluginConfig:cors", "api/RestApiPluginConfig:enabled", @@ -1517,7 +1523,8 @@ "api/RouteDefinition:public", "api/RouteDefinition:rateLimit", "api/RouteDefinition:summary", - "api/RouteDefinition:timeout", + "api/RouteDefinition:timeout [RETIRED]", + "api/RouteDefinition:timeoutMs", "api/RouteGenerationConfig:excludeObjects [RETIRED]", "api/RouteGenerationConfig:includeObjects [RETIRED]", "api/RouteGenerationConfig:nameTransform [RETIRED]", @@ -1802,11 +1809,14 @@ "api/VersioningConfig:versions", "api/WebSocketConfig:headers", "api/WebSocketConfig:maxReconnectAttempts", - "api/WebSocketConfig:pingInterval", + "api/WebSocketConfig:pingInterval [RETIRED]", + "api/WebSocketConfig:pingIntervalMs", "api/WebSocketConfig:protocols", "api/WebSocketConfig:reconnect", - "api/WebSocketConfig:reconnectInterval", - "api/WebSocketConfig:timeout", + "api/WebSocketConfig:reconnectInterval [RETIRED]", + "api/WebSocketConfig:reconnectIntervalMs", + "api/WebSocketConfig:timeout [RETIRED]", + "api/WebSocketConfig:timeoutMs", "api/WebSocketConfig:url", "api/WebSocketEvent:channel", "api/WebSocketEvent:occurredAt", @@ -1815,7 +1825,8 @@ "api/WebSocketEvent:type", "api/WebSocketServerConfig:cursorSharing", "api/WebSocketServerConfig:enabled", - "api/WebSocketServerConfig:heartbeatInterval", + "api/WebSocketServerConfig:heartbeatInterval [RETIRED]", + "api/WebSocketServerConfig:heartbeatIntervalMs", "api/WebSocketServerConfig:path", "api/WebSocketServerConfig:presence", "api/WebSocketServerConfig:reconnectAttempts", diff --git a/packages/spec/liveness/api.json b/packages/spec/liveness/api.json index 30dab7b444..cf7fd5129d 100644 --- a/packages/spec/liveness/api.json +++ b/packages/spec/liveness/api.json @@ -132,11 +132,16 @@ } } }, - "cacheTtl": { + "cacheTtlSeconds": { "status": "live", - "evidence": "packages/runtime/src/endpoint-policy.ts#computeCacheControl (`const ttl = endpoint.cacheTtl`; absent ⇒ no header, non-GET ⇒ no header plus a warn naming the endpoint); packages/runtime/src/endpoint-policy.ts#applyEndpointPolicies (step ③ — the returned header is attached to the SUCCESS answer only)", + "evidence": "packages/runtime/src/endpoint-policy.ts#computeCacheControl (`const ttl = endpoint.cacheTtlSeconds`; absent ⇒ no header, non-GET ⇒ no header plus a warn naming the endpoint); packages/runtime/src/endpoint-policy.ts#applyEndpointPolicies (step ③ — the returned header is attached to the SUCCESS answer only)", "verifiedAt": "2026-08-28", - "note": "Seconds, emitted as a Cache-Control response header and nothing more (#5091 narrowed the original design to header semantics only — there is no response store). Applied to SUCCESSFUL answers only: telling a client to reuse a 401/429/5xx for half a minute is worse than saying nothing. GET-only — on any other method publish refuses it (#5040 §3.3) and the runtime warns instead of emitting. 2026-08-28: RE-ANCHORED (#13003) and PROSE CORRECTED — the line `:252` was ACCURATE (it is the function's own declaration line), but the parenthetical named `cacheControlHeader`, which is not a symbol anywhere in `packages/**` — the function is `computeCacheControl`, and the only other occurrence of the old spelling is a docblock table in `endpoint-publish-gate.ts` that names it and `endpointRateLimiterRegistry` (also stale — `createEndpointRateLimiterRegistry`) as this file's exports, i.e. the same rename went unpropagated in two places. Line-accurate and name-wrong is the combination nothing in the ledger could flag, because no check has ever compared a citation's prose against its position — and it is the combination an anchor removes by construction, since the name IS the pointer now. Re-closed by hand against 93ea19bca." + "note": "Seconds, emitted as a Cache-Control response header and nothing more (#5091 narrowed the original design to header semantics only — there is no response store). Applied to SUCCESSFUL answers only: telling a client to reuse a 401/429/5xx for half a minute is worse than saying nothing. GET-only — on any other method publish refuses it (#5040 §3.3) and the runtime warns instead of emitting. 2026-08-28: RE-ANCHORED (#13003) and PROSE CORRECTED — the line `:252` was ACCURATE (it is the function's own declaration line), but the parenthetical named `cacheControlHeader`, which is not a symbol anywhere in `packages/**` — the function is `computeCacheControl`, and the only other occurrence of the old spelling is a docblock table in `endpoint-publish-gate.ts` that names it and `endpointRateLimiterRegistry` (also stale — `createEndpointRateLimiterRegistry`) as this file's exports, i.e. the same rename went unpropagated in two places. Line-accurate and name-wrong is the combination nothing in the ledger could flag, because no check has ever compared a citation's prose against its position — and it is the combination an anchor removes by construction, since the name IS the pointer now. Re-closed by hand against 93ea19bca. RENAMED 2026-09-05 (#15677, #14478 ruling B) from `cacheTtl`: the unit lived only in the describe prose. `computeCacheControl` moved from `endpoint.cacheTtl` to `endpoint.cacheTtlSeconds` in the same PR at the same magnitude, and the publish gate's issue path moved with it (`apis.N.cacheTtlSeconds`). Anchors carried over from the `cacheTtl` row (re-anchored #13003)." + }, + "cacheTtl": { + "status": "dead", + "verifiedAt": "2026-09-05", + "note": "REMOVED 2026-09-05 (#15677, #14478 ruling B) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of stored sources by the protocol-18 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `cacheTtlSeconds` — rename the key, the value (seconds) is unchanged, it is still GET-only, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/api/endpoint.zod.ts#cacheTtl. Not to be confused with `RestServerConfig.metadata.cacheTtl` (liveness/metadata_endpoints.json), a different key retired for a different reason by #14691." } } } diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 397795fae6..207b7becff 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -54,7 +54,7 @@ for both corollaries. | `seed` | 12 | 0 | 0 | 0 | 0 | 12 | | `translation` | 23 | 0 | 0 | 0 | 2 | 25 | | `validation` | 15 | 0 | 0 | 3 | 0 | 18 | -| `api` | 25 | 0 | 0 | 0 | 2 | 27 | +| `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **92** | **12** | **955** | +| **total** | **845** | **5** | **1** | **93** | **12** | **956** | diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts index 3c62354f00..3231b0e07b 100644 --- a/packages/spec/src/api/apis-publish-gates.test.ts +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -55,7 +55,7 @@ const validObjectEndpoint = { target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'find' as const }, authRequired: true, - cacheTtl: 30, + cacheTtlSeconds: 30, }; /** A fully valid `flow` endpoint under the same namespace. */ @@ -141,7 +141,7 @@ describe('[#5111] the flip — a well-formed `apis:` publishes', () => { path: '/api/v1/apps/showcase/tasks', method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' }, - cacheTtl: undefined, + cacheTtlSeconds: undefined, inputMapping: [{ source: 'title', target: 'name' }, { source: 'meta.owner', target: 'owner_id' }], outputMapping: [{ source: 'id', target: 'task_id' }], }, @@ -330,7 +330,7 @@ describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclaration name: 'showcase_task_create', method: 'POST' as const, objectParams: { object: 'showcase_task', operation: 'create' as const }, - cacheTtl: undefined, + cacheTtlSeconds: undefined, }; it('rejects `transform` on either mapping key', () => { @@ -380,7 +380,7 @@ describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclaration { ...validObjectEndpoint, method: operation === 'delete' ? 'DELETE' : 'GET', - cacheTtl: undefined, + cacheTtlSeconds: undefined, objectParams: { object: 'showcase_task', operation }, inputMapping: [{ source: 'a', target: 'b' }], }, @@ -448,19 +448,19 @@ describe('[#5111] gate (e) — policy keys (ADR-0121 D6 + the E4 refusals)', () expect(apis?.[0]?.rateLimit).toEqual({ enabled: true, windowMs: 60_000, maxRequests: 100 }); }); - it('rejects a negative `cacheTtl`, and accepts 0 (an explicit no-store)', () => { - const message = reject({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: -5 }] }); + it('rejects a negative `cacheTtlSeconds`, and accepts 0 (an explicit no-store)', () => { + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, cacheTtlSeconds: -5 }] }); expect(message).toMatch(/cannot be negative/); - accept({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: 0 }] }); + accept({ manifest, apis: [{ ...validObjectEndpoint, cacheTtlSeconds: 0 }] }); }); - it('rejects `cacheTtl` on a non-GET endpoint', () => { + it('rejects `cacheTtlSeconds` on a non-GET endpoint', () => { const message = reject({ manifest, - apis: [{ ...validFlowEndpoint, cacheTtl: 30 }], + apis: [{ ...validFlowEndpoint, cacheTtlSeconds: 30 }], }); expect(message).toMatch(/GET-only/); - expect(message).toMatch(/apis\.0\.cacheTtl/); + expect(message).toMatch(/apis\.0\.cacheTtlSeconds/); }); }); @@ -500,7 +500,7 @@ describe('[#5111] gate (d) — one claim per METHOD + path inside a stack', () = ...validObjectEndpoint, name: 'showcase_task_create', method: 'POST', - cacheTtl: undefined, + cacheTtlSeconds: undefined, objectParams: { object: 'showcase_task', operation: 'create' }, }, ], @@ -515,12 +515,12 @@ describe('[#5111] every rejection is actionable, and reaches every publish seam' apis: [ { ...validObjectEndpoint, name: 'a_bad', path: '/api/v1/nope' }, { ...validFlowEndpoint, name: 'b_bad', target: '' }, - { ...validObjectEndpoint, name: 'c_bad', path: '/api/v1/apps/showcase/other', cacheTtl: -1 }, + { ...validObjectEndpoint, name: 'c_bad', path: '/api/v1/apps/showcase/other', cacheTtlSeconds: -1 }, ], }); const custom = result.success ? [] : result.error.issues.filter((i) => i.code === 'custom'); expect(custom).toHaveLength(3); - expect(custom.map((i) => i.path.join('.'))).toEqual(['apis.0.path', 'apis.1.target', 'apis.2.cacheTtl']); + expect(custom.map((i) => i.path.join('.'))).toEqual(['apis.0.path', 'apis.1.target', 'apis.2.cacheTtlSeconds']); }); it('`defineStack` throws the same prescription an artifact parse reports', () => { @@ -551,7 +551,7 @@ describe('[#5111] the `ApiEndpoint` vocabulary itself is untouched', () => { expect(parsed.type).toBe('object_operation'); expect(parsed.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); expect(parsed.authRequired).toBe(true); - expect(parsed.cacheTtl).toBe(30); + expect(parsed.cacheTtlSeconds).toBe(30); }); it('keeps `authRequired` defaulting to true — omission is the SAFE state', () => { @@ -596,7 +596,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi it('still refuses D6 — the gate with no runtime counterpart, and the reason #5189 exists', () => { const failure = identityFreeEndpointGateFailure( - ApiEndpointSchema.parse({ ...validObjectEndpoint, cacheTtl: undefined, authRequired: false }), + ApiEndpointSchema.parse({ ...validObjectEndpoint, cacheTtlSeconds: undefined, authRequired: false }), ); expect(failure).toBeDefined(); expect(failure!.path).toEqual(['rateLimit']); @@ -609,7 +609,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi identityFreeEndpointGateFailure( ApiEndpointSchema.parse({ ...validObjectEndpoint, - cacheTtl: undefined, + cacheTtlSeconds: undefined, authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, }), @@ -622,7 +622,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi [{ type: 'proxy', target: 'https://x.test', objectParams: undefined }, ['type']], [{ objectParams: { object: 'showcase_task' } }, ['objectParams']], [{ outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }, ['outputMapping', 0, 'transform']], - [{ cacheTtl: -1 }, ['cacheTtl']], + [{ cacheTtlSeconds: -1 }, ['cacheTtlSeconds']], ]; for (const [over, path] of cases) { const failure = identityFreeEndpointGateFailure( diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index f8eefb9a3e..8c8b4c986c 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { AuthEndpointPaths, + DeviceRequestResponseSchema, AuthEndpointSchema, AuthEndpointAliases, AuthFeaturesConfigSchema, @@ -173,3 +174,41 @@ describe('getAuthEndpointUrl', () => { ); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('DeviceRequestResponse.interval \u2192 intervalSeconds (#15677)', () => { + const base = { + code: 'ABCD-1234', + verificationUrl: 'https://example.com/device', + expiresAt: '2026-09-05T12:00:00.000Z', + }; + + it('REFUSES the retired `interval` spelling with the rename in the message', () => { + const result = DeviceRequestResponseSchema.safeParse({ ...base, interval: 5 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`DeviceRequestResponse\.interval` was renamed to `intervalSeconds`/, + ); + }); + + it('records in the prescription that this is NOT an RFC 8628 mirror', () => { + const result = DeviceRequestResponseSchema.safeParse({ ...base, interval: 5 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue!.message).toMatch(/not an RFC 8628 device-authorization payload/); + }); + + it('accepts `intervalSeconds` at the same magnitude, and keeps the default', () => { + const parsed = DeviceRequestResponseSchema.parse({ ...base, intervalSeconds: 5 }); + expect(parsed.intervalSeconds).toBe(5); + expect(parsed).not.toHaveProperty('interval'); + expect(DeviceRequestResponseSchema.parse(base).intervalSeconds).toBe(2); + }); +}); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 6458512e35..23e58d5225 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -284,7 +284,23 @@ export const DeviceRequestResponseSchema = lazySchema(() => z.object({ code: z.string().describe('Short-lived device code used for polling'), verificationUrl: z.string().url().describe('URL the user should open in a browser'), expiresAt: z.string().datetime().describe('ISO timestamp when the code expires'), - interval: z.number().default(2).describe('Recommended polling interval in seconds'), + // Renamed from `interval` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. Verified NOT an RFC 8628 mirror before renaming — + // this schema already renames every RFC field it carries (`code` is not + // `device_code`, `verificationUrl` is not `verification_uri`, `expiresAt` is + // not `expires_in`, and carries an ISO-8601 string where the RFC has a + // relative lifetime), so it mirrors no standard as a set and cannot claim + // the standard fixes this one name. + intervalSeconds: z.number().default(2).describe('Recommended polling interval in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + interval: retiredKey( + '`DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' + + 'the polling cadence is a duration and its unit lived only in the ' + + 'describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + + 'This response is not an RFC 8628 device-authorization payload — it renames every RFC ' + + 'field it carries — so the standard does not fix the bare spelling here.', + ), })); /** diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts index e95edb9325..5a220e3d18 100644 --- a/packages/spec/src/api/contract.test.ts +++ b/packages/spec/src/api/contract.test.ts @@ -499,7 +499,7 @@ describe('DataLoaderConfigSchema', () => { batchScheduleFn: 'timeout', cacheEnabled: false, cacheKeyFn: 'customKeyFn', - cacheTtl: 60, + cacheTtlSeconds: 60, coalesceRequests: false, maxConcurrency: 4, }); @@ -508,7 +508,7 @@ describe('DataLoaderConfigSchema', () => { expect(config.batchScheduleFn).toBe('timeout'); expect(config.cacheEnabled).toBe(false); expect(config.cacheKeyFn).toBe('customKeyFn'); - expect(config.cacheTtl).toBe(60); + expect(config.cacheTtlSeconds).toBe(60); expect(config.maxConcurrency).toBe(4); }); @@ -520,8 +520,8 @@ describe('DataLoaderConfigSchema', () => { }); }); - it('should reject negative cacheTtl', () => { - expect(() => DataLoaderConfigSchema.parse({ cacheTtl: -1 })).toThrow(); + it('should reject negative cacheTtlSeconds', () => { + expect(() => DataLoaderConfigSchema.parse({ cacheTtlSeconds: -1 })).toThrow(); }); }); @@ -666,3 +666,29 @@ describe('makeApiErrorSchema (federated ledger, #4805)', () => { expect(parsed.requestId).toBe('req_1'); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('DataLoaderConfig.cacheTtl \u2192 cacheTtlSeconds (#15677)', () => { + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = DataLoaderConfigSchema.safeParse({ cacheTtl: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`DataLoaderConfig\.cacheTtl` was renamed to `cacheTtlSeconds`/, + ); + }); + + it('accepts `cacheTtlSeconds` at the same magnitude, and keeps the min(0) bound', () => { + const parsed = DataLoaderConfigSchema.parse({ cacheTtlSeconds: 60 }); + expect(parsed.cacheTtlSeconds).toBe(60); + expect(parsed).not.toHaveProperty('cacheTtl'); + expect(DataLoaderConfigSchema.safeParse({ cacheTtlSeconds: -1 }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index e2706d2a38..24c524804a 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -10,6 +10,7 @@ import { StandardErrorCode } from './errors.zod'; // ========================================== import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const ApiErrorSchema = lazySchema(() => z.object({ /** * Machine-readable semantic code (ADR-0112): a `StandardErrorCode` member or @@ -400,7 +401,16 @@ export const DataLoaderConfigSchema = lazySchema(() => z.object({ .describe('Scheduling strategy for collecting batch keys'), cacheEnabled: z.boolean().default(true).describe('Enable per-request result caching'), cacheKeyFn: z.string().optional().describe('Name or identifier of the cache key function'), - cacheTtl: z.number().min(0).optional().describe('Cache time-to-live in seconds (0 = no expiration)'), + // Renamed from `cacheTtl` (#15677, #14478 ruling B): the unit lived only in + // the describe prose, on a surface whose neighbouring TTLs are milliseconds. + cacheTtlSeconds: z.number().min(0).optional().describe('Cache time-to-live in seconds (0 = no expiration)'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + cacheTtl: retiredKey( + '`DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', + ), coalesceRequests: z.boolean().default(true).describe('Deduplicate identical requests within a batch window'), maxConcurrency: z.number().int().optional().describe('Maximum parallel batch requests'), })); diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts index c30e952b52..97abeb2ee1 100644 --- a/packages/spec/src/api/endpoint-publish-gate.ts +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -28,7 +28,7 @@ * |---|---| * | unsupported target (`script` / `proxy` / incomplete `object_operation` / empty flow `target`) | `planEndpointTarget` — `packages/runtime/src/endpoint-executor.ts` | * | mapping (`transform`, unusable path, colliding `target`) | `mappingDeclarationRejection` — `packages/runtime/src/api-mapping.ts` | - * | policy (armed-but-unusable `rateLimit`, negative `cacheTtl`, `cacheTtl` off GET) | `createEndpointRateLimiterRegistry` / `computeCacheControl` — `packages/runtime/src/endpoint-policy.ts` | + * | policy (armed-but-unusable `rateLimit`, negative `cacheTtlSeconds`, `cacheTtlSeconds` off GET) | `createEndpointRateLimiterRegistry` / `computeCacheControl` — `packages/runtime/src/endpoint-policy.ts` | * | namespace + uniqueness | ADR-0121 D1/D2, and `normalizeEndpointPath` (`packages/metadata/src/endpoint-matcher.ts`) for the path form | * * The runtime keeps its refusals: a declaration can still reach the store @@ -103,7 +103,7 @@ type MappingKey = (typeof MAPPING_KEYS)[number]; * root) and what the author must read. */ export interface EndpointGateIssue { - /** Zod issue path — e.g. `['apis', 2, 'cacheTtl']`. */ + /** Zod issue path — e.g. `['apis', 2, 'cacheTtlSeconds']`. */ path: (string | number)[]; message: string; } @@ -461,7 +461,7 @@ function mappingKeyGate( /** * `inputMapping` on an operation that never reads a request body. * - * PM ruling on #5111 (2026-08-04), same category as `cacheTtl` on a non-GET + * PM ruling on #5111 (2026-08-04), same category as `cacheTtlSeconds` on a non-GET * method: the declaration is legal to parse and provably inert, because * `inputMapping` maps the REQUEST BODY (its own `.describe()`) and `find` / * `get` / `delete` are served from `query` alone. "Declared, parsed, does @@ -487,7 +487,7 @@ function inertInputMappingGate( + 'REQUEST BODY to internal params (its own vocabulary text); `find` takes its criteria from ' + 'the query string and `get` / `delete` take the record id from `query.id`. Remove the key, ' + 'or move the endpoint to an operation that carries a body (`create` / `update`). ' - + 'Same rule, same reason as `cacheTtl` on a non-GET endpoint: a declaration that cannot ' + + 'Same rule, same reason as `cacheTtlSeconds` on a non-GET endpoint: a declaration that cannot ' + 'take effect is rejected instead of silently ignored.', }; } @@ -549,28 +549,28 @@ function policyGate( } } - const cacheTtl = endpoint.cacheTtl; - if (typeof cacheTtl === 'number' && cacheTtl < 0) { + const cacheTtlSeconds = endpoint.cacheTtlSeconds; + if (typeof cacheTtlSeconds === 'number' && cacheTtlSeconds < 0) { return { - path: at('cacheTtl'), + path: at('cacheTtlSeconds'), message: - `${named} declares \`cacheTtl: ${cacheTtl}\`. A response cache lifetime cannot be negative — ` + `${named} declares \`cacheTtlSeconds: ${cacheTtlSeconds}\`. A response cache lifetime cannot be negative — ` + 'seconds only, 0 or more. Use a positive number of seconds for a cacheable answer, or ' - + '`cacheTtl: 0` to say explicitly "never store this response" (it emits ' + + '`cacheTtlSeconds: 0` to say explicitly "never store this response" (it emits ' + '`Cache-Control: no-store`); omit the key to send no caching header at all.', }; } - if (cacheTtl !== undefined && endpoint.method !== 'GET') { + if (cacheTtlSeconds !== undefined && endpoint.method !== 'GET') { // Internal anchor for the GET-only rule: #5040 §3.3. Kept out of the // message, which is printed to a customer with no tracker access. return { - path: at('cacheTtl'), + path: at('cacheTtlSeconds'), message: - `${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only: ` + `${named} declares \`cacheTtlSeconds\` on a ${endpoint.method} endpoint. \`cacheTtlSeconds\` is GET-only: ` + 'it becomes a `Cache-Control` header on a successful response, and a ' + 'non-GET answer is not a cacheable representation, so the key would be parsed and never ' - + 'take effect. Remove `cacheTtl`, or declare the endpoint as GET if it really is a read.', + + 'take effect. Remove `cacheTtlSeconds`, or declare the endpoint as GET if it really is a read.', }; } diff --git a/packages/spec/src/api/endpoint.test.ts b/packages/spec/src/api/endpoint.test.ts index f4d2f28bcd..c954472f9e 100644 --- a/packages/spec/src/api/endpoint.test.ts +++ b/packages/spec/src/api/endpoint.test.ts @@ -201,13 +201,13 @@ describe('ApiEndpointSchema', () => { windowMs: 60000, maxRequests: 10, }, - cacheTtl: 300, + cacheTtlSeconds: 300, }); expect(endpoint.summary).toBe('Create a new order'); expect(endpoint.inputMapping).toHaveLength(2); expect(endpoint.rateLimit?.enabled).toBe(true); - expect(endpoint.cacheTtl).toBe(300); + expect(endpoint.cacheTtlSeconds).toBe(300); }); it('should accept different HTTP methods', () => { @@ -359,10 +359,10 @@ describe('ApiEndpointSchema', () => { method: 'GET', type: 'object_operation', target: 'data', - cacheTtl: 600, + cacheTtlSeconds: 600, }); - expect(endpoint.cacheTtl).toBe(600); + expect(endpoint.cacheTtlSeconds).toBe(600); }); it('should accept public endpoint (no auth required)', () => { @@ -535,7 +535,7 @@ describe('#5384 — ApiEndpointSchema REJECTS undeclared keys', () => { it.each([ // The three typos the file header names as what the strip used to cost. - ['cacheTTL', 'cacheTtl'], + ['cacheTTL', 'cacheTtlSeconds'], ['objectParam', 'objectParams'], ['outputMappings', 'outputMapping'], // The policy block, where a silent strip is worst. @@ -606,3 +606,47 @@ describe('#5227 — the author state is what `ApiEndpoint` denotes', () => { expect(parsed).toBeDefined(); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('ApiEndpoint.cacheTtl \u2192 cacheTtlSeconds (#15677, ADR-0087 `api-endpoint-cache-ttl-to-cache-ttl-seconds`)', () => { + const base = { + name: 'get_customers', + path: '/api/v1/customers', + method: 'GET' as const, + type: 'object_operation' as const, + }; + + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = ApiEndpointSchema.safeParse({ ...base, cacheTtl: 30 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`ApiEndpoint\.cacheTtl` was renamed to `cacheTtlSeconds`/); + }); + + it('closes with the house `os migrate meta` sentence — the surface IS covered by a conversion', () => { + const result = ApiEndpointSchema.safeParse({ ...base, cacheTtl: 30 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue!.message).toMatch(/os migrate meta --from 17/); + }); + + it('accepts `cacheTtlSeconds` at the same magnitude the retired key carried', () => { + const parsed = ApiEndpointSchema.parse({ ...base, cacheTtlSeconds: 30 }); + expect(parsed.cacheTtlSeconds).toBe(30); + expect(parsed).not.toHaveProperty('cacheTtl'); + }); + + it('tsc channel: `cacheTtl` is unwritable on the ApiEndpoint input type', () => { + // @ts-expect-error — `cacheTtl` is a tombstone (input type `never`); the key is `cacheTtlSeconds` + const bad: ApiEndpoint = { ...base, cacheTtl: 30 }; + expect(bad).toBeDefined(); + const good: ApiEndpoint = { ...base, cacheTtlSeconds: 30 }; + expect(good.cacheTtlSeconds).toBe(30); + }); +}); diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index 6e373025f3..ab1d46b74a 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -10,6 +10,7 @@ import { strictObject } from '../shared/strict-object'; * Transform input/output data. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const ApiMappingSchema = lazySchema(() => z.object({ source: z.string().describe('Source field/path'), target: z.string().describe('Target field/path'), @@ -106,7 +107,10 @@ export const ApiEndpointSchema = strictObject({ aliases: { // Policy block — the highest-consequence misses on this surface. auth: 'authRequired', authentication: 'authRequired', requiresAuth: 'authRequired', - cacheTTL: 'cacheTtl', ttl: 'cacheTtl', cache: 'cacheTtl', + // Retargeted onto `cacheTtlSeconds` by #15677: an alias must point at a key + // the schema really accepts, and `cacheTtl` is now a tombstone that accepts + // nothing (`check:alias-integrity` / alias-integrity.test.ts enforce this). + cacheTTL: 'cacheTtlSeconds', ttl: 'cacheTtlSeconds', cache: 'cacheTtlSeconds', rateLimiting: 'rateLimit', throttle: 'rateLimit', // Identity / routing. id: 'name', url: 'path', route: 'path', endpoint: 'path', uri: 'path', @@ -183,7 +187,21 @@ export const ApiEndpointSchema = strictObject({ /** Policies */ authRequired: z.boolean().default(true).describe('Require authentication'), rateLimit: RateLimitConfigSchema.optional().describe('Rate limiting policy'), - cacheTtl: z.number().optional().describe('Response cache TTL in seconds'), + // Renamed from `cacheTtl` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. `apis:` is a stack collection, so the rename is + // replayable — the protocol-18 D2 conversion `api-endpoint-cache-ttl-to- + // cache-ttl-seconds` rewrites stored sources and the tombstone below carries + // the prescription for anyone who jumps majors past it. + cacheTtlSeconds: z.number().optional().describe('Response cache TTL in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + cacheTtl: retiredKey( + '`ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is ' + + 'unchanged, and it stays GET-only. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `api` is a registered metadata kind as of #5271, so the artifact loader diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts index 0595c58c8c..60b6af343c 100644 --- a/packages/spec/src/api/errors.test.ts +++ b/packages/spec/src/api/errors.test.ts @@ -169,7 +169,7 @@ describe('EnhancedApiErrorSchema', () => { httpStatus: 429, retryable: true, retryStrategy: 'retry_after', - retryAfter: 60, + retryAfterSeconds: 60, details: { limit: 1000, remaining: 0, @@ -178,7 +178,7 @@ describe('EnhancedApiErrorSchema', () => { }); expect(error.retryable).toBe(true); - expect(error.retryAfter).toBe(60); + expect(error.retryAfterSeconds).toBe(60); expect(error.details.limit).toBe(1000); }); @@ -386,3 +386,37 @@ describe('EnhancedApiErrorSchema.fieldErrors retirement (ADR-0114 D4)', () => { expect(parsed.fields).toHaveLength(1); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('EnhancedApiError.retryAfter \u2192 retryAfterSeconds (#15677 \u2014 BREAKING on the ADR-0112 wire envelope)', () => { + const base = { code: 'RATE_LIMIT_EXCEEDED' as const, message: 'Rate limit exceeded' }; + + it('REFUSES the retired `retryAfter` spelling with the rename in the message', () => { + const result = EnhancedApiErrorSchema.safeParse({ ...base, retryAfter: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retryAfter'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`EnhancedApiError\.retryAfter` was renamed to `retryAfterSeconds`/, + ); + }); + + it('says in the prescription that the HTTP `Retry-After` header is a separate, unchanged surface', () => { + const result = EnhancedApiErrorSchema.safeParse({ ...base, retryAfter: 60 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retryAfter'); + // The next reader must not "fix" the RFC 9110 header to match the envelope. + expect(issue!.message).toMatch(/HTTP `Retry-After` response header \u2014 that header keeps its RFC 9110 name/); + }); + + it('accepts `retryAfterSeconds` at the same magnitude the retired key carried', () => { + const parsed = EnhancedApiErrorSchema.parse({ ...base, retryAfterSeconds: 60 }); + expect(parsed.retryAfterSeconds).toBe(60); + expect(parsed).not.toHaveProperty('retryAfter'); + }); +}); diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index dd9432190d..32af58595d 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -361,7 +361,7 @@ export type FieldError = z.input; * "httpStatus": 429, * "retryable": true, * "retryStrategy": "retry_after", - * "retryAfter": 60, + * "retryAfterSeconds": 60, * "details": { * "limit": 1000, * "remaining": 0, @@ -391,7 +391,29 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ httpStatus: z.number().optional().describe('HTTP status code'), retryable: z.boolean().default(false).describe('Whether the request can be retried'), retryStrategy: RetryStrategy.optional().describe('Recommended retry strategy'), - retryAfter: z.number().optional().describe('Seconds to wait before retrying'), + /** + * Renamed from `retryAfter` (#15677, #14478 ruling B). + * + * ⚠️ BREAKING on the ADR-0112 wire envelope. Ruling B put this key + * explicitly IN scope: it is read by humans and agents off the wire even + * though nobody authors it, and `retryAfter` bare next to a `Retry-After` + * header that may carry either a delta-seconds OR an HTTP-date is the exact + * ambiguity the rule exists to remove. + * + * The HTTP `Retry-After` RESPONSE HEADER is a separate, UNCHANGED surface + * (RFC 9110 §10.2.3) — its name is fixed outside this repo and no part of + * this rename touches it. Do not "fix" the header to match. + */ + retryAfterSeconds: z.number().optional().describe('Seconds to wait before retrying'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + retryAfter: retiredKey( + '`EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is ' + + 'unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response ' + + 'header — that header keeps its RFC 9110 name and is untouched.', + ), details: z.unknown().optional().describe('Additional error context'), /** * One entry per offending value. diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index b882fb6ea9..8ec12281ae 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -64,7 +64,7 @@ describe('plugin-rest-api.zod', () => { tags: ['Data', 'CRUD'], requestSchema: 'CreateRequestSchema', responseSchema: 'SingleRecordResponseSchema', - timeout: 30000, + timeoutMs: 30000, rateLimit: 'standard', cacheable: false, }); @@ -72,7 +72,7 @@ describe('plugin-rest-api.zod', () => { expect(endpoint.permissions).toEqual(['data.create']); expect(endpoint.summary).toBe('Create a record'); expect(endpoint.tags).toEqual(['Data', 'CRUD']); - expect(endpoint.timeout).toBe(30000); + expect(endpoint.timeoutMs).toBe(30000); }); it('should default public to false', () => { @@ -443,7 +443,7 @@ describe('plugin-rest-api.zod', () => { enableCompression: true, enableETag: true, enableCaching: true, - defaultCacheTtl: 600, + defaultCacheTtlSeconds: 600, }, }); @@ -452,7 +452,7 @@ describe('plugin-rest-api.zod', () => { expect(config.validation?.mode).toBe('strict'); expect(config.openApi?.title).toBe('My API'); expect(config.cors?.origins).toContain('http://localhost:3000'); - expect(config.performance?.defaultCacheTtl).toBe(600); + expect(config.performance?.defaultCacheTtlSeconds).toBe(600); }); }); @@ -520,7 +520,7 @@ describe('plugin-rest-api.zod', () => { // Verify batch endpoints have longer timeouts DEFAULT_BATCH_ROUTES.endpoints?.forEach(endpoint => { - expect(endpoint.timeout).toBe(60000); + expect(endpoint.timeoutMs).toBe(60000); }); }); @@ -578,7 +578,7 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_ANALYTICS_ROUTES.endpoints).toHaveLength(2); // Analytics query should have extended timeout const queryEndpoint = DEFAULT_ANALYTICS_ROUTES.endpoints?.find(e => e.handler === 'analyticsQuery'); - expect(queryEndpoint?.timeout).toBe(120000); + expect(queryEndpoint?.timeoutMs).toBe(120000); }); it('should validate DEFAULT_AUTOMATION_ROUTES', () => { @@ -591,7 +591,7 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_AUTOMATION_ROUTES.endpoints).toHaveLength(2); expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].path).toBe('/trigger/:name'); // Automation trigger should have extended timeout - expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].timeout).toBe(120000); + expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].timeoutMs).toBe(120000); // The actions endpoint exposes the live registry and is cacheable. const actionsEndpoint = DEFAULT_AUTOMATION_ROUTES.endpoints?.find(e => e.path === '/actions'); expect(actionsEndpoint?.method).toBe('GET'); @@ -684,3 +684,78 @@ describe('plugin-rest-api.zod', () => { }); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('RestApiEndpoint / RestApiPluginConfig durations carry their unit (#15677)', () => { + const endpoint = { + method: 'GET' as const, path: '/api/v1/discovery', + handler: 'getDiscovery', category: 'discovery' as const, + }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = RestApiEndpointSchema.safeParse({ ...endpoint, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RestApiEndpoint\.timeout` was renamed to `timeoutMs`/); + }); + + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = RestApiEndpointSchema.safeParse({ ...endpoint, cacheTtl: 3600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RestApiEndpoint\.cacheTtl` was renamed to `cacheTtlSeconds`/); + }); + + it('names the OTHER unit in each prescription — the pair is why this rename exists', () => { + const t = RestApiEndpointSchema.safeParse({ ...endpoint, timeout: 1 }) + .error!.issues.find((i) => i.path.join('.') === 'timeout')!; + const c = RestApiEndpointSchema.safeParse({ ...endpoint, cacheTtl: 1 }) + .error!.issues.find((i) => i.path.join('.') === 'cacheTtl')!; + expect(t.message).toMatch(/cache TTL two lines below is in SECONDS/); + expect(c.message).toMatch(/request timeout two lines above is in MILLISECONDS/); + }); + + it('accepts the suffixed endpoint keys at the same magnitudes', () => { + const parsed = RestApiEndpointSchema.parse({ ...endpoint, timeoutMs: 30000, cacheTtlSeconds: 3600 }); + expect(parsed.timeoutMs).toBe(30000); + expect(parsed.cacheTtlSeconds).toBe(3600); + expect(parsed).not.toHaveProperty('timeout'); + expect(parsed).not.toHaveProperty('cacheTtl'); + }); + + it('REFUSES `performance.defaultCacheTtl` — a tombstone inside a LIVE block', () => { + const result = RestApiPluginConfigSchema.safeParse({ + routes: [], + performance: { enableCompression: true, defaultCacheTtl: 600 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'performance.defaultCacheTtl', + ); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`RestApiPluginConfig\.performance\.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds`/, + ); + }); + + it('parses the live siblings of that tombstone, and keeps the 300 default', () => { + const config = RestApiPluginConfigSchema.parse({ + routes: [], + performance: { enableCompression: false, defaultCacheTtlSeconds: 600 }, + }); + expect(config.performance?.enableCompression).toBe(false); + expect(config.performance?.defaultCacheTtlSeconds).toBe(600); + expect(RestApiPluginConfigSchema.parse({ routes: [], performance: {} }) + .performance?.defaultCacheTtlSeconds).toBe(300); + }); +}); diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index b7f5c5e018..a6f98505b3 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -199,10 +199,28 @@ export const RestApiEndpointSchema = lazySchema(() => z.object({ /** * Performance and reliability settings */ - timeout: z.number().int().optional().describe('Request timeout in milliseconds'), + // Renamed from `timeout` / `cacheTtl` (#15677, #14478 ruling B): both units + // lived only in the describe prose, and the two sat two lines apart in + // DIFFERENT units — milliseconds and seconds — which is the confusion the + // rule exists to remove. + timeoutMs: z.number().int().optional().describe('Request timeout in milliseconds'), rateLimit: z.string().optional().describe('Rate limit policy name'), cacheable: z.boolean().default(false).describe('Whether response can be cached'), - cacheTtl: z.number().int().optional().describe('Cache TTL in seconds'), + cacheTtlSeconds: z.number().int().optional().describe('Cache TTL in seconds'), + + /** Tombstones for the two renames above (#15677, ruling B on #14478). */ + timeout: retiredKey( + '`RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. ' + + 'Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), + cacheTtl: retiredKey( + '`RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose, and the neighbouring request timeout two lines above is in ' + + 'MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', + ), /** * RETIRED (#13823, ADR-0049): `handlerStatus` (`implemented` / `stub` / @@ -714,7 +732,16 @@ export const RestApiPluginConfigSchema = z.object({ enableCompression: z.boolean().default(true).describe('Enable response compression'), enableETag: z.boolean().default(true).describe('Enable ETag generation'), enableCaching: z.boolean().default(true).describe('Enable HTTP caching'), - defaultCacheTtl: z.number().int().default(300).describe('Default cache TTL in seconds'), + // Renamed from `defaultCacheTtl` (#15677, #14478 ruling B). + defaultCacheTtlSeconds: z.number().int().default(300).describe('Default cache TTL in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + defaultCacheTtl: retiredKey( + '`RestApiPluginConfig.performance.defaultCacheTtl` was renamed to ' + + '`defaultCacheTtlSeconds` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename ' + + 'the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged.', + ), }).optional().describe('Performance optimization settings'), }); @@ -747,7 +774,7 @@ export const DEFAULT_DISCOVERY_ROUTES: RestApiRouteRegistration = { tags: ['Discovery'], responseSchema: 'GetDiscoveryResponseSchema', cacheable: true, - cacheTtl: 3600, // Cache for 1 hour as discovery info rarely changes + cacheTtlSeconds: 3600, // Cache for 1 hour as discovery info rarely changes }], middleware: [ { name: 'response_envelope', type: 'transformation', enabled: true, order: 100 }, @@ -782,7 +809,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { tags: ['Metadata'], responseSchema: 'GetMetaTypesResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -795,7 +822,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { tags: ['Metadata'], responseSchema: 'GetMetaItemsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -813,7 +840,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { // performs or could perform. responseSchema: 'GetMetaItemResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -1037,7 +1064,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'BatchUpdateRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.batch'], - timeout: 60000, // 60 seconds for batch operations + timeoutMs: 60000, // 60 seconds for batch operations cacheable: false, }, { @@ -1059,7 +1086,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'CreateManyDataRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.create', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, { @@ -1074,7 +1101,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'UpdateManyRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.update', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, { @@ -1089,7 +1116,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'DeleteManyRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.delete', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, ], @@ -1233,7 +1260,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetLocalesResponseSchema', cacheable: true, - cacheTtl: 86400, // 24 hours — locales change very rarely + cacheTtlSeconds: 86400, // 24 hours — locales change very rarely }, { method: 'GET', @@ -1246,7 +1273,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetTranslationsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -1259,7 +1286,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetFieldLabelsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, ], middleware: [ @@ -1295,7 +1322,7 @@ export const DEFAULT_ANALYTICS_ROUTES: RestApiRouteRegistration = { requestSchema: 'AnalyticsQueryRequestSchema', responseSchema: 'AnalyticsResultResponseSchema', permissions: ['analytics.query'], - timeout: 120000, // 2 minutes for analytics queries + timeoutMs: 120000, // 2 minutes for analytics queries cacheable: false, }, { @@ -1309,7 +1336,7 @@ export const DEFAULT_ANALYTICS_ROUTES: RestApiRouteRegistration = { tags: ['Analytics'], responseSchema: 'AnalyticsMetadataResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, ], middleware: [ @@ -1359,7 +1386,7 @@ export const DEFAULT_AUTOMATION_ROUTES: RestApiRouteRegistration = { // protocol-method contract only. responseSchema: 'AutomationTriggerResponseSchema', permissions: ['automation.trigger'], - timeout: 120000, // 2 minutes for long-running automations + timeoutMs: 120000, // 2 minutes for long-running automations cacheable: false, }, { diff --git a/packages/spec/src/api/router.test.ts b/packages/spec/src/api/router.test.ts index 447cc5ddf0..6235a4787e 100644 --- a/packages/spec/src/api/router.test.ts +++ b/packages/spec/src/api/router.test.ts @@ -198,10 +198,10 @@ describe('RouteDefinitionSchema', () => { method: 'POST', path: '/api/batch', handler: 'batch_process', - timeout: 30000, + timeoutMs: 30000, }); - expect(route.timeout).toBe(30000); + expect(route.timeoutMs).toBe(30000); }); it('should accept route with rate limit', () => { @@ -220,11 +220,11 @@ describe('RouteDefinitionSchema', () => { method: 'POST', path: '/api/heavy-operation', handler: 'heavy_handler', - timeout: 60000, + timeoutMs: 60000, rateLimit: 'moderate', }); - expect(route.timeout).toBe(60000); + expect(route.timeoutMs).toBe(60000); expect(route.rateLimit).toBe('moderate'); }); }); @@ -253,7 +253,7 @@ describe('RouteDefinitionSchema', () => { description: 'Creates a new order in the system', public: false, permissions: ['orders.create'], - timeout: 5000, + timeoutMs: 5000, }; expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); @@ -555,3 +555,28 @@ describe('Integration Tests', () => { }); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('RouteDefinition.timeout \u2192 timeoutMs (#15677)', () => { + const base = { method: 'GET' as const, path: '/api/test', handler: 'test_handler' }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = RouteDefinitionSchema.safeParse({ ...base, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RouteDefinition\.timeout` was renamed to `timeoutMs`/); + }); + + it('accepts `timeoutMs` at the same magnitude the retired key carried', () => { + const parsed = RouteDefinitionSchema.parse({ ...base, timeoutMs: 30000 }); + expect(parsed.timeoutMs).toBe(30000); + expect(parsed).not.toHaveProperty('timeout'); + }); +}); diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 757bd96bd5..396425112d 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -5,6 +5,7 @@ import { CorsConfigSchema, StaticMountSchema, HttpMethod } from '../shared/http. // Re-export HttpMethod for convenience import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export { HttpMethod }; /** @@ -95,7 +96,16 @@ export const RouteDefinitionSchema = lazySchema(() => z.object({ /** * Performance hints */ - timeout: z.number().int().optional().describe('Execution timeout in ms'), + // Renamed from `timeout` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. + timeoutMs: z.number().int().optional().describe('Execution timeout in ms'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + timeout: retiredKey( + '`RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), rateLimit: z.string().optional().describe('Rate limit policy name'), })); diff --git a/packages/spec/src/api/websocket.test.ts b/packages/spec/src/api/websocket.test.ts index b1ce26b9d4..230275f233 100644 --- a/packages/spec/src/api/websocket.test.ts +++ b/packages/spec/src/api/websocket.test.ts @@ -22,6 +22,7 @@ import { PongMessageSchema, WebSocketMessageSchema, WebSocketConfigSchema, + WebSocketServerConfigSchema, type EventSubscription, type PresenceState, type CursorPosition, @@ -650,10 +651,10 @@ describe('WebSocketConfigSchema', () => { url: 'wss://example.com/ws', protocols: ['objectstack-v1', 'json'], reconnect: true, - reconnectInterval: 2000, + reconnectIntervalMs: 2000, maxReconnectAttempts: 10, - pingInterval: 60000, - timeout: 10000, + pingIntervalMs: 60000, + timeoutMs: 10000, headers: { 'Authorization': 'Bearer token123', 'X-Custom-Header': 'value', @@ -662,7 +663,7 @@ describe('WebSocketConfigSchema', () => { const parsed = WebSocketConfigSchema.parse(config); expect(parsed.reconnect).toBe(true); - expect(parsed.reconnectInterval).toBe(2000); + expect(parsed.reconnectIntervalMs).toBe(2000); expect(parsed.maxReconnectAttempts).toBe(10); }); @@ -673,10 +674,10 @@ describe('WebSocketConfigSchema', () => { const parsed = WebSocketConfigSchema.parse(config); expect(parsed.reconnect).toBe(true); - expect(parsed.reconnectInterval).toBe(1000); + expect(parsed.reconnectIntervalMs).toBe(1000); expect(parsed.maxReconnectAttempts).toBe(5); - expect(parsed.pingInterval).toBe(30000); - expect(parsed.timeout).toBe(5000); + expect(parsed.pingIntervalMs).toBe(30000); + expect(parsed.timeoutMs).toBe(5000); }); it('should validate URL format', () => { @@ -692,12 +693,71 @@ describe('WebSocketConfigSchema', () => { it('should reject negative intervals', () => { expect(() => WebSocketConfigSchema.parse({ url: 'wss://example.com/ws', - reconnectInterval: -1000, + reconnectIntervalMs: -1000, })).toThrow(); expect(() => WebSocketConfigSchema.parse({ url: 'wss://example.com/ws', - pingInterval: 0, + pingIntervalMs: 0, })).toThrow(); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('WebSocket durations carry their unit (#15677)', () => { + const url = 'wss://example.com/ws'; + + it.each([ + ['reconnectInterval', 'reconnectIntervalMs', 2000], + ['pingInterval', 'pingIntervalMs', 60000], + ['timeout', 'timeoutMs', 10000], + ])('REFUSES the retired `%s` with the rename to `%s` in the message', (old, next, value) => { + const result = WebSocketConfigSchema.safeParse({ url, [old]: value }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === old); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`WebSocketConfig.${old}\` was renamed to \`${next}\``); + }); + + it('accepts the three suffixed client keys at the same magnitudes', () => { + const parsed = WebSocketConfigSchema.parse({ + url, reconnectIntervalMs: 2000, pingIntervalMs: 60000, timeoutMs: 10000, + }); + expect(parsed.reconnectIntervalMs).toBe(2000); + expect(parsed.pingIntervalMs).toBe(60000); + expect(parsed.timeoutMs).toBe(10000); + expect(parsed).not.toHaveProperty('reconnectInterval'); + }); + + it('keeps the positive-integer bound on the renamed keys', () => { + expect(WebSocketConfigSchema.safeParse({ url, reconnectIntervalMs: -1000 }).success).toBe(false); + expect(WebSocketConfigSchema.safeParse({ url, pingIntervalMs: 0 }).success).toBe(false); + }); + + it('REFUSES `WebSocketServerConfig.heartbeatInterval` with the rename in the message', () => { + const result = WebSocketServerConfigSchema.safeParse({ heartbeatInterval: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'heartbeatInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`WebSocketServerConfig\.heartbeatInterval` was renamed to `heartbeatIntervalMs`/, + ); + }); + + it('accepts `heartbeatIntervalMs` and keeps the 30000 default; the COUNT neighbour is untouched', () => { + const parsed = WebSocketServerConfigSchema.parse({ heartbeatIntervalMs: 15000 }); + expect(parsed.heartbeatIntervalMs).toBe(15000); + expect(parsed).not.toHaveProperty('heartbeatInterval'); + const defaults = WebSocketServerConfigSchema.parse({}); + expect(defaults.heartbeatIntervalMs).toBe(30000); + // `reconnectAttempts` is a COUNT, not a duration — it keeps its bare name. + expect(defaults.reconnectAttempts).toBe(5); + }); +}); diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index d962ab0c06..ef6005d206 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -420,10 +420,34 @@ export const WebSocketConfigSchema = lazySchema(() => z.object({ url: z.string().url().describe('WebSocket server URL'), protocols: z.array(z.string()).optional().describe('WebSocket sub-protocols'), reconnect: z.boolean().optional().default(true).describe('Enable automatic reconnection'), - reconnectInterval: z.number().int().positive().optional().default(1000).describe('Reconnection interval in milliseconds'), + // Renamed from `reconnectInterval` / `pingInterval` / `timeout` (#15677, + // #14478 ruling B): three durations on one shape whose unit lived only in + // the describe prose, beside a `maxReconnectAttempts` that is a COUNT — the + // adjacency the rule exists to disambiguate. + reconnectIntervalMs: z.number().int().positive().optional().default(1000).describe('Reconnection interval in milliseconds'), maxReconnectAttempts: z.number().int().positive().optional().default(5).describe('Maximum reconnection attempts'), - pingInterval: z.number().int().positive().optional().default(30000).describe('Ping interval in milliseconds'), - timeout: z.number().int().positive().optional().default(5000).describe('Message timeout in milliseconds'), + pingIntervalMs: z.number().int().positive().optional().default(30000).describe('Ping interval in milliseconds'), + timeoutMs: z.number().int().positive().optional().default(5000).describe('Message timeout in milliseconds'), + + /** Tombstones for the three renames above (#15677, ruling B on #14478). */ + reconnectInterval: retiredKey( + '`WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; ' + + 'the value (milliseconds) is unchanged.', + ), + pingInterval: retiredKey( + '`WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is ' + + 'unchanged.', + ), + timeout: retiredKey( + '`WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is ' + + 'unchanged.', + ), headers: z.record(z.string(), z.string()).optional().describe('Custom headers for WebSocket handshake'), })); @@ -575,7 +599,7 @@ export type SimpleCursorPosition = z.input; * { * enabled: true, * path: '/ws', - * heartbeatInterval: 30000, + * heartbeatIntervalMs: 30000, * reconnectAttempts: 5, * presence: true, * cursorSharing: true @@ -585,10 +609,20 @@ export type SimpleCursorPosition = z.input; export const WebSocketServerConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable WebSocket server'), path: z.string().default('/ws').describe('WebSocket endpoint path'), - heartbeatInterval: z.number().default(30000).describe('Heartbeat interval in milliseconds'), + // Renamed from `heartbeatInterval` (#15677, #14478 ruling B): its unit lived + // only in the describe prose, beside a `reconnectAttempts` that is a COUNT. + heartbeatIntervalMs: z.number().default(30000).describe('Heartbeat interval in milliseconds'), reconnectAttempts: z.number().default(5).describe('Maximum reconnection attempts for clients'), presence: z.boolean().default(false).describe('Enable presence tracking'), cursorSharing: z.boolean().default(false).describe('Enable collaborative cursor sharing'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + heartbeatInterval: retiredKey( + '`WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; ' + + 'the value (milliseconds) is unchanged.', + ), })); export type WebSocketServerConfig = z.input; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3984265710..5dad0b918c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8622,6 +8622,67 @@ const jobTimeoutToTimeoutMs: MetadataConversion = { }, }; +/** + * `apis[].cacheTtl` → `apis[].cacheTtlSeconds` (protocol 18, #15677 for #14478) + * — the `api` half of the same rename `hookTimeoutToTimeoutMs` and + * `jobTimeoutToTimeoutMs` document, and the ONE key of that card's twelve that + * gets a conversion rather than a semantic entry: `apis:` is a stack collection + * (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered metadata kind + * stored as a row, so the chain has a seam that sees it. The other eleven are + * wire payloads and construction arguments the chain never touches. + * + * Same posture as its two siblings: retired from the load path, tombstoned at + * the schema, replayable here. The fixture keeps `rateLimit` out of the + * converted endpoint on purpose — it is the one neighbouring policy key whose + * own shape is still in a live window — and carries a second endpoint that + * never authored `cacheTtl` so copy-on-write identity is pinned too. + */ +const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { + id: 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'apis[].cacheTtl', + summary: "api endpoint key 'cacheTtl' \u2192 'cacheTtlSeconds' (#14478 \u2014 the unit lived only in the description; the value, seconds, is unchanged, and the key stays GET-only)", + apply(stack, emit) { + return mapCollection(stack, 'apis', (endpoint, path) => { + const renamed = renameKey(endpoint, 'cacheTtl', 'cacheTtlSeconds'); + if (!renamed) return endpoint; + emit({ from: 'cacheTtl', to: 'cacheTtlSeconds', path: `${path}.cacheTtlSeconds` }); + return renamed; + }); + }, + fixture: { + before: { + apis: [ + { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'task', operation: 'find' }, + cacheTtl: 30, + }, + // An endpoint that never authored the key keeps its identity (copy-on-write). + { name: 'create_task', path: '/api/v1/apps/showcase/tasks', method: 'POST', type: 'object_operation' }, + ], + }, + after: { + apis: [ + { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'task', operation: 'find' }, + cacheTtlSeconds: 30, + }, + { name: 'create_task', path: '/api/v1/apps/showcase/tasks', method: 'POST', type: 'object_operation' }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8713,6 +8774,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly ], }; @@ -9043,6 +9202,22 @@ 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. // + // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, and no + // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response + // cache TTL in seconds" in prose and nothing else, on the same authorable + // surface where `rateLimit.windowMs` spells its unit. Renamed to + // `cacheTtlSeconds`; the value is unchanged and the key stays GET-only. + // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare + // deletion would strip the old key in silence, and the unknown-key error could + // not carry the rename. This is the ONE key of this card's twelve that gets a + // D2 CONVERSION rather than a semantic entry: `apis:` is a stack collection + // (`stack.zod.ts` — `apis: z.array(ApiEndpointSchema)`) and an `api` is a + // registered metadata kind stored as a row, so the conversion chain has a seam + // that sees it. `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, + // retired from the load path (no alias window). Registered under 18 for the + // launch-window reason its neighbours state. + 'api/ApiEndpoint: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` @@ -9135,6 +9310,42 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // 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', + // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. + // `DataLoaderConfig.cacheTtl` named seconds only in its describe. Renamed to + // `cacheTtlSeconds`; the value is unchanged. Tombstoned with `retiredKey()` + // because the shape is not `.strict()`. No D2 conversion: a `DataLoaderConfig` + // is a per-request batch-loader construction argument, never a stack collection + // member or a stored row, so the chain has no seam (the `kernel/Manifest:loading` + // precedent); the semantic entry `api-runtime-config-durations-unit-in-key` + // carries the prescription. + 'api/DataLoaderConfig:cacheTtl', + // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. + // `DeviceRequestResponse.interval` named seconds only in its describe. Renamed + // to `intervalSeconds`; the value is unchanged. Checked against ruling B's + // SECOND exemption before renaming — a key mirroring a name fixed outside this + // repo carries `.meta({ externalVocabulary })` — and it does not qualify: + // `DeviceRequestResponseSchema` does not mirror RFC 8628 as a set (`code` is + // not `device_code`, `verificationUrl` is not `verification_uri`, `expiresAt` + // is not `expires_in` and holds an ISO-8601 string where the RFC has a relative + // lifetime), so a schema that already renames every RFC field it carries cannot + // claim the standard fixes this one. Tombstoned with `retiredKey()`. No D2 + // conversion: this is a RUNTIME-EMITTED device-flow response body, never a + // stored row; the semantic entry `device-request-response-interval-unit-in-key` + // carries the prescription. + 'api/DeviceRequestResponse:interval', + // #15677 (stack card 2/6 of #14478) — ruling B, which put this key explicitly + // IN scope with its own BREAKING note: the ~16 runtime-emitted measurements are + // read by humans and agents even if nobody authors them, `ApiError.retryAfter` + // on the wire envelope included. `retryAfter` bare, beside an HTTP `Retry-After` + // header that may carry EITHER delta-seconds OR an HTTP-date, is precisely the + // ambiguity the rule removes. Renamed to `retryAfterSeconds`; the value is + // unchanged. ⚠️ The HTTP `Retry-After` RESPONSE HEADER is a SEPARATE, UNCHANGED + // surface — its name is fixed by RFC 9110 §10.2.3 and nothing here touches it. + // Tombstoned with `retiredKey()`. No D2 conversion: an ADR-0112 error envelope + // is emitted on the wire, never stored as a metadata row, so the chain has no + // seam; the semantic entry `api-error-retry-after-unit-in-key` carries the + // prescription. + 'api/EnhancedApiError:retryAfter', // #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` @@ -9179,6 +9390,12 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // A nested key of an inline block, so it has no line of its own in // `authorable-surface/` (the `kernel/Manifest:contributes.routes` shape). 'api/MetadataEndpointsConfig:endpoints.schema', + // #15677 (stack card 2/6 of #14478) — ruling B; the seconds half of the pair + // documented on `api/RestApiEndpoint:timeout`. Renamed to `cacheTtlSeconds`; + // the value is unchanged. Tombstoned with `retiredKey()`; disposition and + // reasoning are that entry's, and the prescription travels in the semantic + // entry `rest-api-plugin-durations-unit-in-key`. + 'api/RestApiEndpoint:cacheTtl', // #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 @@ -9215,6 +9432,36 @@ 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', + // #15677 (stack card 2/6 of #14478) — ruling B. `RestApiEndpoint.timeout` + // (milliseconds) sat THREE LINES above `cacheTtl` (seconds), each unit named + // only in its describe: one shape, two units, no way to tell them apart at the + // authoring site. Renamed to `timeoutMs`; the value is unchanged. Tombstoned + // with `retiredKey()` on this non-strict shape, beside the `handlerStatus` + // tombstone already there. No D2 conversion: a `RestApiEndpoint` is REST-plugin + // route-registration configuration, never a stack collection member (the + // `rest-api-endpoint-handler-status-retired` precedent on this very shape); the + // semantic entry `rest-api-plugin-durations-unit-in-key` carries the + // prescription. + 'api/RestApiEndpoint:timeout', + // #15677 (stack card 2/6 of #14478) — ruling B. The plugin-wide default behind + // the per-endpoint `cacheTtl` this card also renames; leaving it bare would have + // left the DEFAULT spelled one way and the OVERRIDE another. Renamed to + // `defaultCacheTtlSeconds`; the value is unchanged. Tombstoned with + // `retiredKey()` inside the live `performance` block — a tombstone whose + // siblings must keep parsing. No D2 conversion: `RestApiPluginConfig` is the + // REST plugin's construction argument, never a stored row; the semantic entry + // `rest-api-plugin-durations-unit-in-key` carries the prescription. + 'api/RestApiPluginConfig:performance.defaultCacheTtl', + // #15677 (stack card 2/6 of #14478) — ruling B. `RouteDefinition.timeout` said + // "Execution timeout in ms" in prose and nothing else. Renamed to `timeoutMs`; + // the value is unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a + // `RouteDefinition` is a router registration a host or plugin builds in code, + // never a stack collection member or a stored row; the semantic entry + // `api-runtime-config-durations-unit-in-key` carries the prescription. Note for + // anyone grepping: `packages/runtime/src/dispatcher-plugin.ts` declares its OWN + // local `RouteDefinition` interface for the `ai:routes` hook payload — a + // different type, with no duration key at all, and untouched by this rename. + 'api/RouteDefinition:timeout', // #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` @@ -9344,6 +9591,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry // records: a presence payload is runtime-emitted, never a stored metadata row. 'api/SimplePresenceState:lastSeen', + // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two + // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to + // `pingIntervalMs`; the value is unchanged. Semantic entry + // `websocket-durations-unit-in-key`. + 'api/WebSocketConfig:pingInterval', + // #15677 (stack card 2/6 of #14478) — ruling B. Three durations on + // `WebSocketConfig` named their unit only in prose, interleaved with a + // `maxReconnectAttempts` that is a COUNT — so `reconnectInterval: 5` beside + // `maxReconnectAttempts: 5` read as one kind of number and was two. Renamed to + // `reconnectIntervalMs`; the value is unchanged. Tombstoned with `retiredKey()`. + // No D2 conversion: a `WebSocketConfig` is a client connection argument, never a + // stored row; the semantic entry `websocket-durations-unit-in-key` carries the + // prescription for all four of this shape's renames. + 'api/WebSocketConfig:reconnectInterval', + // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two + // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to `timeoutMs`; + // the value is unchanged. Semantic entry `websocket-durations-unit-in-key`. + 'api/WebSocketConfig:timeout', // #15676 — the epoch-instant half of #14478 ruling B. `WebSocketEvent.timestamp` // is an epoch INSTANT, not a duration: it moved onto the shared `EpochMs` schema // (which declares the millisecond unit) and was renamed `occurredAt`, because @@ -9362,6 +9627,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // v17.0.0 was cut before this landed, so the change ships on the 17.x line and // the prescription lives at the major boundary `migrate meta` users look at. 'api/WebSocketEvent:timestamp', + // #15677 (stack card 2/6 of #14478) — ruling B. The server-side counterpart of + // the `WebSocketConfig` trio, with the same COUNT neighbour problem + // (`reconnectAttempts`). Renamed to `heartbeatIntervalMs`; the value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: server + // construction configuration, never a stored row; the semantic entry + // `websocket-durations-unit-in-key` carries the prescription. + 'api/WebSocketServerConfig:heartbeatInterval', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index baa50df4f7..9285076a38 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -638,6 +638,36 @@ const CALL_SITE_FILE_RE = /(?:^|\/)(?:[\w.-]*route[\w.-]*|[\w.-]*-server)\.ts$/; /** Route LEDGERS — the declared `route` ⟷ `client` tables the `sdk` anchor rides on. */ const LEDGER_FILE_RE = /(?:^|\/)[\w.-]*route-ledger\.ts$/; +/** + * The ADR-0087 CONVERSION CHAIN — replay data, never a route surface (#15677). + * + * Every conversion carries a `fixture: { before, after }` pair: literal stack documents the + * chain replays in `migrations.test.ts`. When the converted collection is a ROUTABLE + * metadata kind those documents are faithful copies of that kind — an `apis:` member is an + * `ApiEndpoint`, which really does declare a `method:` beside a `path:`, because that is + * what the kind IS. So the fixture reads to kind (b) exactly like the contract declaration + * it is a copy of, while serving nothing: the tail is a document's content, not a route + * this repo answers on. + * + * ⛔ THIS EXCLUSION IS NOW DECLARED, AND IT WAS NOT BEFORE. Ruling A (2026-09-04, batch + * #31) named `conversions/registry.ts` as the guard's target and excluded it with a proxy + * — `requireMethodSignal`, "is there an HTTP verb next to the path" — which held only + * because no conversion fixture had yet carried one. #15677's `apis:` conversion is the + * first that does, and the live pin below reds rather than silently minting a phantom + * route source, which is precisely what that pin exists for. The proxy was never wrong + * about the INTENT; it was a content test standing in for a structural fact, and the + * fixture that defeats it is a correct fixture. Naming the directory states the fact + * directly, so the next conversion over a routable kind costs nothing. + * + * Deliberately NOT the other available fix — restricting kind (b) to + * `packages/spec/src/api/**`. That is the invariant the live pin "every contract + * declaration admitted is a packages/spec API declaration" ASSERTS, and enforcing it in + * the walk would make that pin true by construction: a check that cannot fail, over the + * one population this route is most likely to widen by accident. The pin is worth more + * than the tidier rule. + */ +const CONVERSION_REPLAY_FILE_RE = /(?:^|\/)packages\/spec\/src\/conversions\//; + /** * THE selection rule of the `sdk` bridge, defined once: a registrar tail selects a ledger * row when the row's wire path ends with it (the `GET ` prefix is stripped first, which @@ -2157,9 +2187,13 @@ function scanRouteSurface() { // be present in the raw text for any tail to exist. // // ⛔ A LEDGER IS NOT A ROUTE SOURCE and a call site is not counted twice — both are - // skipped here, so `routeSources` partitions cleanly by kind. + // skipped here, so `routeSources` partitions cleanly by kind. ⛔ NEITHER IS REPLAY DATA: + // a conversion fixture copies a routable metadata kind verbatim, verb and all, so it is + // excluded structurally rather than left to the method-signal proxy (#15677 — see + // `CONVERSION_REPLAY_FILE_RE`). for (const rel of sourceFiles) { if (LEDGER_FILE_RE.test(rel) || CALL_SITE_FILE_RE.test(rel)) continue; + if (CONVERSION_REPLAY_FILE_RE.test(rel)) continue; let text; try { text = readFileSync(join(repoRoot, rel), 'utf8'); } catch { continue; } if (!text.includes('path')) continue; @@ -5614,6 +5648,16 @@ function selfTest() { const liveKind = (k) => live.routeSources.filter((r) => r.kind === k).map((r) => r.file); // (1) The guard's target, on the real file rather than a reduced fixture. + // + // ⭐ WHICH guard, updated #15677. This case was written when the exclusion rode on + // `requireMethodSignal` alone — a CONTENT proxy for a STRUCTURAL fact, sound only + // while no conversion fixture carried an HTTP verb. #15677's `apis:` conversion is + // the first that does (an `ApiEndpoint` fixture declares `method:` beside `path:`, + // because that is what the kind is), and this pin RED — doing exactly its job, ahead + // of a phantom route source reaching the census. The fixture is correct and stays; + // the exclusion moved to `CONVERSION_REPLAY_FILE_RE`, which states the fact instead + // of testing a symptom. So this pin now reads: the file is out because replay data is + // declared not to be a route surface, not because its contents happen to lack a verb. check('scanRouteSurface', 'the connector-action input is NOT admitted as a route source', 'packages/spec/src/conversions/registry.ts', false, live.routeSources.some((r) => r.file === 'packages/spec/src/conversions/registry.ts')); @@ -5624,6 +5668,22 @@ function selfTest() { // that would admit it, and the guard is the only thing that does not. check('scanRouteSurface', 'counterfactual: unguarded, that real file WOULD be admitted — the guard is what excludes it', 'registry.ts tails, unguarded', true, registryText === null || parseRouteSource(registryText).size > 0); + // ⭐ AND THE NEW GUARD IS THE LOAD-BEARING ONE, pinned rather than assumed (#15677). + // The counterfactual above survives on the OLD proxy too, so on its own it would keep + // passing if the directory guard were deleted. This case is the one that would not: + // the real file's tails survive `requireMethodSignal`, so the method proxy no longer + // excludes it and `CONVERSION_REPLAY_FILE_RE` is the only thing that does. The day + // someone deletes that guard as "redundant", this reds. + check('scanRouteSurface', 'and the METHOD proxy alone no longer excludes it — the directory guard is load-bearing', + 'registry.ts tails, method-guarded', true, + registryText === null || parseRouteSource(registryText, { requireMethodSignal: true }).size > 0); + check('CONVERSION_REPLAY_FILE_RE', 'which is what the directory guard matches', + 'packages/spec/src/conversions/registry.ts', true, + CONVERSION_REPLAY_FILE_RE.test('packages/spec/src/conversions/registry.ts')); + // …and it is NARROW: a sibling spec directory is untouched by it. + check('CONVERSION_REPLAY_FILE_RE', 'and it does not reach the api declarations kind (b) exists to admit', + 'packages/spec/src/api/storage.zod.ts', false, + CONVERSION_REPLAY_FILE_RE.test('packages/spec/src/api/storage.zod.ts')); // (2) THE CONTRACT KIND ADMITS SOMETHING — the anti-vacuity floor, and the case that // names kind (b) when it stops running. ⚠️ Without it the `every()` below passes on an diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index 99267f6004..c462cdf3f8 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -129,7 +129,7 @@ export const leadFeed: ApiEndpoint = { type: 'object_operation', objectParams: { object: 'acme_lead', operation: 'find' }, // `authRequired` omitted → defaults to `true`. Omission is SAFE. - cacheTtl: 30, // seconds; GET-only; success answers only + cacheTtlSeconds: 30, // GET-only; rides success answers only }; ```