Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .changeset/api-duration-keys-unit-in-key-name.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered api-endpoint-cache-ttl-to-cache-ttl-seconds, api-error-retry-after-unit-in-key, api-runtime-config-durations-unit-in-key, device-request-response-interval-unit-in-key, rest-api-plugin-durations-unit-in-key, websocket-durations-unit-in-key -->

**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.
13 changes: 7 additions & 6 deletions content/docs/api/declarative-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 |
|:---|:---|:---|
Expand Down Expand Up @@ -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=<ttl>` — `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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -714,7 +714,7 @@ reading a field no server sent — move to `error.fields`.
<Callout type="warn">
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.

Expand Down Expand Up @@ -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();

Expand Down
14 changes: 7 additions & 7 deletions content/docs/api/error-handling-client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 |

Expand All @@ -266,12 +266,12 @@ async function withRetry<T>(
}

// 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;
Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<ttl>`) |
| **`cacheTtlSeconds`** | GET-only, applied to successful answers only (`Cache-Control: private, max-age=<ttl>`) |

{/* os:check */}
```typescript
Expand All @@ -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,
};
```

Expand Down
12 changes: 6 additions & 6 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
});
Expand All @@ -1213,7 +1213,7 @@ declaration to shadow a built-in route:

1. **Match** — the request path must be under `<prefix>/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.
Expand All @@ -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
Expand Down Expand Up @@ -1263,7 +1263,7 @@ runs the same gates your publish path does:
| **Namespace** (ADR-0121 D1/D2) | a `path` outside `/api/v1/apps/<manifest.namespace>/<subpath>`, 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 |

<Callout type="warn">
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/auth-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down
Loading