diff --git a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md new file mode 100644 index 0000000000..33ae5af20c --- /dev/null +++ b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md @@ -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) + + + +**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 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`. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 2c6c34f213..24a14a7dfc 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -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. diff --git a/content/docs/references/api/http-cache.mdx b/content/docs/references/api/http-cache.mdx index 288e6e51bc..82f081dbe3 100644 --- a/content/docs/references/api/http-cache.mdx +++ b/content/docs/references/api/http-cache.mdx @@ -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)) | --- @@ -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)) | --- @@ -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)) | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5435ac2bce..665006b7d4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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)) | --- diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index 5b0e1e6219..bfe175a5e8 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -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]` diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 8405609097..8e9bdc5f47 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -287,7 +287,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **fileId** | `string` | ✅ | Temporary File ID | | **method** | `Enum<'PUT' \| 'POST'>` | ✅ | HTTP Method to use | | **headers** | `Record` | 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)) | --- diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index d28cc178e4..0d01598748 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -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` | optional | Additional presence metadata (e.g., current page, custom status) | @@ -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()`). | --- diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index 806fd72b91..73996bc98c 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -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 | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 153d797bc5..f40714bdf0 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 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/. */} @@ -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 | --- @@ -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` | diff --git a/content/docs/references/kernel/context.mdx b/content/docs/references/kernel/context.mdx index bebb71cc85..89a31c6f73 100644 --- a/content/docs/references/kernel/context.mdx +++ b/content/docs/references/kernel/context.mdx @@ -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` | 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. | --- @@ -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` | 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 | diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index 356a354f61..945a88ca9e 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -36,7 +36,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN 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 `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | @@ -53,7 +54,7 @@ const result = HealthStatusSchema.parse(data); | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; timestamp: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | +| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | ### Nested Shape: `PluginStartupResult.error` @@ -69,7 +70,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN 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 `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | @@ -110,7 +112,7 @@ const result = HealthStatusSchema.parse(data); | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; timestamp: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | +| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | --- diff --git a/content/docs/references/shared/epoch.mdx b/content/docs/references/shared/epoch.mdx new file mode 100644 index 0000000000..0e43aebfb6 --- /dev/null +++ b/content/docs/references/shared/epoch.mdx @@ -0,0 +1,32 @@ +--- +title: Epoch +description: Epoch protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + + +**Source:** `packages/spec/src/shared/epoch.zod.ts` + + +## TypeScript Usage + +```typescript +import { EpochMs } from '@objectstack/spec/shared'; +import type { EpochMs } from '@objectstack/spec/shared'; + +// Validate data +const result = EpochMs.parse(data); +``` + +--- + +## EpochMs + +Unix timestamp in milliseconds (epoch) + +**Type:** `integer` + + +--- + diff --git a/content/docs/references/shared/http.mdx b/content/docs/references/shared/http.mdx index b7a8912f60..cb5c556062 100644 --- a/content/docs/references/shared/http.mdx +++ b/content/docs/references/shared/http.mdx @@ -36,7 +36,7 @@ const result = CorsConfigSchema.parse(data); | **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)) | --- diff --git a/content/docs/references/shared/index.mdx b/content/docs/references/shared/index.mdx index 02268ead5a..446b5a5a88 100644 --- a/content/docs/references/shared/index.mdx +++ b/content/docs/references/shared/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the shared layer of ObjectStack. + diff --git a/content/docs/references/shared/meta.json b/content/docs/references/shared/meta.json index c06c4191b8..d4cbcb73ed 100644 --- a/content/docs/references/shared/meta.json +++ b/content/docs/references/shared/meta.json @@ -2,6 +2,7 @@ "title": "Shared Protocol", "pages": [ "enums", + "epoch", "expression", "http", "identifiers", diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index f8c248c004..e01734b686 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -112,7 +112,7 @@ Advanced / low-level Better-Auth options | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **expiresIn** | `number` | optional (default: `604800`) | Session duration in seconds | +| **expiresIn** | `number` | optional (default: `604800`) | Session duration in seconds (unit per better-auth `session.expiresIn`) | | **updateAge** | `number` | optional (default: `86400`) | Session update frequency | ### Nested Shape: `AuthConfig.socialProviders[string]` @@ -151,7 +151,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **requireEmailVerification** | `boolean` | optional | Require email verification before creating a session | | **minPasswordLength** | `number` | optional | Minimum password length (default 8) | | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | -| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) | +| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | | **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | @@ -162,7 +162,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **sendOnSignUp** | `boolean` | optional | Automatically send verification email after sign-up | | **sendOnSignIn** | `boolean` | optional | Send verification email on sign-in when not yet verified | | **autoSignInAfterVerification** | `boolean` | optional | Auto sign-in the user after email verification | -| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) | +| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) (unit per better-auth `emailVerification.expiresIn`) | ### Nested Shape: `AuthConfig.audience` @@ -248,7 +248,7 @@ Email and password authentication options forwarded to better-auth | **requireEmailVerification** | `boolean` | optional | Require email verification before creating a session | | **minPasswordLength** | `number` | optional | Minimum password length (default 8) | | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | -| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) | +| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | | **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | @@ -266,7 +266,7 @@ Email verification options forwarded to better-auth | **sendOnSignUp** | `boolean` | optional | Automatically send verification email after sign-up | | **sendOnSignIn** | `boolean` | optional | Send verification email on sign-in when not yet verified | | **autoSignInAfterVerification** | `boolean` | optional | Auto sign-in the user after email verification | -| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) | +| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) (unit per better-auth `emailVerification.expiresIn`) | --- diff --git a/content/docs/references/system/disaster-recovery.mdx b/content/docs/references/system/disaster-recovery.mdx index b47f173d65..c3799794d0 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -212,7 +212,7 @@ Failover configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **ttl** | `number` | optional (default: `60`) | DNS TTL in seconds for failover | +| **ttl** | `number` | optional (default: `60`) | DNS TTL in seconds for failover (unit per DNS resource-record TTL (RFC 1035 §4.1.3)) | | **provider** | `Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'>` | optional | DNS provider for automatic failover | diff --git a/content/docs/references/system/object-storage.mdx b/content/docs/references/system/object-storage.mdx index f7ae80cbae..9e67cf3e5d 100644 --- a/content/docs/references/system/object-storage.mdx +++ b/content/docs/references/system/object-storage.mdx @@ -312,7 +312,7 @@ Lifecycle policy action type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **operation** | `Enum<'get' \| 'put' \| 'delete' \| 'head'>` | ✅ | Allowed operation | -| **expiresIn** | `number` | ✅ | Expiration time in seconds (max 7 days) | +| **expiresIn** | `number` | ✅ | Expiration time in seconds (max 7 days) (unit per AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)) | | **contentType** | `string` | optional | Required content type for PUT operations | | **maxSize** | `number` | optional | Maximum file size in bytes for PUT operations | | **responseContentType** | `string` | optional | Override content-type for GET operations | diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index d3d56924f4..90d6e66672 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -12,6 +12,7 @@ "CronExpressionInputSchema (const)", "EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS (const)", + "EpochMs (type)", "Expression (type)", "ExpressionDialect (type)", "ExpressionInput (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index f141354046..e03db2020e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1647,7 +1647,8 @@ "api/SimpleCursorPosition:recordId", "api/SimpleCursorPosition:selection", "api/SimpleCursorPosition:userId", - "api/SimplePresenceState:lastSeen", + "api/SimplePresenceState:lastSeen [RETIRED]", + "api/SimplePresenceState:lastSeenAt", "api/SimplePresenceState:metadata", "api/SimplePresenceState:status", "api/SimplePresenceState:userId", @@ -1808,8 +1809,9 @@ "api/WebSocketConfig:timeout", "api/WebSocketConfig:url", "api/WebSocketEvent:channel", + "api/WebSocketEvent:occurredAt", "api/WebSocketEvent:payload", - "api/WebSocketEvent:timestamp", + "api/WebSocketEvent:timestamp [RETIRED]", "api/WebSocketEvent:type", "api/WebSocketServerConfig:cursorSharing", "api/WebSocketServerConfig:enabled", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 7883c8928b..016597856f 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -203,10 +203,11 @@ "kernel/ExtensionPoint:type", "kernel/GetPackageRequest:id", "kernel/GetPackageResponse:package", + "kernel/HealthStatus:checkedAt", "kernel/HealthStatus:details", "kernel/HealthStatus:healthy", "kernel/HealthStatus:message", - "kernel/HealthStatus:timestamp", + "kernel/HealthStatus:timestamp [RETIRED]", "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", "kernel/HotReloadConfig:debounceDelay", @@ -240,7 +241,8 @@ "kernel/KernelContext:instanceId", "kernel/KernelContext:mode", "kernel/KernelContext:previewMode [RETIRED]", - "kernel/KernelContext:startTime", + "kernel/KernelContext:startTime [RETIRED]", + "kernel/KernelContext:startedAt", "kernel/KernelContext:version", "kernel/KernelContext:workspaceRoot", "kernel/KernelSecurityPolicy:auditLog", @@ -755,7 +757,8 @@ "kernel/TenantRuntimeContext:instanceId", "kernel/TenantRuntimeContext:mode", "kernel/TenantRuntimeContext:previewMode [RETIRED]", - "kernel/TenantRuntimeContext:startTime", + "kernel/TenantRuntimeContext:startTime [RETIRED]", + "kernel/TenantRuntimeContext:startedAt", "kernel/TenantRuntimeContext:tenantDbUrl", "kernel/TenantRuntimeContext:tenantId", "kernel/TenantRuntimeContext:tenantPlan", diff --git a/packages/spec/declaration-map/shared.json b/packages/spec/declaration-map/shared.json index 6b2c3b7f71..328d4fa2f7 100644 --- a/packages/spec/declaration-map/shared.json +++ b/packages/spec/declaration-map/shared.json @@ -8,6 +8,7 @@ "CorsConfigSchema": "shared/CorsConfig", "CronExpressionInput": "shared/CronExpressionInput", "CronExpressionInputSchema": "shared/CronExpressionInput", + "EpochMs": "shared/EpochMs", "Expression": "shared/Expression", "ExpressionDialect": "shared/ExpressionDialect", "ExpressionInput": "shared/ExpressionInput", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 1991e1234d..1a58541778 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -12,6 +12,7 @@ "CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)", "EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)", + "EpochMs": "src/shared/epoch.zod.ts#EpochMs (type)", "Expression": "src/shared/expression.zod.ts#Expression (type)", "ExpressionDialect": "src/shared/expression.zod.ts#ExpressionDialect (type)", "ExpressionInput": "src/shared/expression.zod.ts#ExpressionInput (type)", diff --git a/packages/spec/json-schema.manifest/shared.json b/packages/spec/json-schema.manifest/shared.json index a5350b4de9..c0a045561b 100644 --- a/packages/spec/json-schema.manifest/shared.json +++ b/packages/spec/json-schema.manifest/shared.json @@ -5,6 +5,7 @@ "shared/BaseMetadataRecord", "shared/CorsConfig", "shared/CronExpressionInput", + "shared/EpochMs", "shared/Expression", "shared/ExpressionDialect", "shared/ExpressionInput", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 74bba77ccc..f39ff89720 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (207 schemas) +## 3. Schema Inventory by Domain (208 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -91,7 +91,7 @@ here is verifiable from the installed package. | api | 30 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 13 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook | -| shared | 13 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain | +| shared | 14 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs) | | ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | cloud | 11 | Marketplace, Developer Portal, App Store, Environment, Package, Tenant | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index 5d7306265c..fe98c8fae5 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -66,6 +66,43 @@ * talking about time. `--list` still prints the unit-nowhere keys so the * population stays visible; closing it is a describe-by-describe decision. * + * ## The two exemptions, DECLARED ON THE SCHEMA (#15676, ruling B) + * + * The rule governs every authored and every runtime-emitted duration MINUS two + * structural classes, and the ruling is explicit about the mechanism: they are + * "declared ON THE SCHEMA, never in a gate ledger". So neither of them appears + * in this file as a key, a path or a name. What appears here is the ability to + * READ a declaration the schema itself carries. + * + * 1. **Epoch instants** — a key whose value IS the shared {@link INSTANT_ROOT} + * schema (`EpochMs`, `src/shared/epoch.zod.ts`) is an INSTANT, not a + * duration. An instant is numerically the same shape and its describe names + * the same unit, but it is a different confusion: renaming `startTime` to + * `startTimeMs` would move it into the `*Ms` DURATION family (measured on + * this package's authorable surface: all 51 distinct `*Ms` keys are + * durations, all 51 distinct `*At` keys are instants), which is the opposite + * of what the rule is for. The instant is spelled `*At` and typed `EpochMs`. + * + * 2. **External-standard mirrors** — a key that carries + * `.meta({ externalVocabulary: '' })` mirrors a name fixed + * outside this repo (`max-age` from HTTP Cache-Control, `statement_timeout` + * from PostgreSQL, better-auth's option names). Renaming it would break the + * correspondence that makes it readable. The marker rides `z.toJSONSchema` + * verbatim — the same channel `xRef` / `xExpression` / `xEnumDeprecated` use + * — so the reference page prints the unit as "per the named standard" + * (`scripts/lib/schema-section.ts`) instead of the reader having to guess. + * + * ⛔ Neither exemption is a pass on lying. A marked key still fails + * `name-unit-contradicts-prose` (a marker waives the RENAME, never a + * contradiction), and an `EpochMs` key whose describe names a unit other than + * milliseconds fails `instant-unit-contradicts-schema` — the schema says + * milliseconds, so prose that says seconds is one of the two being wrong. A + * declaration that could never be refused is an allowlist wearing a `.meta()`. + * + * Both classes stay VISIBLE in the census: `--list` marks them and the verdict + * line counts them. An exemption nobody can see is the ledger this ruling + * refused. + * * ## No baseline, by ruling * * Triage proposed a ratchet from the day's count with the existing keys @@ -179,6 +216,29 @@ const DURATION_SHAPED_TOKENS = new Set([ const NUMERIC_ROOTS = new Set(['z.number', 'z.int', 'z.coerce.number']); +/** + * The shared epoch-instant schema — exemption class (i), read from the SOURCE + * TEXT as the identifier a property's value chain is rooted at. + * + * Recognised by NAME rather than by resolving the import, for the same reason + * the whole file is a syntactic scan: a detector with no module resolution + * cannot fail to resolve in CI. The coupling that keeps the name honest is a + * self-test case which reads `src/shared/epoch.zod.ts` and asserts it really + * exports this symbol — so renaming the schema without renaming it here is RED, + * not a silently-empty exemption. + */ +const INSTANT_ROOT = 'EpochMs'; +/** Where {@link INSTANT_ROOT} is declared — read by the self-test, not by the scan. */ +const INSTANT_ROOT_MODULE = 'src/shared/epoch.zod.ts'; + +/** + * The `.meta()` key that declares exemption class (ii). A key carrying it + * mirrors a name fixed by an external standard, so the RENAME is waived — never + * the contradiction check, and never the requirement that the describe still + * state the unit. + */ +const EXTERNAL_VOCABULARY_META_KEY = 'externalVocabulary'; + export interface DurationKey { file: string; line: number; @@ -191,11 +251,18 @@ export interface DurationKey { /** true when a sibling `unit` key sits on the same object literal */ valueUnitPair: boolean; durationShaped: boolean; + /** true when the value chain is rooted at the shared `EpochMs` schema — exemption (i) */ + instant: boolean; + /** the standard named by `.meta({ externalVocabulary })`, when one is declared — exemption (ii) */ + externalVocabulary: string | undefined; } export interface Finding { site: DurationKey; - rule: 'unit-in-prose-not-in-name' | 'name-unit-contradicts-prose'; + rule: + | 'unit-in-prose-not-in-name' + | 'name-unit-contradicts-prose' + | 'instant-unit-contradicts-schema'; message: string; } @@ -237,19 +304,57 @@ export function isDurationShaped(key: string): boolean { // ── AST ──────────────────────────────────────────────────────────────────── -/** Walk a `z.x().y().z()` chain to its root; return the root's dotted name and every `.describe()` string. */ -function chainInfo(expr: ts.Expression): { root: string | undefined; describes: string[] } { +/** + * Walk a `z.x().y().z()` chain to its root. + * + * Returns the root's dotted name (`z.number`, `z.coerce.number`) OR, when the + * chain bottoms out at a plain identifier, that identifier — which is how a key + * declared as `EpochMs` / `EpochMs.optional().describe(…)` is recognised as + * exemption class (i) rather than vanishing from the population as an + * unresolvable root. Every OTHER identifier root (`PositiveInt.describe(…)`) + * stays outside the population exactly as before: `collectDurationKeys` admits + * only the roots it knows. + * + * Also collects, from the same single pass: + * - every `.describe()` string; + * - `description` and `externalVocabulary` from `.meta({ … })` — `.meta()` is + * the repo's established annotation channel (`xRef`, `xExpression`, + * `xEnumDeprecated`) and it MERGES with a `.describe()` earlier in the + * chain rather than replacing it (measured against zod 4.4.3), so the two + * spellings coexist on one key. + * + * Reading `description` out of `.meta()` closes a hole rather than adding a + * feature: without it, moving a describe into `.meta({ description })` would + * take a key out of this gate's population SILENTLY — an exemption by + * blindness, which is precisely what ruling B refuses. (Measured on this tree: + * exactly one numeric key declares its description that way — `data/Field`'s + * `precision`, "Decimal precision (default: 2)" — so the reading adds no + * offender today. It stops the next one.) + */ +function chainInfo(expr: ts.Expression): { + root: string | undefined; + describes: string[]; + metaDescription: string | undefined; + externalVocabulary: string | undefined; +} { const describes: string[] = []; + let metaDescription: string | undefined; + let externalVocabulary: string | undefined; let cur: ts.Expression = expr; for (;;) { if (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur)) { cur = cur.expression; continue; } - if (!ts.isCallExpression(cur)) return { root: undefined, describes }; + if (ts.isIdentifier(cur)) { + // A bare schema constant, or the receiver a chain bottomed out at: + // `createdAt: EpochMs` / `createdAt: EpochMs.optional()`. + return { root: cur.text, describes, metaDescription, externalVocabulary }; + } + if (!ts.isCallExpression(cur)) return { root: undefined, describes, metaDescription, externalVocabulary }; if (!ts.isPropertyAccessExpression(cur.expression)) { // `someHelper(...)` — a call whose callee is not `a.b`; not a `z.` root - return { root: undefined, describes }; + return { root: undefined, describes, metaDescription, externalVocabulary }; } const method = cur.expression.name.text; if (method === 'describe' && cur.arguments.length > 0) { @@ -257,13 +362,35 @@ function chainInfo(expr: ts.Expression): { root: string | undefined; describes: const text = concatLiteral(a); if (text !== undefined) describes.push(text); } + if (method === 'meta' && cur.arguments.length > 0) { + const a = cur.arguments[0]; + if (ts.isObjectLiteralExpression(a)) { + for (const prop of a.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : undefined; + if (name === undefined) continue; + // Only a non-empty STRING LITERAL declares anything. A computed value, + // a template with holes or an empty string is not a standard's name, + // and an unverifiable claim is refused rather than assumed true — so + // the key stays in the population and stays judged. + const value = concatLiteral(prop.initializer); + if (name === 'description' && value !== undefined && metaDescription === undefined) { + metaDescription = value; + } + if (name === EXTERNAL_VOCABULARY_META_KEY && value !== undefined && value.trim() !== '' + && externalVocabulary === undefined) { + externalVocabulary = value; + } + } + } + } // The callee `a.b.c` — collect its dotted parts down to whatever `a` is. const parts: string[] = []; let p: ts.Expression = cur.expression; while (ts.isPropertyAccessExpression(p)) { parts.unshift(p.name.text); p = p.expression; } if (ts.isIdentifier(p) && p.text === 'z') { // reached `z.number(...)` / `z.coerce.number(...)`: this call is the root - return { root: ['z', ...parts].join('.'), describes }; + return { root: ['z', ...parts].join('.'), describes, metaDescription, externalVocabulary }; } // otherwise `p` is the receiver of this method call — keep walking down it cur = p; @@ -290,13 +417,17 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.parent)) { const name = ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name) ? node.name.text : undefined; if (name) { - const { root, describes } = chainInfo(node.initializer); - if (root && NUMERIC_ROOTS.has(root)) { + const { root, describes, metaDescription, externalVocabulary } = chainInfo(node.initializer); + const instant = root === INSTANT_ROOT; + if (root && (NUMERIC_ROOTS.has(root) || instant)) { const siblings = node.parent.properties; const valueUnitPair = siblings.some( (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'unit', ); - const describe = describes.length ? describes[describes.length - 1] : undefined; + // An explicit `.describe()` wins over a `.meta({ description })`: it is + // what every site in this tree writes, and where a key carries both, + // the describe is the one an author reads at the declaration. + const describe = describes.length ? describes[describes.length - 1] : metaDescription; out.push({ file: fileName, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, @@ -306,6 +437,8 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey keyUnits: unitsInKey(name), valueUnitPair, durationShaped: isDurationShaped(name), + instant, + externalVocabulary, }); } } @@ -319,8 +452,35 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey export function judge(site: DurationKey): Finding | undefined { if (site.valueUnitPair) return undefined; const where = `${site.file}:${site.line} \`${site.key}\``; + + // Exemption (i): the value IS the shared `EpochMs` schema, so the key is an + // INSTANT and the duration rule does not reach it. The one thing still + // refused is a describe that contradicts the schema: `EpochMs` declares + // milliseconds, so prose naming another unit means the site and the schema + // disagree, and a silent exemption there would let the declaration launder a + // real unit bug. + if (site.instant) { + if (site.proseUnits.length > 0 && !site.proseUnits.includes('ms')) { + return { + site, + rule: 'instant-unit-contradicts-schema', + message: `${where} — typed \`${INSTANT_ROOT}\` (epoch MILLISECONDS) but the describe says ` + + `${site.proseUnits.join('/')}. One of them is lying; either the describe is wrong or this is ` + + `not an epoch-millisecond instant and must not be typed \`${INSTANT_ROOT}\`.`, + }; + } + return undefined; + } + if (site.proseUnits.length > 0) { if (site.keyUnits.length === 0) { + // Exemption (ii): the key mirrors a name fixed outside this repo, declared + // on the schema with `.meta({ externalVocabulary })`. It waives the RENAME + // and nothing else — the describe must still state the unit, which is what + // put this site in `proseUnits.length > 0` in the first place, and the + // contradiction branch below is not reachable past a `return` here because + // a marked key with a unit token in its NAME never takes this branch. + if (site.externalVocabulary !== undefined) return undefined; return { site, rule: 'unit-in-prose-not-in-name', @@ -329,10 +489,16 @@ export function judge(site: DurationKey): Finding | undefined { }; } if (!site.keyUnits.some((u) => site.proseUnits.includes(u))) { + // Reached by MARKED keys too, deliberately: a marker waives the rename, + // never a contradiction. A key spelled `maxAgeMs` whose describe says + // seconds is the 1000x bug whatever standard its name mirrors. return { site, rule: 'name-unit-contradicts-prose', - message: `${where} — the key name says ${site.keyUnits.join('/')} but the describe says ${site.proseUnits.join('/')}. One of them is lying; fix whichever is wrong.`, + message: `${where} — the key name says ${site.keyUnits.join('/')} but the describe says ${site.proseUnits.join('/')}. One of them is lying; fix whichever is wrong.` + + (site.externalVocabulary !== undefined + ? ` The \`${EXTERNAL_VOCABULARY_META_KEY}\` marker waives the RENAME, never this.` + : ''), }; } return undefined; @@ -450,6 +616,79 @@ function selfTest(): number { rulesOf(`const S = z.object({ a: z.number().describe('Wait 1 second'), b: z.number().describe('A 15-minute window'), c: z.number().describe('Poll every 5 min'), d: z.number().describe('Debounce of 30 ms') });`) .join() === 'unit-in-prose-not-in-name,unit-in-prose-not-in-name,unit-in-prose-not-in-name,unit-in-prose-not-in-name'); + // ── the two DECLARED exemptions (#15676, ruling B) ─────────────────────── + // Each class is pinned in both directions: the declaration exempts, and the + // declaration does NOT exempt a contradiction. A marker that could never be + // refused would be an allowlist wearing a `.meta()`. + + expect('exempt (i): a key whose value IS `EpochMs` is an instant, not a duration', + rulesOf(`const S = z.object({ createdAt: EpochMs.describe('Unix timestamp in milliseconds when the scope was created') });`) + .join() === ''); + expect('exempt (i): a BARE `EpochMs` key (no chain at all) is an instant', + rulesOf(`const S = z.object({ createdAt: EpochMs });`) + .join() === ''); + expect('exempt (i): `EpochMs.optional()` — the exemption survives the chain', + rulesOf(`const S = z.object({ registeredAt: EpochMs.optional().describe('Unix timestamp in milliseconds when registered') });`) + .join() === ''); + expect('REFUSED (i): an `EpochMs` key whose describe names a unit other than ms → instant-unit-contradicts-schema', + rulesOf(`const S = z.object({ startedAt: EpochMs.describe('Boot timestamp in seconds') });`) + .join() === 'instant-unit-contradicts-schema'); + expect('the instant exemption is `EpochMs` ALONE — another identifier root stays outside the population', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ startedAt: SomeOtherSchema.describe('Boot timestamp in seconds') });`); + return sites.length === 0; + })()); + expect('an `EpochMs` site is COUNTED in the census, not vanished from it', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ createdAt: EpochMs.describe('Unix timestamp in milliseconds') });`); + return sites.length === 1 && sites[0].instant && sites[0].proseUnits.join() === 'ms'; + })()); + + expect('exempt (ii): `.meta({ externalVocabulary })` waives the rename on a bare-named mirror', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) + .join() === ''); + expect('exempt (ii): the marker rides in a `.meta()` that also carries description/title', + rulesOf(`const S = z.object({ statementTimeout: z.number().int().positive().optional().describe('Abort statements running longer than this (ms)').meta({ title: 'Statement timeout (ms)', externalVocabulary: 'PostgreSQL statement_timeout' }) });`) + .join() === ''); + expect('REFUSED (ii): a MARKED key whose name-unit contradicts its describe is still an offender', + rulesOf(`const S = z.object({ maxAgeMs: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) + .join() === 'name-unit-contradicts-prose'); + expect('REFUSED (ii): an EMPTY marker declares no standard and exempts nothing', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: '' }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('REFUSED (ii): a non-literal marker value is unverifiable and exempts nothing', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: SOME_CONST }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('REFUSED (ii): a marker is not a licence to drop the unit from the describe — an unmarked sibling still fails', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'RFC 9111' }), ttl: z.number().describe('TTL in seconds') });`) + .join() === ',unit-in-prose-not-in-name'); + expect('a marked site is COUNTED in the census with its standard, not vanished from it', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'RFC 9111' }) });`); + return sites.length === 1 && sites[0].externalVocabulary === 'RFC 9111' && sites[0].proseUnits.join() === 'seconds'; + })()); + + expect('a describe declared through `.meta({ description })` is READ — no exemption by blindness', + rulesOf(`const S = z.object({ timeout: z.number().meta({ description: 'Timeout in milliseconds' }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('an explicit `.describe()` wins over a `.meta({ description })` on the same key', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ ttl: z.number().describe('Cache TTL in seconds').meta({ description: 'Cache TTL in milliseconds' }) });`); + return sites.length === 1 && sites[0].describe === 'Cache TTL in seconds' && judge(sites[0])?.rule === 'unit-in-prose-not-in-name'; + })()); + + // The instant exemption names a schema by IDENTIFIER, because this file is a + // syntactic scan with no module resolution. That is only honest while the + // identifier really is exported from where it says — otherwise the exemption + // would be silently empty and every instant would read as an offender (or, + // after a rename in the other direction, an unrelated local could inherit the + // exemption). Held from this side, the same coupling ROOT_DIR_WATCH_HINTS has. + expect(`\`${INSTANT_ROOT}\` is exported from \`${INSTANT_ROOT_MODULE}\``, + (() => { + const src = readFileSync(join(pkgRoot, INSTANT_ROOT_MODULE), 'utf8'); + return new RegExp(`export const ${INSTANT_ROOT}\\b`).test(src); + })()); + // The declared population must be the population the scan reads (the // ROOT_DIR_WATCH_HINTS idiom's coupling, held from this side). const repoRoot = join(pkgRoot, '..', '..'); @@ -474,24 +713,44 @@ function main(argv: string[]): number { const { sites, findings, files } = scanTree(root ? resolve(root) : undefined); const durationSites = sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0); + // The two DECLARED exemptions, counted rather than hidden. A key exempted by + // a declaration stays in the census and stays countable — that is what makes + // the exemption reviewable at a glance and keeps it from becoming the ledger + // ruling B refused. Counted over the same `durationSites` population the + // verdict line reports, so the three numbers add up on the page. + const instants = durationSites.filter((s) => s.instant); + const mirrors = durationSites.filter((s) => !s.instant && s.externalVocabulary !== undefined); + const exemptions = `${instants.length} declared \`${INSTANT_ROOT}\` instant(s), ` + + `${mirrors.length} declared \`${EXTERNAL_VOCABULARY_META_KEY}\` mirror(s)`; + if (argv.includes('--list')) { for (const s of durationSites) { - console.log(`${s.file}:${s.line} ${s.key} [name: ${s.keyUnits.join('/') || '-'}] [prose: ${s.proseUnits.join('/') || '-'}]${s.valueUnitPair ? ' [value/unit pair]' : ''} ${JSON.stringify(s.describe ?? null)}`); + const marks = [ + s.valueUnitPair ? ' [value/unit pair]' : '', + s.instant ? ` [instant: ${INSTANT_ROOT}]` : '', + s.externalVocabulary !== undefined ? ` [${EXTERNAL_VOCABULARY_META_KEY}: ${s.externalVocabulary}]` : '', + ].join(''); + console.log(`${s.file}:${s.line} ${s.key} [name: ${s.keyUnits.join('/') || '-'}] [prose: ${s.proseUnits.join('/') || '-'}]${marks} ${JSON.stringify(s.describe ?? null)}`); } - console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all.`); + console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all; ${exemptions}.`); } if (findings.length === 0) { - console.log(`✓ check:duration-unit-keys — ${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`); zero offenders, no baseline.`); + console.log(`✓ check:duration-unit-keys — ${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`, or under a declared exemption: ${exemptions}); zero offenders, no baseline.`); return 0; } - console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} duration-shaped numeric key(s) in ${files} source file(s):\n`); + console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} duration-shaped numeric key(s) in ${files} source file(s) (${exemptions}):\n`); for (const f of findings) console.error(` [${f.rule}] ${f.message}`); console.error( '\nThe unit of a duration-shaped number lives in the KEY NAME (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`)' + ' or in a unit-carrying VALUE (a duration literal, or a `{ value, unit }` pair) — never only in the describe prose,' + ' and never nowhere. There is no baseline: a published key is renamed under an ADR-0087 conversion (registry entry +' - + ' a loud refusal of the old spelling naming the new key); see the header of this script.', + + ' a loud refusal of the old spelling naming the new key); see the header of this script.' + + '\n\nTwo structural classes are exempt, and both are DECLARED ON THE SCHEMA — there is no list to add a key to:' + + `\n - an epoch INSTANT is typed \`${INSTANT_ROOT}\` (\`${INSTANT_ROOT_MODULE}\`) and named \`*At\`;` + + `\n - a key mirroring a name fixed outside this repo carries \`.meta({ ${EXTERNAL_VOCABULARY_META_KEY}: '' })\`,` + + ' which the reference page prints as "unit per ".' + + '\nIf the offender above is neither, it is a rename.', ); return 1; } diff --git a/packages/spec/scripts/lib/schema-section.ts b/packages/spec/scripts/lib/schema-section.ts index 4117ff4421..37f2f31bb8 100644 --- a/packages/spec/scripts/lib/schema-section.ts +++ b/packages/spec/scripts/lib/schema-section.ts @@ -229,6 +229,36 @@ function carriesDescription(shape: NestedShape): boolean { ); } +/** + * The published half of the `externalVocabulary` exemption (#15676, ruling B on + * #14478). + * + * A key that mirrors a name fixed outside this repo — `max-age` from HTTP + * Cache-Control, `statement_timeout` from PostgreSQL, better-auth's option + * names — keeps its bare name instead of gaining a `Seconds` / `Ms` suffix, and + * declares WHY on the schema with `.meta({ externalVocabulary: '' })`. + * That marker rides `z.toJSONSchema` verbatim, the same channel `xRef` / + * `xExpression` / `xEnumDeprecated` use, so it arrives here as a property of + * the JSON-Schema node. + * + * Printing it is what makes the exemption honest for the ONE reader who cannot + * see the source. `check:duration-unit-keys` exists because a bare `maxAge` + * publishes a naked number to the reference page and the reader has to guess + * whether it is seconds or milliseconds; exempting the key without publishing + * its reason would leave that reader exactly where the gate found them. With + * the standard named, the unit IS stated — by reference rather than by suffix, + * which is the whole claim the exemption rests on. + * + * Appended to the description cell rather than given a column of its own: it + * qualifies the prose already in that cell (which still states the unit), and + * eleven keys do not earn a fifth column on every table in the reference. + */ +function externalVocabularyNote(prop: any): string { + const standard = prop?.externalVocabulary; + if (typeof standard !== 'string' || standard.trim() === '') return ''; + return ` (unit per ${standard.trim()})`; +} + /** * Render one schema's section, heading included. * @@ -434,7 +464,9 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio // `\|` in a description can't decay into an escaped backslash + live // pipe), then pipes — an unescaped `|` (even inside a code span) // splits the cell. - const desc = escapeMdxDescription((prop.description || '').replace(/\n/g, ' ')) + const desc = escapeMdxDescription( + ((prop.description || '') + externalVocabularyNote(prop)).replace(/\n/g, ' '), + ) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|'); t += `| **${key}** | \`${typeStr}\` | ${isReq} | ${desc} |\n`; diff --git a/packages/spec/scripts/schema-section.test.ts b/packages/spec/scripts/schema-section.test.ts index 1c875edfd3..6b58e0c3f8 100644 --- a/packages/spec/scripts/schema-section.test.ts +++ b/packages/spec/scripts/schema-section.test.ts @@ -569,3 +569,91 @@ describe('renderSchemaSection — the same treatment for relocated vocabularies expect(qualifiedHeadings(md)).toEqual(['### Allowed Values: `Widget.mode`']); }); }); + +/** + * [#15676] The PUBLISHED half of the `externalVocabulary` exemption — ruling B + * on #14478. + * + * `check:duration-unit-keys` exists because a bare `maxAge` publishes a naked + * number to the reference page and the reader has to guess seconds from + * milliseconds. The exemption lets eleven keys keep their bare name BECAUSE the + * name is fixed by an external standard — and that argument only holds for the + * reference-page reader if the page says which standard. Exempting the key + * without publishing its reason would leave exactly that reader where the gate + * found them, so the note is part of the exemption rather than a nicety. + * + * The marker reaches this renderer as a property of the JSON-Schema node, + * riding `z.toJSONSchema` verbatim — the same channel `xRef` / `xExpression` / + * `xEnumDeprecated` use. + * + * MEASURED (reverse verification): deleting the `externalVocabularyNote(prop)` + * term from the description cell turns the first three cases below red and + * leaves the last two green — the last two assert the note's ABSENCE, which is + * what keeps it from decorating every row in the reference. + */ +describe('externalVocabulary — the published half of the duration-rule exemption', () => { + const withMarker = (marker: unknown) => ({ + type: 'object', + properties: { + maxAge: { + type: 'number', + description: 'Maximum cache age in seconds', + ...(marker === undefined ? {} : { externalVocabulary: marker }), + }, + }, + }); + + it('prints the unit as per the named standard, beside the prose that states it', () => { + const md = renderSchemaSection('CacheControl', withMarker('HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)')); + + expect(md).toContain( + 'Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1))', + ); + }); + + it('keeps the describe prose — the note QUALIFIES the unit, it does not replace it', () => { + const md = renderSchemaSection('CacheControl', withMarker('PostgreSQL `statement_timeout`')); + + expect(md).toContain('Maximum cache age in seconds'); + expect(md).toContain('(unit per PostgreSQL `statement_timeout`)'); + }); + + it('renders inside a nested shape table too — one grammar, not two', () => { + const md = renderSchemaSection('AuthConfig', { + type: 'object', + properties: { + session: { + type: 'object', + description: 'Session options', + properties: { + expiresIn: { + type: 'number', + description: 'Session duration in seconds', + externalVocabulary: 'better-auth `session.expiresIn`', + }, + }, + }, + }, + }); + + expect(md).toContain('Session duration in seconds (unit per better-auth `session.expiresIn`)'); + }); + + it('prints nothing for a key that declares no marker — the note is not decoration', () => { + const md = renderSchemaSection('CacheControl', withMarker(undefined)); + + expect(md).toContain('Maximum cache age in seconds'); + expect(md).not.toContain('unit per'); + }); + + it('prints nothing for an empty or non-string marker — an unverifiable claim publishes nothing', () => { + // The gate refuses these too (they exempt no key), so the page must not + // print a standard the contract never named. Held on the SAME inputs from + // both sides so the two halves cannot drift into disagreeing about what + // counts as a declaration. + for (const marker of ['', ' ', 42, null, { name: 'RFC 9111' }]) { + const md = renderSchemaSection('CacheControl', withMarker(marker)); + expect(md, `marker ${JSON.stringify(marker)}`).not.toContain('unit per'); + } + }); +}); diff --git a/packages/spec/src/api/http-cache.zod.ts b/packages/spec/src/api/http-cache.zod.ts index eff738b9df..9590ba0155 100644 --- a/packages/spec/src/api/http-cache.zod.ts +++ b/packages/spec/src/api/http-cache.zod.ts @@ -68,9 +68,23 @@ export type CacheDirective = z.input; */ export const CacheControlSchema = lazySchema(() => z.object({ directives: z.array(CacheDirective).describe('Cache control directives'), - maxAge: z.number().optional().describe('Maximum cache age in seconds'), - staleWhileRevalidate: z.number().optional().describe('Allow serving stale content while revalidating (seconds)'), - staleIfError: z.number().optional().describe('Allow serving stale content on error (seconds)'), + // The three keys below are the camelCase of the HTTP response directives they + // carry, and the `directives` enum above spells the same names on the wire + // (`max-age`). They are `externalVocabulary` mirrors under #14478 ruling B: + // renaming them to `maxAgeSeconds` would break the correspondence that lets a + // reader match this object to the `Cache-Control` header it becomes. The + // describe still states the unit, and the reference page prints it as "unit + // per the named standard". + // + // `stale-while-revalidate` and `stale-if-error` are RFC 5861, NOT RFC 9111 — + // RFC 9111 defines neither. Attribution corrected against the directives + // themselves rather than inherited (#15676). + maxAge: z.number().optional().describe('Maximum cache age in seconds') + .meta({ externalVocabulary: 'HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)' }), + staleWhileRevalidate: z.number().optional().describe('Allow serving stale content while revalidating (seconds)') + .meta({ externalVocabulary: 'HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)' }), + staleIfError: z.number().optional().describe('Allow serving stale content on error (seconds)') + .meta({ externalVocabulary: 'HTTP Cache-Control `stale-if-error` (RFC 5861 §4)' }), })); export type CacheControl = z.input; diff --git a/packages/spec/src/api/storage.zod.ts b/packages/spec/src/api/storage.zod.ts index 3ab04f1b95..beefdbd081 100644 --- a/packages/spec/src/api/storage.zod.ts +++ b/packages/spec/src/api/storage.zod.ts @@ -41,7 +41,14 @@ export const PresignedUrlResponseSchema = lazySchema(() => BaseResponseSchema.ex fileId: z.string().describe('Temporary File ID'), method: z.enum(['PUT', 'POST']).describe('HTTP Method to use'), headers: z.record(z.string(), z.string()).optional().describe('Required headers for upload'), - expiresIn: z.number().describe('URL expiry in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): this is the AWS SDK + // presigner's own option name, carried end to end — `storage-routes.ts` + // holds it as `expiresIn`, the adapter interface takes it as + // `getSignedUrl(key, expiresIn, …)`, and `s3-storage-adapter.ts` hands it + // to `getSignedUrl(client, cmd, { expiresIn })`. Renaming only where the + // value surfaces to the client would leave one name for one number. + expiresIn: z.number().describe('URL expiry in seconds') + .meta({ externalVocabulary: 'AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)' }), }), })); diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index d8f3f2cbab..d962ab0c06 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -5,6 +5,8 @@ import { PresenceStatus } from './realtime-shared.zod'; // Re-export shared PresenceStatus for backward compatibility import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; +import { retiredKey } from '../shared/retired-key'; export { PresenceStatus } from './realtime-shared.zod'; /** @@ -445,7 +447,7 @@ export type WebSocketConfigParsed = z.infer; * type: 'subscribe', * channel: 'record.account.123', * payload: { events: ['created', 'updated'] }, - * timestamp: Date.now() + * occurredAt: Date.now() * } * ``` * @@ -455,7 +457,7 @@ export type WebSocketConfigParsed = z.infer; * type: 'data-change', * channel: 'record.account.123', * payload: { id: '123', action: 'updated', data: {...} }, - * timestamp: Date.now() + * occurredAt: Date.now() * } * ``` */ @@ -470,7 +472,19 @@ export const WebSocketEventSchema = lazySchema(() => z.object({ ]).describe('Event type'), channel: z.string().describe('Channel identifier (e.g., "record.account.123", "user.456")'), payload: z.unknown().describe('Event payload data'), - timestamp: z.number().describe('Unix timestamp in milliseconds'), + // Renamed from `timestamp` and typed `EpochMs` (#15676, #14478 ruling B): an + // epoch INSTANT, not a duration. `*At` is this package's measured convention + // for an instant and `EpochMs` is where the millisecond unit is declared, so + // the unit no longer lives only in the describe prose. + occurredAt: EpochMs.describe('Unix timestamp in milliseconds when the event occurred'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + timestamp: retiredKey( + '`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()`).', + ), })); export type WebSocketEvent = z.input; @@ -490,7 +504,7 @@ export type WebSocketEvent = z.input; * userId: 'user123', * userName: 'John Doe', * status: 'online', - * lastSeen: Date.now(), + * lastSeenAt: Date.now(), * metadata: { currentPage: '/dashboard' } * } * ``` @@ -499,7 +513,19 @@ export const SimplePresenceStateSchema = lazySchema(() => z.object({ userId: z.string().describe('User identifier'), userName: z.string().describe('User display name'), status: z.enum(['online', 'away', 'offline']).describe('User presence status'), - lastSeen: z.number().describe('Unix timestamp of last activity in milliseconds'), + // Renamed from `lastSeen` and typed `EpochMs` (#15676, #14478 ruling B) — an + // epoch instant, joining the `lastAccessedAt` / `lastUsedAt` family. + lastSeenAt: EpochMs.describe('Unix timestamp of last activity in milliseconds'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + lastSeen: retiredKey( + '`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: z.record(z.string(), z.unknown()).optional().describe('Additional presence metadata (e.g., current page, custom status)'), })); diff --git a/packages/spec/src/contracts/startup-orchestrator.test.ts b/packages/spec/src/contracts/startup-orchestrator.test.ts index 6ed362c031..9888f309b4 100644 --- a/packages/spec/src/contracts/startup-orchestrator.test.ts +++ b/packages/spec/src/contracts/startup-orchestrator.test.ts @@ -51,17 +51,17 @@ describe('Startup Orchestrator Contract', () => { it('should allow a minimal health status', () => { const status: HealthStatus = { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }; expect(status.healthy).toBe(true); - expect(status.timestamp).toBeGreaterThan(0); + expect(status.checkedAt).toBeGreaterThan(0); }); it('should allow a full health status with details', () => { const status: HealthStatus = { healthy: false, - timestamp: Date.now(), + checkedAt: Date.now(), details: { connections: 0, maxConnections: 10 }, message: 'No database connections available', }; @@ -109,7 +109,7 @@ describe('Startup Orchestrator Contract', () => { duration: 50, health: { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), details: { uptime: 1000 }, }, }; @@ -131,7 +131,7 @@ describe('Startup Orchestrator Contract', () => { rollback: async (_startedPlugins) => {}, checkHealth: async (_plugin) => ({ healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }), }; @@ -155,7 +155,7 @@ describe('Startup Orchestrator Contract', () => { })); }, rollback: async () => {}, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; const results = await orchestrator.orchestrateStartup(plugins, { timeout: 5000 }); @@ -168,7 +168,7 @@ describe('Startup Orchestrator Contract', () => { const orchestrator: IStartupOrchestrator = { orchestrateStartup: async () => [], rollback: async () => {}, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), startWithTimeout: async (_plugin, _context, _timeoutMs) => {}, }; @@ -188,7 +188,7 @@ describe('Startup Orchestrator Contract', () => { rolledBack.push(p.name); } }, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; await orchestrator.rollback([ diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index cf8920b04e..ef8c798eab 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -262,9 +262,11 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( .meta({ title: 'Application name' }), /** `statement_timeout` in milliseconds — aborts any statement that runs longer. */ + // `externalVocabulary` mirror (#14478 ruling B): the camelCase of PostgreSQL's + // own `statement_timeout` parameter, which the JSDoc above names directly. statementTimeout: z.number().int().positive().optional() .describe('Abort statements running longer than this (ms)') - .meta({ title: 'Statement timeout (ms)' }), + .meta({ title: 'Statement timeout (ms)', externalVocabulary: 'PostgreSQL `statement_timeout`' }), /** Dev-only, loosen-only schema self-heal (#2186). */ autoMigrate: SqlAutoMigrateSchema.optional(), diff --git a/packages/spec/src/kernel/context.test.ts b/packages/spec/src/kernel/context.test.ts index 723fabe960..baa0c48e1a 100644 --- a/packages/spec/src/kernel/context.test.ts +++ b/packages/spec/src/kernel/context.test.ts @@ -31,7 +31,7 @@ describe('KernelContextSchema', () => { mode: 'production', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), features: {}, }; @@ -84,10 +84,10 @@ describe('KernelContextSchema', () => { expect(() => KernelContextSchema.parse({ instanceId: '550e8400-e29b-41d4-a716-446655440000' })).toThrow(); }); - it('should reject non-integer startTime', () => { + it('should reject non-integer startedAt', () => { expect(() => KernelContextSchema.parse({ ...validContext, - startTime: 1.5, + startedAt: 1.5, })).toThrow(); }); @@ -114,7 +114,7 @@ describe('TenantRuntimeContextSchema', () => { mode: 'production' as const, version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), features: {}, }; diff --git a/packages/spec/src/kernel/context.zod.ts b/packages/spec/src/kernel/context.zod.ts index a3b0731f18..d126374286 100644 --- a/packages/spec/src/kernel/context.zod.ts +++ b/packages/spec/src/kernel/context.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { TenantQuotaSchema } from '../system/tenant.zod.js'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; +import { EpochMs } from '../shared/epoch.zod'; // Retirement prescriptions (#11846, ADR-0049 enforce-or-remove; maintainer // ruling 2026-08-27). Declared with `//` (never `/** */`) and ABOVE the enum's @@ -27,6 +28,14 @@ const RUNTIME_MODE_PREVIEW_RETIRED = + 'job (`OS_PREVIEW_MODE` is routing-only and never touched identity). If a preview ' + 'experience becomes a product capability it re-declares fresh, with the ' + 'production-posture hard-refusal as the first-landed half.'; +const START_TIME_RENAMED = + '`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.'; const PREVIEW_MODE_RETIRED = '`context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, ' @@ -103,7 +112,10 @@ export const KernelContextSchema = lazySchema(() => z.object({ /** * Telemetry */ - startTime: z.number().int().describe('Boot timestamp (ms)'), + // Renamed from `startTime` and typed `EpochMs` (#15676, #14478 ruling B): the + // boot INSTANT. Spelling it `startTimeMs` would have moved it into the `*Ms` + // duration family, which is the confusion the ruling separates. + startedAt: EpochMs.describe('Boot timestamp — Unix milliseconds'), /** * Feature Flags (Global) @@ -120,6 +132,13 @@ export const KernelContextSchema = lazySchema(() => z.object({ * inherits the tombstone. */ previewMode: retiredKey(PREVIEW_MODE_RETIRED), + + /** + * Tombstone for the epoch-instant rename (#15676, ruling B on #14478). + * `TenantRuntimeContextSchema` extends this shape and inherits it, which is + * why the retirement is registered under BOTH def keys. + */ + startTime: retiredKey(START_TIME_RENAMED), })); export type KernelContext = z.input; diff --git a/packages/spec/src/kernel/preview-mode-retirement.test.ts b/packages/spec/src/kernel/preview-mode-retirement.test.ts index 683dfe71b0..a2781e69fb 100644 --- a/packages/spec/src/kernel/preview-mode-retirement.test.ts +++ b/packages/spec/src/kernel/preview-mode-retirement.test.ts @@ -87,7 +87,7 @@ describe("[#11846] RuntimeMode 'preview' retirement", () => { mode: 'preview', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), }); expect(result.success).toBe(false); if (result.success) return; @@ -113,7 +113,7 @@ describe('[#11846] KernelContext.previewMode retirement', () => { mode: 'production', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), } as const; /** The block exactly as the retired docs taught authors to write it. */ diff --git a/packages/spec/src/kernel/service-registry.zod.ts b/packages/spec/src/kernel/service-registry.zod.ts index 56dff4cab5..32e8bcffe2 100644 --- a/packages/spec/src/kernel/service-registry.zod.ts +++ b/packages/spec/src/kernel/service-registry.zod.ts @@ -26,6 +26,7 @@ import { ServiceClusterAnnotationsSchema } from './cluster.zod'; * Different service scoping strategies */ import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; export const ServiceScopeType = z.enum([ 'singleton', // Single instance shared across the application 'transient', // New instance created each time @@ -66,7 +67,9 @@ export const ServiceMetadataSchema = lazySchema(() => z.object({ /** * Registration timestamp (Unix milliseconds) */ - registeredAt: z.number().int().optional() + // Typed `EpochMs` (#15676, #14478 ruling B) — an epoch instant, already + // correctly named `*At`, so the declaration is the whole change here. + registeredAt: EpochMs.optional() .describe('Unix timestamp in milliseconds when service was registered'), /** @@ -263,7 +266,9 @@ export const ScopeInfoSchema = lazySchema(() => z.object({ /** * Creation timestamp (Unix milliseconds) */ - createdAt: z.number().int().describe('Unix timestamp in milliseconds when scope was created'), + // Typed `EpochMs` (#15676, #14478 ruling B) — an epoch instant; no rename, + // the name already carries the `*At` instant convention. + createdAt: EpochMs.describe('Unix timestamp in milliseconds when scope was created'), /** * Number of services in this scope diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index e9b596c7a7..461a113c3d 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -49,7 +49,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate healthy status', () => { const healthyStatus = { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), details: { databaseConnected: true, memoryUsage: 45.2, @@ -63,7 +63,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate unhealthy status with message', () => { const unhealthyStatus = { healthy: false, - timestamp: Date.now(), + checkedAt: Date.now(), message: 'Database connection failed', }; @@ -111,7 +111,7 @@ describe('Startup Orchestrator Protocol', () => { duration: 1250, health: { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }, }; diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 0de4aa3118..767b048ffe 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -29,6 +29,8 @@ import { z } from 'zod'; * } */ import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; +import { retiredKey } from '../shared/retired-key'; export const StartupOptionsSchema = lazySchema(() => z.object({ /** * Maximum time (ms) to wait for each plugin to start @@ -79,7 +81,7 @@ export type StartupOptionsParsed = z.infer; * @example * { * "healthy": true, - * "timestamp": 1706659200000, + * "checkedAt": 1706659200000, * "details": { * "databaseConnected": true, * "memoryUsage": 45.2 @@ -95,7 +97,17 @@ export const HealthStatusSchema = lazySchema(() => z.object({ /** * Health check timestamp (Unix milliseconds) */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds when health check was performed'), + // Renamed from `timestamp` and typed `EpochMs` (#15676, #14478 ruling B): the + // instant the health check ran, named for what it marks. + checkedAt: EpochMs.describe('Unix timestamp in milliseconds when health check was performed'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + timestamp: retiredKey( + '`HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the ' + + 'instant the check RAN 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 `checkedAt`; the value is unchanged (`Date.now()`).', + ), /** * Optional health details (plugin-specific) @@ -125,7 +137,7 @@ export type HealthStatus = z.input; * "duration": 1250, * "health": { * "healthy": true, - * "timestamp": 1706659200000 + * "checkedAt": 1706659200000 * } * } */ diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts new file mode 100644 index 0000000000..2b1d3654f8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. +// `SimplePresenceState.lastSeen` is an epoch INSTANT: it moved onto the shared +// `EpochMs` schema and was renamed `lastSeenAt`, joining this package's +// `lastAccessedAt` / `lastUsedAt` family. +// +// ⚠️ Not to be confused with `api/PresenceState:lastSeen` +// (`api/realtime-shared.zod.ts`), a DIFFERENT key of a different type — an +// ISO-8601 datetime string — which is untouched and stays live. +// +// Semantic entry rather than a D2 conversion, and registered under 18 rather +// than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry +// records: a presence payload is runtime-emitted, never a stored metadata row. +export const entry = 'api/SimplePresenceState:lastSeen'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts new file mode 100644 index 0000000000..94fa184891 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// every `*Ms` key in this package is a duration and spelling an instant that way +// would put it in the family the rule exists to separate it from. +// +// Registered here but NOT in `src/conversions/registry.ts`, the +// `kernel/KernelContext:previewMode` reasoning: a WebSocket event is a RUNTIME +// wire payload emitted by the transport, never a stack collection member and +// never stored as a `sys_metadata` row, so a MetadataConversion would be a +// transform with no seam that ever runs. The prescription reaches consumers +// through the tombstone plus the D3 semantic entry `epoch-instant-keys-renamed` +// — which is exactly what ruling B prescribes for a runtime-emitted key. +// +// Registered under 18, not 17, for the reason the previewMode entry records: +// 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. +export const entry = 'api/WebSocketEvent:timestamp'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts new file mode 100644 index 0000000000..390d683ddd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` +// is the instant the health check RAN: it moved onto the shared `EpochMs` schema +// and was renamed `checkedAt`, which also states what the instant marks. +// +// Semantic entry rather than a D2 conversion, and registered under 18 rather +// than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry +// records: a health report is emitted by the startup orchestrator at runtime, +// never authored into a metadata document. +export const entry = 'kernel/HealthStatus:timestamp'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts new file mode 100644 index 0000000000..cdda98d9b8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. `KernelContext.startTime` +// is the boot INSTANT: it moved onto the shared `EpochMs` schema and was renamed +// `startedAt`. +// +// Semantic entry rather than a D2 conversion, the same disposition +// `kernel/KernelContext:previewMode` already carries on this very def: a kernel +// context is constructed by HOST CODE at boot — not a stack collection member +// (`PLURAL_TO_SINGULAR` has no entry for it), never stored as a `sys_metadata` +// row — so the conversion chain has no seam that would ever see one. +// +// Registered under 18, not 17, for the reason that sibling entry records. +export const entry = 'kernel/KernelContext:startTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts new file mode 100644 index 0000000000..87aea050a2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the walked-shape copy of `kernel/KernelContext:startTime`. +// `TenantRuntimeContextSchema` extends `KernelContextSchema`, so it inherits +// both the renamed `startedAt` key and the tombstone; the authorable-surface +// ratchet records the two copies separately, so both are declared here. The +// `previewMode` retirement registered its two copies the same way. +export const entry = 'kernel/TenantRuntimeContext:startTime'; diff --git a/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts new file mode 100644 index 0000000000..6548bd6e6e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'epoch-instant-keys-renamed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'four epoch-instant keys whose name carried no unit: ' + + 'WebSocketEvent.timestamp, SimplePresenceState.lastSeen, ' + + 'KernelContext.startTime (inherited by TenantRuntimeContext) and ' + + 'HealthStatus.timestamp', + replacement: + 'the same instants named for what they mark and typed with the new shared ' + + 'EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt ' + + 'and checkedAt. The VALUE is unchanged in every case — still ' + + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + + 'and the declared schema move', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + + 'Epoch instants are the first class. They read to the rule exactly like ' + + 'an offending duration — a bare name plus a describe that says ' + + '"milliseconds" — but renaming them the way the rule prescribes would ' + + 'resolve the wrong confusion: measured on this package 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 ' + + 'with the Ms suffix would move it INTO the duration family. So the ' + + 'exemption is a declaration on the contract: the value becomes EpochMs, ' + + 'which states the epoch-millisecond unit once, and the key takes this ' + + 'package established At convention. Two of the six instants ruling B ' + + 'names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were ' + + 'already correctly named and only changed schema, so they are not ' + + 'retirements and appear in no table. A SEMANTIC entry rather than a D2 ' + + 'conversion because all four keys are RUNTIME-EMITTED — a WebSocket ' + + 'event and a presence payload are wire messages, a kernel context is ' + + 'constructed by host code at boot, a health report is emitted by the ' + + 'startup orchestrator — so none is ever stored as a sys_metadata row and ' + + 'the conversion chain has no seam that would see one. That is the same ' + + 'disposition kernel/KernelContext:previewMode already carries on one of ' + + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + + 'conversion where the key is authorable, a semantic entry where it is ' + + 'runtime-emitted. #15676, #14478, ADR-0087.', + acceptanceCriteria: + 'No producer emits the old key and no consumer reads it. All four are ' + + 'tombstoned with retiredKey(), so each fails tsc at the construction ' + + 'site (the key types never) and fails the parse with the rename ' + + 'prescription. Concretely, check four places. (1) Code building a ' + + 'WebSocketEvent: rename timestamp to occurredAt. (2) Code building a ' + + 'SimplePresenceState: rename lastSeen to lastSeenAt — and note that the ' + + 'neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a ' + + 'DIFFERENT key holding an ISO-8601 datetime string, which is untouched ' + + 'and must not be renamed with it. (3) Host boot code composing a ' + + 'KernelContext or a TenantRuntimeContext: rename startTime to startedAt. ' + + '(4) Code building a kernel HealthStatus: rename timestamp to checkedAt. ' + + 'In every case the value is carried across unchanged. One behavioural ' + + 'note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were ' + + 'declared z.number() with no integer constraint and EpochMs is ' + + 'z.number().int(), so a fractional epoch that used to parse is now ' + + 'refused at those two sites — a tightening, and Date.now() has always ' + + 'satisfied it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c998856791..cd898ad512 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6618,6 +6618,67 @@ const step18: MigrationStep = { + '(`{"address.city": …}`) needs NO action — it is deliberately not judged. Reads complete ' + 'with no `INVALID_FIELD` naming a dotted filter key, at either door.', }, + { + id: 'epoch-instant-keys-renamed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'four epoch-instant keys whose name carried no unit: ' + + 'WebSocketEvent.timestamp, SimplePresenceState.lastSeen, ' + + 'KernelContext.startTime (inherited by TenantRuntimeContext) and ' + + 'HealthStatus.timestamp', + replacement: + 'the same instants named for what they mark and typed with the new shared ' + + 'EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt ' + + 'and checkedAt. The VALUE is unchanged in every case — still ' + + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + + 'and the declared schema move', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + + 'Epoch instants are the first class. They read to the rule exactly like ' + + 'an offending duration — a bare name plus a describe that says ' + + '"milliseconds" — but renaming them the way the rule prescribes would ' + + 'resolve the wrong confusion: measured on this package 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 ' + + 'with the Ms suffix would move it INTO the duration family. So the ' + + 'exemption is a declaration on the contract: the value becomes EpochMs, ' + + 'which states the epoch-millisecond unit once, and the key takes this ' + + 'package established At convention. Two of the six instants ruling B ' + + 'names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were ' + + 'already correctly named and only changed schema, so they are not ' + + 'retirements and appear in no table. A SEMANTIC entry rather than a D2 ' + + 'conversion because all four keys are RUNTIME-EMITTED — a WebSocket ' + + 'event and a presence payload are wire messages, a kernel context is ' + + 'constructed by host code at boot, a health report is emitted by the ' + + 'startup orchestrator — so none is ever stored as a sys_metadata row and ' + + 'the conversion chain has no seam that would see one. That is the same ' + + 'disposition kernel/KernelContext:previewMode already carries on one of ' + + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + + 'conversion where the key is authorable, a semantic entry where it is ' + + 'runtime-emitted. #15676, #14478, ADR-0087.', + acceptanceCriteria: + 'No producer emits the old key and no consumer reads it. All four are ' + + 'tombstoned with retiredKey(), so each fails tsc at the construction ' + + 'site (the key types never) and fails the parse with the rename ' + + 'prescription. Concretely, check four places. (1) Code building a ' + + 'WebSocketEvent: rename timestamp to occurredAt. (2) Code building a ' + + 'SimplePresenceState: rename lastSeen to lastSeenAt — and note that the ' + + 'neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a ' + + 'DIFFERENT key holding an ISO-8601 datetime string, which is untouched ' + + 'and must not be renamed with it. (3) Host boot code composing a ' + + 'KernelContext or a TenantRuntimeContext: rename startTime to startedAt. ' + + '(4) Code building a kernel HealthStatus: rename timestamp to checkedAt. ' + + 'In every case the value is carried across unchanged. One behavioural ' + + 'note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were ' + + 'declared z.number() with no integer constraint and EpochMs is ' + + 'z.number().int(), so a fractional epoch that used to parse is now ' + + 'refused at those two sites — a tightening, and Date.now() has always ' + + 'satisfied it.', + }, { id: 'event-name-schema-retired', surface: @@ -9270,6 +9331,37 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // consumers through this tombstone plus the D3 semantic entry // `session-user-language-retired`. 'api/SessionUser:language', + // #15676 — the epoch-instant half of #14478 ruling B. + // `SimplePresenceState.lastSeen` is an epoch INSTANT: it moved onto the shared + // `EpochMs` schema and was renamed `lastSeenAt`, joining this package's + // `lastAccessedAt` / `lastUsedAt` family. + // + // ⚠️ Not to be confused with `api/PresenceState:lastSeen` + // (`api/realtime-shared.zod.ts`), a DIFFERENT key of a different type — an + // ISO-8601 datetime string — which is untouched and stays live. + // + // Semantic entry rather than a D2 conversion, and registered under 18 rather + // 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', + // #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 + // every `*Ms` key in this package is a duration and spelling an instant that way + // would put it in the family the rule exists to separate it from. + // + // Registered here but NOT in `src/conversions/registry.ts`, the + // `kernel/KernelContext:previewMode` reasoning: a WebSocket event is a RUNTIME + // wire payload emitted by the transport, never a stack collection member and + // never stored as a `sys_metadata` row, so a MetadataConversion would be a + // transform with no seam that ever runs. The prescription reaches consumers + // through the tombstone plus the D3 semantic entry `epoch-instant-keys-renamed` + // — which is exactly what ruling B prescribes for a runtime-emitted key. + // + // Registered under 18, not 17, for the reason the previewMode entry records: + // 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', // #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 @@ -9346,6 +9438,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `${defKey}:${name}` membership per def, never by radiating from a neighbour. // See `18.integration__Connector__errorMapping.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:errorMapping', + // #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` + // is the instant the health check RAN: it moved onto the shared `EpochMs` schema + // and was renamed `checkedAt`, which also states what the instant marks. + // + // Semantic entry rather than a D2 conversion, and registered under 18 rather + // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry + // records: a health report is emitted by the startup orchestrator at runtime, + // never authored into a metadata document. + 'kernel/HealthStatus:timestamp', // #12428 — ADR-0049 enforce-or-remove, one symbol over from #12340 (PR #12425) // in the same file and on the same per-key test. `HotReloadManager.startWatching` // contained NO watcher: a guard plus `logger.info('File watching started', @@ -9409,6 +9510,18 @@ 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 #8495 / PR #8666 precedent). 'kernel/KernelContext:previewMode', + // #15676 — the epoch-instant half of #14478 ruling B. `KernelContext.startTime` + // is the boot INSTANT: it moved onto the shared `EpochMs` schema and was renamed + // `startedAt`. + // + // Semantic entry rather than a D2 conversion, the same disposition + // `kernel/KernelContext:previewMode` already carries on this very def: a kernel + // context is constructed by HOST CODE at boot — not a stack collection member + // (`PLURAL_TO_SINGULAR` has no entry for it), never stored as a `sys_metadata` + // row — so the conversion chain has no seam that would ever see one. + // + // Registered under 18, not 17, for the reason that sibling entry records. + 'kernel/KernelContext:startTime', // #11332 — ADR-0049 enforce-or-remove on the plugin manifest's three dead // top-level containers (triage graded 2026-08-23; cloud leg measured clean // 2026-08-29 on #12400 with positive controls). The census found ZERO reads @@ -9905,6 +10018,12 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // precedent). See the base entry for the full record and the // no-D2-conversion reasoning. 'kernel/TenantRuntimeContext:previewMode', + // #15676 — the walked-shape copy of `kernel/KernelContext:startTime`. + // `TenantRuntimeContextSchema` extends `KernelContextSchema`, so it inherits + // both the renamed `startedAt` key and the tombstone; the authorable-surface + // ratchet records the two copies separately, so both are declared here. The + // `previewMode` retirement registered its two copies the same way. + 'kernel/TenantRuntimeContext:startTime', // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key diff --git a/packages/spec/src/shared/epoch.zod.ts b/packages/spec/src/shared/epoch.zod.ts new file mode 100644 index 0000000000..45ee075c4e --- /dev/null +++ b/packages/spec/src/shared/epoch.zod.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * An INSTANT: milliseconds since the Unix epoch (`Date.now()`). + * + * ## Why this exists as a shared schema and not as a naming rule + * + * `check:duration-unit-keys` (#14478, maintainer ruling B) makes a + * duration-shaped `z.number()` carry its unit in its KEY NAME, because two + * sibling keys both spelled `ttl` in different units are indistinguishable at + * the authoring site. An epoch instant is numerically the same shape and reads + * the same way to that rule — `startTime: z.number().describe('Boot timestamp + * (ms)')` names a unit in prose and carries none in the name — but it is a + * DIFFERENT confusion, and renaming it to `startTimeMs` would resolve the wrong + * one: measured on this package's own authorable surface, all 51 distinct `*Ms` + * keys are durations (`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`, …) and + * all 51 distinct `*At` keys are instants (`createdAt`, `expiresAt`, + * `lastUsedAt`, …). Spelling an instant `*Ms` would move it INTO the duration + * family, which is the opposite of the ruling's purpose. + * + * So the exemption is a DECLARATION ON THE CONTRACT, never a gate ledger: + * a key whose value IS this schema is an instant, the gate recognises that + * structurally, and no allowlist anywhere names the key. The ruling's words: + * "epoch instants move to a shared `EpochMs` schema". + * + * ## What it declares + * + * `z.number().int()` — an integer, because `Date.now()` is one and a + * fractional epoch is a bug at the producer, not a value to carry. Sites that + * previously declared a bare `z.number()` are tightened by adopting this; the + * four that already declared `.int()` keep exactly what they had. + * + * No `.min()`: a pre-1970 instant is negative and legitimate, and inventing a + * floor here would refuse data this schema has no business judging. + * + * ## How to use it + * + * Compose it and describe the instant at the site — the site's `.describe()` + * wins over this one, and the reference page prints the site's prose: + * + * ```ts + * createdAt: EpochMs.describe('Unix timestamp in milliseconds when the scope was created'), + * registeredAt: EpochMs.optional().describe('Unix timestamp in milliseconds when the service was registered'), + * ``` + * + * Name the key `*At`. That is this package's measured convention for an + * instant, and it is what keeps an instant out of the `*Ms` duration family. + */ +export const EpochMs = z.number().int().describe('Unix timestamp in milliseconds (epoch)'); +/** + * The value an `EpochMs` key carries: milliseconds since the Unix epoch. + * + * Author state and parsed state coincide (`z.number().int()` has no default and + * no transform), so there is deliberately no `EpochMsParsed` — a permanent + * synonym is a name an author can only pick wrongly. The isomorphism is pinned + * in `type-alias-convention.pin.test.ts` (ADR-0122), so the day this schema + * gains a default or a transform the pin goes red with the alias named. + */ +export type EpochMs = z.input; diff --git a/packages/spec/src/shared/http.zod.ts b/packages/spec/src/shared/http.zod.ts index eea7dd0ba2..fe3b000edd 100644 --- a/packages/spec/src/shared/http.zod.ts +++ b/packages/spec/src/shared/http.zod.ts @@ -136,7 +136,12 @@ export const CorsConfigSchema = lazySchema(() => z.object({ /** * Preflight cache duration in seconds */ - maxAge: z.number().int().optional().describe('Preflight cache duration in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): this key IS the CORS + // `Access-Control-Max-Age` response header, whose value is defined in seconds + // by the standard. Renaming it to `maxAgeSeconds` would break the one-to-one + // reading between this config and the header it emits. + maxAge: z.number().int().optional().describe('Preflight cache duration in seconds') + .meta({ externalVocabulary: 'CORS `Access-Control-Max-Age` (WHATWG Fetch)' }), })); export type CorsConfig = z.input; diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index 24bd628950..b939acaaff 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -33,3 +33,8 @@ export * from './resilient-fetch'; // specifiers and object fields (maintainer ruling 2026-09-02). Declared here so // `system/` and `data/` both reference one schema instead of carrying a copy. export * from './value-domain.zod'; +// [#15676] The shared epoch-milliseconds INSTANT (`EpochMs`), and the first of +// the two structural exemptions ruling B of #14478 declares ON THE SCHEMA +// rather than in a gate ledger: a key whose value is this schema is an instant, +// not a duration, and `check:duration-unit-keys` recognises it structurally. +export * from './epoch.zod'; diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index a6c71b1939..29e5b726b2 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -305,9 +305,15 @@ export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ ), minPasswordLength: z.number().optional().describe('Minimum password length (default 8)'), maxPasswordLength: z.number().optional().describe('Maximum password length (default 128)'), + // `externalVocabulary` mirror (#14478 ruling B): this object's own describe + // says its options are "forwarded to better-auth", and every sibling here is + // a better-auth option name verbatim (`disableSignUp`, + // `requireEmailVerification`, `minPasswordLength`, `autoSignIn`, + // `revokeSessionsOnPasswordReset`). A key that is forwarded by name cannot be + // renamed without breaking the forwarding. resetPasswordTokenExpiresIn: z.number().optional().describe( 'Reset-password token TTL in seconds (default 3600)' - ), + ).meta({ externalVocabulary: 'better-auth `emailAndPassword.resetPasswordTokenExpiresIn`' }), autoSignIn: z.boolean().optional().describe('Auto sign-in after sign-up (default true)'), revokeSessionsOnPasswordReset: z.boolean().optional().describe( 'Revoke all other sessions on password reset' @@ -327,9 +333,11 @@ export const EmailVerificationConfigSchema = lazySchema(() => z.object({ autoSignInAfterVerification: z.boolean().optional().describe( 'Auto sign-in the user after email verification' ), + // `externalVocabulary` mirror (#14478 ruling B) — forwarded to better-auth by + // name, as this object's own describe states. expiresIn: z.number().optional().describe( 'Verification token TTL in seconds (default 3600)' - ), + ).meta({ externalVocabulary: 'better-auth `emailVerification.expiresIn`' }), }).optional().describe('Email verification options forwarded to better-auth')); /** @@ -547,7 +555,11 @@ export const AuthConfigSchema = lazySchema(() => z.object({ providers: z.array(AuthProviderConfigSchema).optional(), plugins: AuthPluginConfigSchema.optional(), session: z.object({ - expiresIn: z.number().default(60 * 60 * 24 * 7).describe('Session duration in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): better-auth's own + // `session.expiresIn` / `session.updateAge` pair, forwarded by name — the + // defaults above are that library's defaults (7 days / 1 day). + expiresIn: z.number().default(60 * 60 * 24 * 7).describe('Session duration in seconds') + .meta({ externalVocabulary: 'better-auth `session.expiresIn`' }), updateAge: z.number().default(60 * 60 * 24).describe('Session update frequency'), }).optional(), trustedOrigins: z.array(z.string()).optional().describe( diff --git a/packages/spec/src/system/disaster-recovery.zod.ts b/packages/spec/src/system/disaster-recovery.zod.ts index 6701f7b98a..0732edaea1 100644 --- a/packages/spec/src/system/disaster-recovery.zod.ts +++ b/packages/spec/src/system/disaster-recovery.zod.ts @@ -124,7 +124,11 @@ export const FailoverConfigSchema = lazySchema(() => z.object({ })).min(2).describe('Multi-region configuration (minimum 2 regions)'), /** DNS failover configuration */ dns: z.object({ - ttl: z.number().default(60).describe('DNS TTL in seconds for failover'), + // `externalVocabulary` mirror (#14478 ruling B): the DNS resource-record TTL + // field, whose unit is fixed at seconds by the standard and spelled `ttl` + // by every provider API this key is forwarded to (Route 53, Cloudflare). + ttl: z.number().default(60).describe('DNS TTL in seconds for failover') + .meta({ externalVocabulary: 'DNS resource-record TTL (RFC 1035 §4.1.3)' }), provider: z.enum(['route53', 'cloudflare', 'azure_dns', 'custom']).optional() .describe('DNS provider for automatic failover'), }).optional().describe('DNS failover settings'), diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index 0854886637..d35b46744d 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -194,7 +194,10 @@ export type ObjectMetadata = z.input; */ export const PresignedUrlConfigSchema = lazySchema(() => z.object({ operation: z.enum(['get', 'put', 'delete', 'head']).describe('Allowed operation'), - expiresIn: z.number().min(1).max(604800).describe('Expiration time in seconds (max 7 days)'), + // `externalVocabulary` mirror (#14478 ruling B): the AWS SDK presigner option + // name, and the `.max(604800)` above is that standard's own 7-day ceiling. + expiresIn: z.number().min(1).max(604800).describe('Expiration time in seconds (max 7 days)') + .meta({ externalVocabulary: 'AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)' }), contentType: z.string().optional().describe('Required content type for PUT operations'), maxSize: z.number().min(0).optional().describe('Maximum file size in bytes for PUT operations'), responseContentType: z.string().optional().describe('Override content-type for GET operations'), diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f8c8df2da3..136b387e47 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -268,9 +268,11 @@ import type * as M170 from './ui/component.zod.js'; // [#10235] The served sortability projection — new module, next free index. import type * as M183 from './api/sortability.zod.js'; import type * as M184 from './shared/value-domain.zod.js'; +// [#15676] The shared epoch-millisecond instant — new module, next free index. +import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 826 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1044,6 +1046,11 @@ export type Iso501 = Assert, // shared/protection.zod.ts export type Iso502 = Assert, z.infer< typeof M115.ProtectionSchema > >>; +// shared/epoch.zod.ts — the shared epoch-millisecond INSTANT (#15676), the +// first of the two exemptions ruling B on #14478 declares on the schema. +// `z.number().int()`: no default, no transform, the (RISE) case. +export type Iso868 = Assert, z.infer< typeof M185.EpochMs > >>; + // shared/value-domain.zod.ts — the ONE standard-domain vocabulary (#14168); // `SpecifierValueDomainSchema` (Iso758) is an alias of it, so both pins hold // or fall together. A `z.enum` has no default or transform, the (RISE) case. @@ -1685,7 +1692,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 825 isomorphic pins', () => { + it('still declares all 826 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2116,6 +2123,12 @@ describe('ADR-0122 type-alias convention', () => { // `SpecifierValueDomainSchema` became an alias of it, so its own pin // (`Iso758`) stays and the two hold or fall together. +1 added. // + // 825 -> 826 is #15676's `EpochMs` (shared/epoch.zod.ts) — the shared + // epoch-millisecond instant that ruling B on #14478 declares as the first + // of the duration rule's two structural exemptions. A bare + // `z.number().int()` with no default and no transform: the (RISE) case, + // one new pin (`Iso868`). +1 added. + // // 830 -> 828 is #14180's ADR-0049 retirement of the `metadata:changed` // event payload (kernel/cluster.zod.ts): `MetadataChangedEventPayloadSchema` // — a MUST-emit contract nothing ever produced or consumed, whose @@ -2146,7 +2159,7 @@ describe('ADR-0122 type-alias convention', () => { // earlier: `ElementRecordPickerPropsParsed` declared, the Iso819 pin // deleted. -1 converted to an `XParsed` pair; the Iso number stays vacant // (ids are claims about pins, not positions). - expect(pins).toHaveLength(825); + expect(pins).toHaveLength(826); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index a3e50816ce..b37beedfc2 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -30,6 +30,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, +- `node_modules/@objectstack/spec/src/shared/epoch.zod.ts` — Exports: EpochMs - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema