Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 86 additions & 0 deletions .changeset/epoch-instant-and-external-vocabulary-exemptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
"@objectstack/spec": minor
---

feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478)

<!-- adr-0087: registered epoch-instant-keys-renamed -->

**BREAKING** — four published epoch-instant keys are renamed and tombstoned.
Shipped as `minor` under the repo's launch-window convention for breaking
changes; the hand-migration prescription is 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, because two sibling keys both spelled `ttl` in different units
are indistinguishable at the authoring site. Ruling B exempts two structural
classes from it, and is explicit about the mechanism: both are **declared on the
schema, never in a gate ledger**. This change lands both declarations and
applies them.

## 1. Epoch instants — the shared `EpochMs` schema

`EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing
milliseconds since the Unix epoch. A key whose value IS that schema is an
INSTANT, and the gate recognises it structurally — nothing anywhere names the
exempt keys.

An instant reads to the rule exactly like an offending duration (a bare name
plus a describe that says "milliseconds"), but renaming it the way the rule
prescribes would resolve the wrong confusion. Measured on this package's own
authorable surface: all 51 distinct keys ending in `Ms` are durations
(`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys
ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling
an instant `*Ms` would move it INTO the family the rule exists to separate it
from. So the six instants take `EpochMs`, and the four whose name was bare take
the `*At` convention.

### FROM → TO

| Schema | Wrote | Write instead |
| :-- | :-- | :-- |
| `api/WebSocketEvent` | `timestamp` | `occurredAt` |
| `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` |
| `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` |
| `kernel/HealthStatus` | `timestamp` | `checkedAt` |

```ts
// before
const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} };
// after — the value is unchanged; only the key name and the declared schema move
const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} };
```

Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the
construction site and fails the parse with the rename prescription rather than
being silently stripped. `kernel/ServiceMetadata.registeredAt` and
`kernel/ScopeInfo.createdAt` were already correctly named and only change
schema — they are not retirements and need no edit.

⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different**
key holding an ISO-8601 datetime string. It is untouched; do not rename it with
its neighbour.

**One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen`
were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a
fractional epoch that used to parse at those two sites is now refused.
`Date.now()` has always satisfied it. The other four already declared `.int()`.

## 2. External-standard mirrors — `.meta({ externalVocabulary })`

A key whose name is fixed outside this repo carries
`.meta({ externalVocabulary: '<the standard>' })`. The marker rides
`z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the
gate honours it, and **the reference page publishes it**: the description cell
now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`.
Publishing it is what makes the exemption honest — the gate exists because a
bare `maxAge` publishes a naked number to a reader who cannot see the source.

Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS
`Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys,
the three better-auth forwarded options, PostgreSQL's `statement_timeout` and
the DNS record `ttl`. No authorable key is renamed or re-typed by this half.

⛔ Neither exemption is a pass on lying: a marked key still fails
`name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit
other than milliseconds fails the new `instant-unit-contradicts-schema`.
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ from the provider itself, not from hand-written spec files.
|:---------|:-----------|:------------|:--------|
| **[Connector](/docs/references/integration/connector)** | `connector.zod.ts` | Connector | The connector protocol — auth, sync, webhooks, rate limiting |

## Shared Protocol (5 of 8 schemas)
## Shared Protocol (5 of 9 schemas)

Common utilities used across all protocols.

Expand Down
18 changes: 9 additions & 9 deletions content/docs/references/api/http-cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ const result = CacheControlSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives |
| **maxAge** | `number` | optional | Maximum cache age in seconds |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) |
| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) |


---
Expand Down Expand Up @@ -148,9 +148,9 @@ const result = CacheControlSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives |
| **maxAge** | `number` | optional | Maximum cache age in seconds |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) |
| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) |


---
Expand Down Expand Up @@ -180,9 +180,9 @@ const result = CacheControlSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives |
| **maxAge** | `number` | optional | Maximum cache age in seconds |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) |
| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) |


---
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1207,9 +1207,9 @@ Enable package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives |
| **maxAge** | `number` | optional | Maximum cache age in seconds |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) |
| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) |
| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) |
| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/router.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST-
| **origins** | `string \| string[]` | optional (default: `"*"`) | Allowed origins (* for all) |
| **methods** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>[]` | optional | Allowed HTTP methods |
| **credentials** | `boolean` | optional (default: `false`) | Allow credentials (cookies, authorization headers) |
| **maxAge** | `integer` | optional | Preflight cache duration in seconds |
| **maxAge** | `integer` | optional | Preflight cache duration in seconds (unit per CORS `Access-Control-Max-Age` (WHATWG Fetch)) |

### Nested Shape: `RouterConfig.staticMounts[number]`

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data);
| **fileId** | `string` | ✅ | Temporary File ID |
| **method** | `Enum<'PUT' \| 'POST'>` | ✅ | HTTP Method to use |
| **headers** | `Record<string, string>` | optional | Required headers for upload |
| **expiresIn** | `number` | ✅ | URL expiry in seconds |
| **expiresIn** | `number` | ✅ | URL expiry in seconds (unit per AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)) |


---
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/api/websocket.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ Event pattern (supports wildcards like "record.*" or "*.created")
| **userId** | `string` | ✅ | User identifier |
| **userName** | `string` | ✅ | User display name |
| **status** | `Enum<'online' \| 'away' \| 'offline'>` | ✅ | User presence status |
| **lastSeen** | `number` | ✅ | Unix timestamp of last activity in milliseconds |
| **lastSeenAt** | `integer` | ✅ | Unix timestamp of last activity in milliseconds |
| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. |
| **metadata** | `Record<string, any>` | optional | Additional presence metadata (e.g., current page, custom status) |


Expand Down Expand Up @@ -450,7 +451,8 @@ Event pattern (supports wildcards like "record.*" or "*.created")
| **type** | `Enum<'subscribe' \| 'unsubscribe' \| 'data-change' \| 'presence-update' \| 'cursor-update' \| 'error'>` | ✅ | Event type |
| **channel** | `string` | ✅ | Channel identifier (e.g., "record.account.123", "user.456") |
| **payload** | `any` | ✅ | Event payload data |
| **timestamp** | `number` | ✅ | Unix timestamp in milliseconds |
| **occurredAt** | `integer` | ✅ | Unix timestamp in milliseconds when the event occurred |
| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-postgres.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ PostgreSQL connection configuration
| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. |
| **schema** | `string` | optional (default: `"public"`) | Default schema (knex searchPath) |
| **applicationName** | `string` | optional | Postgres application_name |
| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) |
| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) (unit per PostgreSQL `statement_timeout`) |
| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal |


Expand Down
9 changes: 5 additions & 4 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Protocol Reference
description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
Expand Down Expand Up @@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with
| [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. |
| [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. |
| [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 8 | 26 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Shared Protocol](/docs/references/shared) | 9 | 27 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
| [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **200** | **1589** | 14 protocol modules |
| **Total** | **201** | **1590** | 14 protocol modules |

---

Expand Down Expand Up @@ -286,13 +286,14 @@ Permission sets, row-level security, sharing rules, tenancy posture.

## Shared Protocol

**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 26 schemas**
**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **9 pages, 27 schemas**

Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums.

| File | Schemas |
| :--- | :--- |
| [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` |
| [`epoch.zod.ts`](/docs/references/shared/epoch) | `EpochMs` |
| [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` |
| [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` |
| [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `MetadataItemName`, `SnakeCaseIdentifier`, `SystemIdentifier` |
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/kernel/context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ const result = KernelContextSchema.parse(data);
| **appName** | `string` | optional | Host application name |
| **cwd** | `string` | ✅ | Current working directory |
| **workspaceRoot** | `string` | optional | Workspace root if different from cwd |
| **startTime** | `integer` | ✅ | Boot timestamp (ms) |
| **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds |
| **features** | `Record<string, boolean>` | optional (default: `{}`) | Global feature toggles |
| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). |
| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. |


---
Expand Down Expand Up @@ -68,9 +69,10 @@ Tenant-aware kernel runtime context
| **appName** | `string` | optional | Host application name |
| **cwd** | `string` | ✅ | Current working directory |
| **workspaceRoot** | `string` | optional | Workspace root if different from cwd |
| **startTime** | `integer` | ✅ | Boot timestamp (ms) |
| **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds |
| **features** | `Record<string, boolean>` | optional (default: `{}`) | Global feature toggles |
| **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). |
| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. |
| **tenantId** | `string` | ✅ | Resolved tenant identifier |
| **tenantPlan** | `Enum<'free' \| 'pro' \| 'enterprise'>` | ✅ | Tenant subscription plan |
| **tenantRegion** | `string` | optional | Tenant deployment region |
Expand Down
Loading