diff --git a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..148570312c --- /dev/null +++ b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md @@ -0,0 +1,11 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) + +The dashboard key catalogue `os explain` prints lists +`refreshIntervalSeconds` instead of `refreshInterval`, following the +`@objectstack/spec` rename of the authored key (the unit now lives in the key +name). Same key, same seconds; no other command output and no public surface of +this package changes. diff --git a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c66ebc58f2 --- /dev/null +++ b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md @@ -0,0 +1,138 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) + + + +**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped +as `minor` under the repo's launch-window convention for breaking changes; the +hand-migration prescriptions are registered under protocol major 18. Maintainer +ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in +the key NAME, never only in its `.describe()` prose, and grandfathers no existing +offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 +(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 +(#15679) cleared `system/`. This card clears the remainder, and is the first +where the gate itself reads **`zero offenders`** and exits `0`. + +⚠️ That is green **for the gate's currently declared population** +(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has +already measured an offender outside this subtree, so the gate is expected to go +red again by design. This changeset does not claim #14478 is finished. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | +| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | +| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | +| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `TursoConfig.timeout` | `timeoutMs` | milliseconds | +| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | +| `ConversationAnalytics.duration` | `durationSeconds` | seconds | + +**Every value is unchanged** — only key names move. The two keys that carried a +default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to +60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare +none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything +under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and +`TursoConfig.timeoutMs` still refuse a zero or negative integer, and +`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old +spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site +(input type `never`) and fails the parse with the rename prescription rather than +a bare unrecognized-key error. + +`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` +— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming +the tombstone would have prescribed a key the shape refuses, which is the one +failure this rename could have introduced silently; a pin asserts all three. + +## ⚠️ `dashboard.refreshInterval` crosses a repository boundary + +This is the only rename in the whole stack whose consumer is in **another +repository**, so its reader could not move in this PR the way every other reader +in this card did. objectui's dashboard renderer reads the key, multiplies by +1000 to drive a `setInterval`, and republishes it as an authoring input the +console offers. Those sites move in a follow-up objectui card, sequenced behind +a release that actually ships this rename. + +Until that lands the renderer sees an absent key and simply does not start its +refresh timer — a dashboard still renders, and still refreshes when the user +asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards +and `os migrate meta` correct in the meantime. + +## ⚠️ An eighth key moves that the gate did not list + +`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` +named no unit at all, and the predicate judges prose against name. + +It moves anyway because it is not a second key. `persistence: { type: 'auto' }` +resolves to the same Node.js file adapter as `type: 'file'`, and this value is +forwarded to the same `FileSystemPersistenceAdapter` field, in the same +milliseconds, under the same `min(100)` bound. Renaming one arm and not the other +would have left one value with two spellings across sibling arms of one union, +and the driver reading both — the consumer-side dialect Prime Directive #12 +forbids. Its describe now names the unit too, and a pin asserts the refusal on +the arm the gate never listed, so a later reader cannot "restore" the bare +spelling as an over-application of the rule. + +## Dispositions — four D2 conversions, two semantic entries + +Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and +unlike card 4/6 this card's answer is split. + +**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` +are each a stack collection whose members are stored whole as `sys_metadata` +rows, so the conversion chain has a seam that sees them: +`dashboard-refresh-interval-to-refresh-interval-seconds`, +`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one +pass, emitting separately), +`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and +`turso-config-timeout-to-timeout-ms`. The two datasource conversions are +driver-aware for the reason `datasource-config-driver-key-aliases` records: a +bare `config.timeout` under another driver is that driver's own key and must not +be touched. + +**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime +and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument +reached only through `AggregationPipeline.options`. Neither is a stack collection +member or a stored row, so the chain has no seam — the disposition every +runtime-emitted measurement in this stack has taken. + +All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + +## A retirement tombstone is no longer read as a secret + +`refusedCredentialKeys` derives a driver's refused inline credentials by finding +`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a +`z.never()`, and until this card no driver contract carried one — so "never ⇒ +credential" held by accident of population rather than by construction. The first +tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a +millisecond budget was a secret: it was redacted off the datasource read path and +dragged a non-credential name into the fallback list every unrecognised driver is +scrubbed by. + +The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` +itself stamps. The exclusion is deliberately **negative** — skip declared +tombstones — rather than positive (keep only keys marked `format: 'password'`), +even though every credential slot in every builtin contract does carry that +marker today: under-redacting is the dangerous direction, so a future credential +key whose author forgets the marker is still scrubbed, and only a key that has +explicitly declared itself retired may drop out. Both directions are pinned. + +## Keys deliberately left alone + +`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` +already carried their unit — they are the same-shape neighbours that made the +bare `timeout` and `monitoringWindow` collisions visible, and pins assert they +did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every +number on `ConversationAnalytics` other than the duration is a count of messages, +tokens or events: a count has no unit to carry. The turso schema shipped by +`@objectstack/driver-turso` is a separate declaration outside this gate's +declared population and is not touched here; card 6/6 owns it, so the two +declarations disagree by design until that lands. diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md new file mode 100644 index 0000000000..aa551961ff --- /dev/null +++ b/.changeset/driver-memory-auto-save-interval-ms.md @@ -0,0 +1,28 @@ +--- +"@objectstack/driver-memory": minor +--- + +feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + + + +**BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and +`FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both +renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of +the authored keys on both persistence arms. + +Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The +option was always milliseconds — it is passed straight to `setInterval` — and the +spec's `min(100)` bound is what made the bare name dangerous rather than untidy: +100 reads as a plausible number of seconds, so an author who guessed the unit +wrong cleared the bound, was refused nowhere, and saved a thousand times more +often than intended. + +Both persistence arms move together: `type: 'auto'` resolves to this same file +adapter and forwards the same field, so this package reads exactly one spelling +rather than two. + +```diff +- new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) ++ new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) +``` diff --git a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..6b6b872d5c --- /dev/null +++ b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md @@ -0,0 +1,12 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) + +The `metadataForms.dashboard` translation bundles key the auto-refresh field as +`refreshIntervalSeconds`, following the `@objectstack/spec` rename of the +authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the +hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across +the rename unchanged, because the field still means what it meant and each help +text already named the unit. diff --git a/.changeset/service-datasource-turso-timeout-ms-reader.md b/.changeset/service-datasource-turso-timeout-ms-reader.md new file mode 100644 index 0000000000..cd2e32c621 --- /dev/null +++ b/.changeset/service-datasource-turso-timeout-ms-reader.md @@ -0,0 +1,57 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) + +`buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — +still consulted `config.timeout` after #15680 renamed that authored key to +`timeoutMs` and tombstoned the old spelling. A turso datasource authored the +canonical way therefore reached the seam, matched nothing, and had its timeout +**silently dropped**: no diagnostic in any channel. + +The reader now consults `config.timeoutMs`. The DRIVER key it lands on is +unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is +published-but-inert (#16024), and renaming an inert key would ratify it as real, +which is what ADR-0049 exists to prevent. So this seam is the one place the +authored and driver spellings differ, and it now says so. + +## No fallback arm for the retired spelling — the seam's own precedent + +Both sibling arms in `default-datasource-driver-factory.ts` already answer this +in the same words: sqlite's "`filename` is the whole contract … so no `??` +tolerance survives here", mongo's "`url` is the one spelling". A renamed +datasource config key reaches a reader already canonical from two directions — +authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` +`never` plus a parse-time prescription), and a stored `sys_metadata` row replays +the full ADR-0087 chain including `retiredFromLoadPath` entries at +`loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion +`turso-config-timeout-to-timeout-ms` has rewritten the key before this table +sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a +spelling both doors have closed. + +`authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route +(host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs +itself, which never meets the authoring schema), not for a retired spelling. + +## Why the covering test did not catch it, and what replaces it + +`TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +rename through it — the tombstone's type channel, which caught the alias tables +elsewhere in this stack, does not reach here. And the covering test authored the +**retired** spelling at all three of its turso `config` sites, so it was green +for exactly the behaviour that had become wrong. A test that pins the retired +spelling cannot notice this class of bug. + +The three sites now author the canonical spelling, and the file gains cases +DERIVED from the authoring contract rather than written against today's key +list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert +that (a) every canonical replacement is consulted by some reader, and (b) no +retired spelling is — probed at every JS type a reader could type-test, with a +vacuity guard so a mis-derived empty list fails instead of passing. They hold +for the next rename without being edited. + +The two sibling pins that author the same spec — `packages/cli`'s driver +correspondence check and `packages/runtime`'s cross-loader convergence check — +move to the canonical spelling with it; their assertions read driver keys and +are unchanged. diff --git a/.changeset/system-duration-keys-unit-in-key-name.md b/.changeset/system-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c77b197fab --- /dev/null +++ b/.changeset/system-duration-keys-unit-in-key-name.md @@ -0,0 +1,110 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) + + + +**BREAKING** — fifteen published `system/` duration keys are renamed and +tombstoned. Shipped as `minor` under the repo's launch-window convention for +breaking changes; the hand-migration prescriptions are registered under protocol +major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, +「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared +`kernel/`; this card clears `system/`. Measured with the gate itself: +`src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls +**22 → 7**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `CacheTier.ttl` | `ttlSeconds` | seconds | +| `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | +| `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | +| `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | +| `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | +| `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | +| `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | +| `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | +| `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | +| `StorageConnection.timeout` | `timeoutMs` | milliseconds | +| `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | +| `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | +| `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | +| `Span.duration` | `durationMs` | milliseconds | +| `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | + +**Every value is unchanged** — only key names move, and every default moves with +its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to +300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, +`RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so +`syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything +under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` +at the authoring site (input type `never`) and fails the parse with the rename +prescription rather than a bare unrecognized-key error. + +## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them + +`AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. +Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare +name under an `externalVocabulary` marker. + +The asymmetry is the whole point. Every bucket-CORS standard the first value is +forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, +Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation +from* the cited standard rather than a mirror of it. The Fetch response header +the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. +A find-and-replace across both leaves no gate red: the marker exempts the twin +either way. A pin test in `object-storage.test.ts` is the only guard. + +## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` + +The gate prints `sizeSeconds` for the two `window.size` keys, and that name is +wrong on its face. `size` means a byte or row count everywhere else in this spec +— `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and +`MetricExportConfig.batch.size` on the very same file is a record count — so +`sizeSeconds` would have kept the misleading half of the name and bolted a unit +onto it. `windowSeconds` was rejected for a plainer reason: the parent key is +already `window`, so it would read `window.windowSeconds`. + +`durationSeconds` names what the number is, and the file supplied its own +precedent: `ServiceLevelObjective.period.duration` already called a period +length a duration. After the rename all three read alike. The prescription says +so explicitly, so the next author does not read the departure as a slip and +"correct" it back to the mechanical name. + +## Dispositions — eight semantic entries, no D2 conversion + +Justified per key rather than defaulted, and this card's answer is uniform: +**none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs +over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, +`disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or +`worker` root — none of these twelve defs is a stack collection member or a +registered metadata kind stored as a `sys_metadata` row, so the conversion chain +has no seam that would see one. They are host configuration (`CacheTier`, +`FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, +`QueueConfig`), call arguments (`CollaborationSessionConfig`) and +runtime-emitted measurements (`Span`). Each therefore carries a **semantic** +entry, which is what ruling B prescribes for a key that is not authorable stack +metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, +nested spellings included. + +## Keys deliberately left alone + +`FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS +resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. +`CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is +milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must +not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a +record count and `QueueConfig.rateLimit.max` is a task count: neither is a +duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s +burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit +anywhere in their prose, so both are outside the gate's population entirely. +Pin tests assert each of these, so a later sweep cannot read this card as +"every duration-shaped number on these files". diff --git a/content/docs/references/ai/conversation.mdx b/content/docs/references/ai/conversation.mdx index 06c181bcf0..cb39926f0b 100644 --- a/content/docs/references/ai/conversation.mdx +++ b/content/docs/references/ai/conversation.mdx @@ -58,7 +58,8 @@ const result = CodeContentSchema.parse(data); | **summarizationEvents** | `integer` | optional (default: `0`) | | | **tokensSavedByPruning** | `integer` | optional (default: `0`) | | | **tokensSavedBySummarization** | `integer` | optional (default: `0`) | | -| **duration** | `number` | optional | Session duration in seconds | +| **durationSeconds** | `number` | optional | Session duration in seconds | +| **duration** | `never` | optional | [REMOVED] `ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged. | | **firstMessageAt** | `string` | optional | ISO 8601 timestamp | | **lastMessageAt** | `string` | optional | ISO 8601 timestamp | diff --git a/content/docs/references/data/driver-memory.mdx b/content/docs/references/data/driver-memory.mdx index 03e63e948c..2c40acb3b2 100644 --- a/content/docs/references/data/driver-memory.mdx +++ b/content/docs/references/data/driver-memory.mdx @@ -44,7 +44,8 @@ Auto-detect persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'auto'` | ✅ | | | **path** | `string` | optional | File path override for Node.js environments | -| **autoSaveInterval** | `number` | optional | Auto-save interval override for Node.js environments | +| **autoSaveIntervalMs** | `number` | optional | Auto-save interval override for Node.js environments, in milliseconds | +| **autoSaveInterval** | `never` | optional | [REMOVED] `AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **key** | `string` | optional | localStorage key override for browser environments | @@ -60,7 +61,8 @@ File-system persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'file'` | ✅ | | | **path** | `string` | optional | File path to persist data | -| **autoSaveInterval** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveIntervalMs** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveInterval** | `never` | optional | [REMOVED] `FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) and the 2000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/data/driver-nosql.mdx b/content/docs/references/data/driver-nosql.mdx index 5e79c0aa0e..b31d7ba7e9 100644 --- a/content/docs/references/data/driver-nosql.mdx +++ b/content/docs/references/data/driver-nosql.mdx @@ -29,7 +29,7 @@ const result = AggregationPipelineSchema.parse(data); | :--- | :--- | :--- | :--- | | **collection** | `string` | ✅ | Collection/table name | | **stages** | `{ operator: string; options: Record }[]` | ✅ | Aggregation pipeline stages | -| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeout?: integer; … }` | optional | Query options | +| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeoutMs?: integer; … }` | optional | Query options | ### Nested Shape: `AggregationPipeline.stages[number]` @@ -45,7 +45,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | @@ -319,7 +320,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 19ca0d1567..c8233200b7 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -71,7 +71,8 @@ Turso / libSQL Connection Configuration | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | | **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | -| **timeout** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeoutMs** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeout** | `never` | optional | [REMOVED] `turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **mode** | `Enum<'local' \| 'replica' \| 'remote'>` | optional | Force a transport mode instead of inferring it from `url` | ### Nested Shape: `TursoConfig.sync` diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index f9f079593a..96c7ed31bf 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -148,7 +148,8 @@ Circuit breaker configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -272,7 +273,8 @@ Circuit breaker configuration | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Connector.syncConfig` @@ -440,7 +442,8 @@ Connector health configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -597,7 +600,8 @@ Connector status | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -755,7 +759,8 @@ Connector type | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `DeclarativeConnectorEntry.syncConfig` diff --git a/content/docs/references/system/cache.mdx b/content/docs/references/system/cache.mdx index f2ae75b68b..942758dceb 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -53,7 +53,7 @@ Cache avalanche/stampede prevention configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **jitterTtl** | `{ enabled: boolean; maxJitterSeconds: number }` | optional | TTL jitter to prevent simultaneous expiration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeout: number }` | optional | Circuit breaker for backend protection | +| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutSeconds: number }` | optional | Circuit breaker for backend protection | | **lockout** | `{ enabled: boolean; lockTimeoutMs: number }` | optional | Lock-based stampede prevention | ### Nested Shape: `CacheAvalanchePrevention.jitterTtl` @@ -69,7 +69,8 @@ Cache avalanche/stampede prevention configuration | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable circuit breaker for backend protection | | **failureThreshold** | `number` | optional (default: `5`) | Failures before circuit opens | -| **resetTimeout** | `number` | optional (default: `30`) | Seconds before half-open state | +| **resetTimeoutSeconds** | `number` | optional (default: `30`) | Seconds before half-open state | +| **resetTimeout** | `never` | optional | [REMOVED] `CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to `resetTimeoutSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `lockout` block on this same schema already spells `lockTimeoutMs`. Rename the key to `resetTimeoutSeconds`; the value (seconds) and the 30 default are unchanged. Note the two are NOT the same unit: this one is seconds, `lockTimeoutMs` is milliseconds. | ### Nested Shape: `CacheAvalanchePrevention.lockout` @@ -90,7 +91,7 @@ Top-level application cache configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable application-level caching | -| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttl: number; … }[]` | ✅ | Ordered cache tier hierarchy | +| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds: number; … }[]` | ✅ | Ordered cache tier hierarchy | | **invalidation** | `{ trigger: Enum<'create' \| 'update' \| 'delete' \| 'manual'>; scope: Enum<'key' \| 'pattern' \| 'tag' \| 'all'>; pattern?: string; tags?: string[] }[]` | ✅ | Cache invalidation rules | | **prefetch** | `boolean` | optional (default: `false`) | Enable cache prefetching | | **compression** | `boolean` | optional (default: `false`) | Enable data compression in cache | @@ -105,7 +106,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -178,7 +180,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -211,7 +214,7 @@ Distributed cache configuration with consistency and avalanche prevention | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable application-level caching | -| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttl?: number; … }[]` | ✅ | Ordered cache tier hierarchy | +| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds?: number; … }[]` | ✅ | Ordered cache tier hierarchy | | **invalidation** | `{ trigger: Enum<'create' \| 'update' \| 'delete' \| 'manual'>; scope: Enum<'key' \| 'pattern' \| 'tag' \| 'all'>; pattern?: string; tags?: string[] }[]` | ✅ | Cache invalidation rules | | **prefetch** | `boolean` | optional (default: `false`) | Enable cache prefetching | | **compression** | `boolean` | optional (default: `false`) | Enable data compression in cache | @@ -229,7 +232,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -249,7 +253,7 @@ Rule defining when and how cached entries are invalidated | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **jitterTtl** | `{ enabled?: boolean; maxJitterSeconds?: number }` | optional | TTL jitter to prevent simultaneous expiration | -| **circuitBreaker** | `{ enabled?: boolean; failureThreshold?: number; resetTimeout?: number }` | optional | Circuit breaker for backend protection | +| **circuitBreaker** | `{ enabled?: boolean; failureThreshold?: number; resetTimeoutSeconds?: number }` | optional | Circuit breaker for backend protection | | **lockout** | `{ enabled?: boolean; lockTimeoutMs?: number }` | optional | Lock-based stampede prevention | ### Nested Shape: `DistributedCacheConfig.warmup` diff --git a/content/docs/references/system/collaboration.mdx b/content/docs/references/system/collaboration.mdx index bb2292e4aa..bc72ef2ddf 100644 --- a/content/docs/references/system/collaboration.mdx +++ b/content/docs/references/system/collaboration.mdx @@ -346,10 +346,11 @@ This schema accepts one of the following structures: | **enablePresence** | `boolean` | optional (default: `true`) | Enable presence tracking | | **enableAwareness** | `boolean` | optional (default: `true`) | Enable awareness state | | **maxUsers** | `integer` | optional | Maximum concurrent users | -| **idleTimeout** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeoutMs** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeout** | `never` | optional | [REMOVED] `CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one was the live 1000x collision the rule exists to remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare name meant five minutes here and three and a half days there. Rename the key to `idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged. | | **conflictResolution** | `Enum<'ot' \| 'crdt' \| 'manual'>` | optional (default: `"ot"`) | Conflict resolution strategy | | **persistence** | `boolean` | optional (default: `true`) | Enable operation persistence | -| **snapshot** | `{ enabled: boolean; interval: integer }` | optional | Snapshot configuration | +| **snapshot** | `{ enabled: boolean; intervalMs: integer }` | optional | Snapshot configuration | ### Nested Shape: `CollaborationSession.users[number]` @@ -421,17 +422,19 @@ This schema accepts one of the following structures: | **enablePresence** | `boolean` | optional (default: `true`) | Enable presence tracking | | **enableAwareness** | `boolean` | optional (default: `true`) | Enable awareness state | | **maxUsers** | `integer` | optional | Maximum concurrent users | -| **idleTimeout** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeoutMs** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeout** | `never` | optional | [REMOVED] `CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one was the live 1000x collision the rule exists to remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare name meant five minutes here and three and a half days there. Rename the key to `idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged. | | **conflictResolution** | `Enum<'ot' \| 'crdt' \| 'manual'>` | optional (default: `"ot"`) | Conflict resolution strategy | | **persistence** | `boolean` | optional (default: `true`) | Enable operation persistence | -| **snapshot** | `{ enabled: boolean; interval: integer }` | optional | Snapshot configuration | +| **snapshot** | `{ enabled: boolean; intervalMs: integer }` | optional | Snapshot configuration | ### Nested Shape: `CollaborationSessionConfig.snapshot` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable periodic snapshots | -| **interval** | `integer` | ✅ | Snapshot interval in milliseconds | +| **intervalMs** | `integer` | ✅ | Snapshot interval in milliseconds | +| **interval** | `never` | optional | [REMOVED] `CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalMs`; the value (milliseconds) is unchanged. | --- diff --git a/content/docs/references/system/disaster-recovery.mdx b/content/docs/references/system/disaster-recovery.mdx index c3799794d0..188e7ba3bc 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -112,7 +112,7 @@ Complete disaster recovery plan configuration | **rpo** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Point Objective | | **rto** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Time Objective | | **backup** | `{ strategy?: Enum<'full' \| 'incremental' \| 'differential'>; schedule?: string \| object; retention: object; destination: object; … }` | ✅ | Backup configuration | -| **failover** | `{ mode?: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover?: boolean; healthCheckInterval?: number; failureThreshold?: number; … }` | optional | Multi-region failover configuration | +| **failover** | `{ mode?: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover?: boolean; healthCheckIntervalSeconds?: number; failureThreshold?: number; … }` | optional | Multi-region failover configuration | | **replication** | `{ mode?: Enum<'synchronous' \| 'asynchronous' \| 'semi_synchronous'>; maxLagSeconds?: number; includeObjects?: string[]; excludeObjects?: string[] }` | optional | Data replication settings | | **testing** | `{ enabled?: boolean; schedule?: string \| object; notificationChannel?: string }` | optional | Automated disaster recovery testing | | **runbookUrl** | `string` | optional | URL to disaster recovery runbook/playbook | @@ -150,7 +150,8 @@ Complete disaster recovery plan configuration | :--- | :--- | :--- | :--- | | **mode** | `Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>` | optional (default: `"active_passive"`) | Failover mode | | **autoFailover** | `boolean` | optional (default: `true`) | Enable automatic failover | -| **healthCheckInterval** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckIntervalSeconds** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckInterval** | `never` | optional | [REMOVED] `FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the value (seconds) and the 30 default are unchanged. | | **failureThreshold** | `number` | optional (default: `3`) | Consecutive failures before failover | | **regions** | `{ name: string; role: Enum<'primary' \| 'secondary' \| 'witness'>; endpoint?: string; priority?: number }[]` | ✅ | Multi-region configuration (minimum 2 regions) | | **dns** | `{ ttl?: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | @@ -194,7 +195,8 @@ Failover configuration | :--- | :--- | :--- | :--- | | **mode** | `Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>` | optional (default: `"active_passive"`) | Failover mode | | **autoFailover** | `boolean` | optional (default: `true`) | Enable automatic failover | -| **healthCheckInterval** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckIntervalSeconds** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckInterval** | `never` | optional | [REMOVED] `FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the value (seconds) and the 30 default are unchanged. | | **failureThreshold** | `number` | optional (default: `3`) | Consecutive failures before failover | | **regions** | `{ name: string; role: Enum<'primary' \| 'secondary' \| 'witness'>; endpoint?: string; priority?: number }[]` | ✅ | Multi-region configuration (minimum 2 regions) | | **dns** | `{ ttl: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | diff --git a/content/docs/references/system/metrics.mdx b/content/docs/references/system/metrics.mdx index 7632ab036e..d1b63b6d4c 100644 --- a/content/docs/references/system/metrics.mdx +++ b/content/docs/references/system/metrics.mdx @@ -77,7 +77,7 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| 'p999' \| 'rate' \| 'stddev'>` | ✅ | Aggregation type | -| **window** | `{ size: integer; sliding: boolean; slideInterval?: integer }` | optional | | +| **window** | `{ durationSeconds: integer; sliding: boolean; slideInterval?: integer }` | optional | | | **groupBy** | `string[]` | optional | Group by label names | | **filters** | `Record` | optional | Filter criteria | @@ -85,7 +85,8 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **size** | `integer` | ✅ | Window size in seconds | +| **durationSeconds** | `integer` | ✅ | Window duration in seconds | +| **size** | `never` | optional | [REMOVED] The aggregation/SLI window key `size` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or row count everywhere else in this spec, so the rename drops it rather than bolting a unit onto it. Rename `window.size` to `window.durationSeconds` on both MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged. | | **sliding** | `boolean` | optional (default: `false`) | | | **slideInterval** | `integer` | optional | | @@ -328,7 +329,7 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>` | ✅ | Aggregation type | -| **window** | `{ size: integer; sliding?: boolean; slideInterval?: integer }` | optional | | +| **window** | `{ durationSeconds: integer; sliding?: boolean; slideInterval?: integer }` | optional | | | **groupBy** | `string[]` | optional | Group by label names | | **filters** | `Record` | optional | Filter criteria | @@ -344,7 +345,7 @@ Service Level Indicator | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | | **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ size: integer; rolling?: boolean }` | ✅ | Measurement window | +| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `MetricsConfig.slos[number]` @@ -358,7 +359,7 @@ Service Level Objective | **description** | `string` | optional | SLO description | | **sli** | `string` | ✅ | SLI name | | **target** | `number` | ✅ | Target percentage | -| **period** | `{ type: Enum<'rolling' \| 'calendar'>; duration?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | +| **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | | **errorBudget** | `{ enabled?: boolean; alertThreshold?: number; burnRateWindows?: object[] }` | optional | | | **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | | | **enabled** | `boolean` | optional (default: `true`) | | @@ -393,7 +394,7 @@ Service Level Indicator | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | | **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ size: integer; rolling?: boolean }` | ✅ | Measurement window | +| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `ServiceLevelIndicator.successCriteria[option 1]` @@ -408,7 +409,8 @@ Service Level Indicator | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **size** | `integer` | ✅ | Window size in seconds | +| **durationSeconds** | `integer` | ✅ | Window duration in seconds | +| **size** | `never` | optional | [REMOVED] The aggregation/SLI window key `size` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or row count everywhere else in this spec, so the rename drops it rather than bolting a unit onto it. Rename `window.size` to `window.durationSeconds` on both MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged. | | **rolling** | `boolean` | optional (default: `true`) | | @@ -427,7 +429,7 @@ Service Level Objective | **description** | `string` | optional | SLO description | | **sli** | `string` | ✅ | SLI name | | **target** | `number` | ✅ | Target percentage | -| **period** | `{ type: Enum<'rolling' \| 'calendar'>; duration?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | +| **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | | **errorBudget** | `{ enabled: boolean; alertThreshold: number; burnRateWindows?: object[] }` | optional | | | **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | | | **enabled** | `boolean` | optional (default: `true`) | | @@ -437,7 +439,8 @@ Service Level Objective | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'rolling' \| 'calendar'>` | ✅ | Period type | -| **duration** | `integer` | optional | Duration in seconds | +| **durationSeconds** | `integer` | optional | Duration in seconds | +| **duration** | `never` | optional | [REMOVED] `ServiceLevelObjective.period.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged. | | **calendar** | `Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'>` | optional | | ### Nested Shape: `ServiceLevelObjective.alerts[number]` diff --git a/content/docs/references/system/object-storage.mdx b/content/docs/references/system/object-storage.mdx index 9e67cf3e5d..bacdc8e6c4 100644 --- a/content/docs/references/system/object-storage.mdx +++ b/content/docs/references/system/object-storage.mdx @@ -44,7 +44,8 @@ const result = AccessControlConfigSchema.parse(data); | **allowedMethods** | `Enum<'GET' \| 'PUT' \| 'POST' \| 'DELETE' \| 'HEAD'>[]` | optional | CORS allowed HTTP methods | | **allowedHeaders** | `string[]` | optional | CORS allowed headers | | **exposeHeaders** | `string[]` | optional | CORS exposed headers | -| **maxAge** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAgeSeconds** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAge** | `never` | optional | [REMOVED] `AccessControlConfig.maxAge` was renamed to `maxAgeSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Every bucket-CORS standard this value is forwarded to already spells the unit (S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds), so the bare name was a deviation from them rather than a mirror of them. Rename the key to `maxAgeSeconds`; the value (seconds) is unchanged. The unrelated `CorsConfig.maxAge` on the shared HTTP surface keeps its name — that one mirrors the Access-Control-Max-Age response header, which carries no unit token. | | **corsEnabled** | `boolean` | optional (default: `false`) | Enable CORS configuration | | **publicAccess** | `{ allowPublicRead: boolean; allowPublicWrite: boolean; allowPublicList: boolean }` | optional | Public access control | | **allowedIps** | `string[]` | optional | Allowed IP addresses/CIDR blocks | @@ -100,7 +101,8 @@ const result = AccessControlConfigSchema.parse(data); | **allowedMethods** | `Enum<'GET' \| 'PUT' \| 'POST' \| 'DELETE' \| 'HEAD'>[]` | optional | CORS allowed HTTP methods | | **allowedHeaders** | `string[]` | optional | CORS allowed headers | | **exposeHeaders** | `string[]` | optional | CORS exposed headers | -| **maxAge** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAgeSeconds** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAge** | `never` | optional | [REMOVED] `AccessControlConfig.maxAge` was renamed to `maxAgeSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Every bucket-CORS standard this value is forwarded to already spells the unit (S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds), so the bare name was a deviation from them rather than a mirror of them. Rename the key to `maxAgeSeconds`; the value (seconds) is unchanged. The unrelated `CorsConfig.maxAge` on the shared HTTP surface keeps its name — that one mirrors the Access-Control-Max-Age response header, which carries no unit token. | | **corsEnabled** | `boolean` | optional (default: `false`) | Enable CORS configuration | | **publicAccess** | `{ allowPublicRead: boolean; allowPublicWrite: boolean; allowPublicList: boolean }` | optional | Public access control | | **allowedIps** | `string[]` | optional | Allowed IP addresses/CIDR blocks | @@ -280,7 +282,8 @@ Lifecycle policy action type | **endpoint** | `string` | optional | Custom endpoint URL | | **region** | `string` | optional | Default region | | **useSSL** | `boolean` | optional (default: `true`) | Use SSL/TLS for connections | -| **timeout** | `number` | optional | Connection timeout in milliseconds | +| **timeoutMs** | `number` | optional | Connection timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `StorageConnection.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `ObjectStorageConfig.buckets[number]` @@ -369,7 +372,8 @@ Storage class/tier for cost optimization | **endpoint** | `string` | optional | Custom endpoint URL | | **region** | `string` | optional | Default region | | **useSSL** | `boolean` | optional (default: `true`) | Use SSL/TLS for connections | -| **timeout** | `number` | optional | Connection timeout in milliseconds | +| **timeoutMs** | `number` | optional | Connection timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `StorageConnection.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | --- diff --git a/content/docs/references/system/registry-config.mdx b/content/docs/references/system/registry-config.mdx index 6d1d28bc3b..5168650457 100644 --- a/content/docs/references/system/registry-config.mdx +++ b/content/docs/references/system/registry-config.mdx @@ -33,13 +33,13 @@ const result = RegistryConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'public' \| 'private' \| 'hybrid'>` | ✅ | Registry deployment type | -| **upstream** | `{ url: string; syncPolicy: Enum<'manual' \| 'auto' \| 'proxy'>; syncInterval?: integer; auth?: object; … }[]` | optional | Upstream registries to sync from or proxy to | +| **upstream** | `{ url: string; syncPolicy: Enum<'manual' \| 'auto' \| 'proxy'>; syncIntervalSeconds?: integer; auth?: object; … }[]` | optional | Upstream registries to sync from or proxy to | | **scope** | `string[]` | optional | npm-style scopes managed by this registry (e.g., @my-corp, @enterprise) | | **defaultScope** | `string` | optional | Default scope prefix for new plugins | | **storage** | `{ backend: Enum<'local' \| 's3' \| 'gcs' \| 'azure-blob' \| 'oss'>; path?: string; credentials?: Record }` | optional | | | **visibility** | `Enum<'public' \| 'private' \| 'internal'>` | optional (default: `"private"`) | Who can access this registry | | **accessControl** | `{ requireAuthForRead: boolean; requireAuthForWrite: boolean; allowedPrincipals?: string[] }` | optional | | -| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer }` | optional | | +| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer }` | optional | | | **mirrors** | `{ url: string; priority: integer }[]` | optional | Mirror registries for redundancy | ### Nested Shape: `RegistryConfig.upstream[number]` @@ -48,10 +48,12 @@ const result = RegistryConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | Upstream registry endpoint | | **syncPolicy** | `Enum<'manual' \| 'auto' \| 'proxy'>` | optional (default: `"auto"`) | Registry synchronization strategy | -| **syncInterval** | `integer` | optional | Auto-sync interval in seconds | +| **syncIntervalSeconds** | `integer` | optional | Auto-sync interval in seconds | +| **syncInterval** | `never` | optional | [REMOVED] `RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the `timeout` beside it on this same block is milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the min-60 bound are unchanged. | | **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api-key' \| 'oauth2'>; username?: string; password?: string; token?: string; … }` | optional | | | **tls** | `{ enabled: boolean; verifyCertificate: boolean; certificate?: string; privateKey?: string }` | optional | | -| **timeout** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `syncIntervalSeconds` on this same block is seconds. Rename the key to `timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are unchanged. | | **retry** | `{ maxAttempts: integer; backoff: Enum<'fixed' \| 'linear' \| 'exponential'> }` | optional | | ### Nested Shape: `RegistryConfig.cache` @@ -59,7 +61,8 @@ const result = RegistryConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | | -| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `RegistryConfig.cache.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` in this same cache block is bytes. Rename the key to `ttlSeconds`; the value (seconds) and the 3600 default are unchanged. | | **maxSize** | `integer` | optional | Maximum cache size in bytes | @@ -86,10 +89,12 @@ Registry synchronization strategy | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | Upstream registry endpoint | | **syncPolicy** | `Enum<'manual' \| 'auto' \| 'proxy'>` | optional (default: `"auto"`) | Registry synchronization strategy | -| **syncInterval** | `integer` | optional | Auto-sync interval in seconds | +| **syncIntervalSeconds** | `integer` | optional | Auto-sync interval in seconds | +| **syncInterval** | `never` | optional | [REMOVED] `RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the `timeout` beside it on this same block is milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the min-60 bound are unchanged. | | **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api-key' \| 'oauth2'>; username?: string; password?: string; token?: string; … }` | optional | | | **tls** | `{ enabled: boolean; verifyCertificate: boolean; certificate?: string; privateKey?: string }` | optional | | -| **timeout** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `syncIntervalSeconds` on this same block is seconds. Rename the key to `timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are unchanged. | | **retry** | `{ maxAttempts: integer; backoff: Enum<'fixed' \| 'linear' \| 'exponential'> }` | optional | | diff --git a/content/docs/references/system/tracing.mdx b/content/docs/references/system/tracing.mdx index bcbae79b68..7061571d30 100644 --- a/content/docs/references/system/tracing.mdx +++ b/content/docs/references/system/tracing.mdx @@ -142,7 +142,8 @@ OpenTelemetry span | **kind** | `Enum<'internal' \| 'server' \| 'client' \| 'producer' \| 'consumer'>` | optional (default: `"internal"`) | Span kind | | **startTime** | `string` | ✅ | Span start time | | **endTime** | `string` | optional | Span end time | -| **duration** | `number` | optional | Duration in milliseconds | +| **durationMs** | `number` | optional | Duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `Span.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | | **status** | `{ code: Enum<'unset' \| 'ok' \| 'error'>; message?: string }` | optional | | | **attributes** | `Record` | optional (default: `{}`) | Span attributes | | **events** | `{ name: string; timestamp: string; attributes?: Record }[]` | optional (default: `[]`) | | diff --git a/content/docs/references/system/worker.mdx b/content/docs/references/system/worker.mdx index 1bf7cd1c0d..796b25cfad 100644 --- a/content/docs/references/system/worker.mdx +++ b/content/docs/references/system/worker.mdx @@ -75,7 +75,7 @@ const result = BatchProgressSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Queue name (snake_case) | | **concurrency** | `integer` | optional (default: `5`) | Max concurrent task executions | -| **rateLimit** | `{ max: integer; duration: integer }` | optional | Rate limit configuration | +| **rateLimit** | `{ max: integer; durationMs: integer }` | optional | Rate limit configuration | | **defaultRetryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer; … }` | optional | Default retry policy for tasks | | **deadLetterQueue** | `string` | optional | Dead letter queue name | | **priority** | `integer` | optional (default: `0`) | Queue priority (lower = higher priority) | @@ -86,7 +86,8 @@ const result = BatchProgressSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **max** | `integer` | ✅ | Maximum tasks per duration | -| **duration** | `integer` | ✅ | Duration in milliseconds | +| **durationMs** | `integer` | ✅ | Duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `QueueConfig.rateLimit.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `TaskResult.durationMs` on this same file already spelled it that way. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `QueueConfig.defaultRetryPolicy` diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index a43e73ba20..d78c62471b 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -34,7 +34,8 @@ const result = DashboardSchema.parse(data); | **widgets** | `{ id: string; title?: string \| Record; description?: string \| Record; type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; … }[]` | ✅ | Widgets to display | | **columns** | `integer` | optional | Number of grid columns (default 12) | | **gap** | `integer` | optional | Space between widgets, in steps of 0.25rem (4 = 1rem) | -| **refreshInterval** | `number` | optional | Auto-refresh interval in seconds | +| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds | +| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration | | **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | | **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index e8942c5745..57481f53ba 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -18,7 +18,7 @@ const salesDashboard = { name: 'sales_overview', label: 'Sales Overview', description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // Refresh every 5 minutes + refreshIntervalSeconds: 300, // Refresh every 5 minutes dateRange: { field: 'close_date', @@ -65,7 +65,7 @@ const salesDashboard = { | `label` | `string` | ✅ | Display label | | `description` | `string` | optional | Dashboard description | | `widgets` | `DashboardWidget[]` | ✅ | Chart and metric widgets | -| `refreshInterval` | `number` | optional | Auto-refresh interval (seconds) | +| `refreshIntervalSeconds` | `number` | optional | Auto-refresh interval (seconds) | | `dateRange` | `object` | optional | Global date range filter | | `globalFilters` | `GlobalFilter[]` | optional | Global filter controls | @@ -449,7 +449,7 @@ const projectDashboard = { name: 'project_overview', label: 'Project Overview', description: 'Real-time project health metrics', - refreshInterval: 60, + refreshIntervalSeconds: 60, dateRange: { field: 'created_at', diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 32c9efe2ab..59ea4b52eb 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -238,7 +238,7 @@ export const SCHEMAS: Record = { { name: 'label', type: 'string', description: 'Display name' }, { name: 'widgets', type: 'Widget[]', description: 'Dashboard widget definitions' }, { name: 'layout', type: 'GridLayout', description: 'Widget positioning' }, - { name: 'refreshInterval', type: 'number', description: 'Auto-refresh interval in seconds' }, + { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh interval in seconds' }, ], example: `{ name: 'project_overview', diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index f1ed2ac785..e903ecf97a 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -474,7 +474,7 @@ describe('#7314 — the shared libSQL config builder against the real TursoDrive concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, } as const; diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 24f354c0bb..1f2dc5ce39 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -201,9 +201,9 @@ export interface InMemoryDriverConfig { * - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled) * - `'file'` — File-system persistence with defaults (Node.js only) * - `'local'` — localStorage persistence with defaults (Browser only) - * - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options + * - `{ type: 'file', path?: string, autoSaveIntervalMs?: number }` — File-system with options * - `{ type: 'local', key?: string }` — localStorage with options - * - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options + * - `{ type: 'auto', path?: string, key?: string, autoSaveIntervalMs?: number }` — Auto-detect with options * - `{ adapter: PersistenceAdapterInterface }` — Custom adapter * * Durability is **opt-in**, as #815 specified ("默认情况下不启用持久化(纯内存,行为不变)", @@ -222,7 +222,7 @@ export interface InMemoryDriverConfig { type?: 'file' | 'local' | 'auto'; path?: string; key?: string; - autoSaveInterval?: number; + autoSaveIntervalMs?: number; adapter?: PersistenceAdapterInterface; }; } @@ -2293,7 +2293,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); this.logger.debug('Auto-detected Node.js environment, using file persistence'); } @@ -2301,7 +2301,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); } else if (persistence.type === 'local') { const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js'); diff --git a/packages/drivers/driver-memory/src/persistence/file-adapter.ts b/packages/drivers/driver-memory/src/persistence/file-adapter.ts index 391e4debd4..1a5a4104a9 100644 --- a/packages/drivers/driver-memory/src/persistence/file-adapter.ts +++ b/packages/drivers/driver-memory/src/persistence/file-adapter.ts @@ -13,14 +13,14 @@ import * as path from 'node:path'; */ export class FileSystemPersistenceAdapter { private readonly filePath: string; - private readonly autoSaveInterval: number; + private readonly autoSaveIntervalMs: number; private dirty = false; private timer: ReturnType | null = null; private currentDb: Record | null = null; - constructor(options?: { path?: string; autoSaveInterval?: number }) { + constructor(options?: { path?: string; autoSaveIntervalMs?: number }) { this.filePath = options?.path || path.join('.objectstack', 'data', 'memory-driver.json'); - this.autoSaveInterval = options?.autoSaveInterval ?? 2000; + this.autoSaveIntervalMs = options?.autoSaveIntervalMs ?? 2000; } /** @@ -67,7 +67,7 @@ export class FileSystemPersistenceAdapter { await this.writeToDisk(this.currentDb); this.dirty = false; } - }, this.autoSaveInterval); + }, this.autoSaveIntervalMs); // Allow process to exit even if timer is running if (this.timer) { diff --git a/packages/drivers/driver-memory/src/persistence/persistence.test.ts b/packages/drivers/driver-memory/src/persistence/persistence.test.ts index f4c5433cb9..8da79c90ef 100644 --- a/packages/drivers/driver-memory/src/persistence/persistence.test.ts +++ b/packages/drivers/driver-memory/src/persistence/persistence.test.ts @@ -44,7 +44,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist and restore data via file adapter', async () => { // Create and populate driver with file persistence const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -59,7 +59,7 @@ describe('InMemoryDriver Persistence', () => { // Create a new driver and verify data is restored const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -84,7 +84,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist updates and deletes', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); @@ -99,7 +99,7 @@ describe('InMemoryDriver Persistence', () => { // Restore const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -194,7 +194,7 @@ describe('InMemoryDriver Persistence', () => { it('should auto-detect Node.js environment and use file persistence with object config', async () => { const filePath = path.join(TEST_DATA_DIR, 'auto-test-db.json'); const driver1 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -206,7 +206,7 @@ describe('InMemoryDriver Persistence', () => { // Restore from file const driver2 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const users = await driver2.find('users', {}); @@ -219,7 +219,7 @@ describe('InMemoryDriver Persistence', () => { describe('Bulk Operations with Persistence', () => { it('should persist bulk creates', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.bulkCreate('items', [ @@ -231,7 +231,7 @@ describe('InMemoryDriver Persistence', () => { await driver1.disconnect(); const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const items = await driver2.find('items', {}); @@ -278,7 +278,7 @@ describe('InMemoryDriver Persistence', () => { it('still persists when a host opts in explicitly', async () => { const filePath = path.join(TEST_DATA_DIR, 'opt-in.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: 'a', name: 'Widget' }); @@ -338,7 +338,7 @@ describe('InMemoryDriver Persistence', () => { process.env.VERCEL = '1'; const filePath = path.join(TEST_DATA_DIR, 'explicit-file-serverless.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: '1', name: 'Widget' }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 46d6a81468..946bb9f9fb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -942,8 +942,8 @@ export const enMetadataForms: NonNullable = { label: "Gap", helpText: "Space between widgets, in steps of 0.25rem (4 = 1rem)" }, - refreshInterval: { - label: "Refresh Interval", + refreshIntervalSeconds: { + label: "Refresh Interval Seconds", helpText: "Auto-refresh (seconds)" }, header: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index efa2656a43..798ede93ef 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const esESMetadataForms: NonNullable = label: "Separación", helpText: "Separación de cuadrícula (unidades Tailwind)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "Intervalo de actualización", helpText: "Actualización automática (segundos)" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 55aa8e3e3c..84015fc32f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const jaJPMetadataForms: NonNullable = label: "間隔", helpText: "グリッド間隔(Tailwind 単位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "更新間隔", helpText: "自動更新(秒)" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 4a804896a8..3cb1ad259b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const zhCNMetadataForms: NonNullable = label: "间距", helpText: "栅格间距(Tailwind 单位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "刷新间隔", helpText: "自动刷新间隔(秒)" }, diff --git a/packages/runtime/src/turso-driver-factory.convergence.test.ts b/packages/runtime/src/turso-driver-factory.convergence.test.ts index 6865ce1847..44834fc906 100644 --- a/packages/runtime/src/turso-driver-factory.convergence.test.ts +++ b/packages/runtime/src/turso-driver-factory.convergence.test.ts @@ -64,7 +64,9 @@ const FULL_SPEC: DatasourceConnectionSpec = { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + // The AUTHORED key (#15680). The driver key it lands on is still `timeout` + // — asserted on the constructor argument below. + timeoutMs: 9000, mode: 'replica', }, }; diff --git a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts index b709fd160e..bdf742ae91 100644 --- a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts +++ b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts @@ -23,6 +23,7 @@ // authoring-door version lives in `turso-bound-secret-authoring.test.ts`. import { describe, it, expect } from 'vitest'; +import { getDriverConfigSchema } from '@objectstack/spec/data'; import { buildTursoDriverConfig, resolveTursoUrl, @@ -44,7 +45,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, }; @@ -75,7 +76,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 1, syncUrl: 'libsql://y', sync: {}, - timeout: 1, + timeoutMs: 1, mode: 'remote', }, }; @@ -92,12 +93,12 @@ describe('buildTursoDriverConfig (#7314)', () => { // Empty strings are unset, not credentials of length zero — the open-core // arm's own type-tests, carried over unchanged. The number keys deliberately - // have no truthiness check: `concurrency: 0` / `timeout: 0` are values the + // have no truthiness check: `concurrency: 0` / `timeoutMs: 0` are values the // driver reads. it('treats empty string credentials as unset and keeps zero-valued numbers', () => { const spec: DatasourceConnectionSpec = { driver: 'turso', - config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeout: 0 }, + config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeoutMs: 0 }, }; expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); @@ -204,6 +205,135 @@ describe('buildTursoDriverConfig (#7314)', () => { }); }); +// ── #16023 — the reader table reads the AUTHORED spelling, whatever it is now ── +// +// The defect this closes, and why the cases above could not have caught it. +// `TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +// key rename through it: when `TursoConfig.timeout` became `timeoutMs` +// (#15680), the `timeout` reader kept consulting the spelling the authoring +// contract had just started REFUSING, and every case above stayed green — +// because every one of them authored the retired spelling too. A datasource +// authored the canonical way reached the seam and had its timeout dropped, with +// no diagnostic in any channel. +// +// "Grep by TYPE, not by name" — the discipline the rename stack used everywhere +// else — fails at an untyped seam BY CONSTRUCTION. So these cases are derived +// from the authoring contract rather than written against today's key list: +// they read `TursoConfigSchema`'s own tombstones and hold for the NEXT rename +// without being edited. A test that has to be remembered is the same defect one +// layer up. +describe('#16023 — every reader consults the spelling the authoring contract accepts', () => { + /** `retiredKey()` stamps this prefix on the description; that IS the marker. */ + const RETIRED_PREFIX = '[REMOVED] '; + + /** The turso authoring contract, through the published door the spec offers. */ + function tursoShape(): Record { success: boolean } }> { + const schema = getDriverConfigSchema('turso') as unknown as { shape?: unknown; def?: { shape?: unknown } }; + const raw = schema.shape ?? schema.def?.shape; + const shape = typeof raw === 'function' ? (raw as () => unknown)() : raw; + return shape as Record { success: boolean } }>; + } + + /** Retired authoring keys, and the canonical key each prescription names. */ + function retirements(): Array<{ retired: string; replacement: string | undefined }> { + return Object.entries(tursoShape()) + .filter(([, field]) => (field.description ?? '').startsWith(RETIRED_PREFIX)) + .map(([retired, field]) => ({ + retired, + replacement: /was renamed to `([A-Za-z0-9_]+)`/.exec(field.description ?? '')?.[1], + })); + } + + /** + * The JS types a reader can type-test for. Every probe below is run at all + * three rather than at one guessed type: a reader tests `typeof === 'number'` + * or `'string'`, so a probe of the wrong type passes vacuously. + */ + const CANDIDATES: readonly unknown[] = [424_242, 'SENTINEL-424242', true]; + + /** + * A value the LIVE field accepts, chosen by asking the field rather than by + * guessing its type. + * + * ⚠️ Only ever called on a canonical key. A retired key is `z.never()`, so + * NOTHING parses against it — asking a tombstone for a valid sample is a + * category error, and this throws rather than answering one. (It did, on the + * first run of this file: the guard is here because it fired.) + */ + function sampleFor(key: string): unknown { + const field = tursoShape()[key]; + for (const candidate of CANDIDATES) { + if (field?.safeParse(candidate).success) return candidate; + } + throw new Error( + `no sample value of a supported JS type parses against \`${key}\` — extend the candidate list ` + + 'rather than letting this case go vacuous', + ); + } + + const build = (config: Record) => { + const spec = { driver: 'turso', config } as const; + return buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + }; + + // The vacuity guard. Every case below iterates the derived list, so an empty + // or mis-derived list would make all of them pass while testing nothing. + it('derives at least one retirement from the contract, including the known one', () => { + const retired = retirements().map((r) => r.retired); + expect(retired.length).toBeGreaterThan(0); + expect(retired).toContain('timeout'); + expect(retirements().find((r) => r.retired === 'timeout')?.replacement).toBe('timeoutMs'); + }); + + // ⭐ RED BEFORE THE FIX. The reproduction, stated as a property: a config + // authored the CANONICAL way must reach the driver. Before the fix this + // failed for `timeoutMs` — the built config was `{ url }` and the authored + // 424242 appeared nowhere. + it('reads every canonical replacement — an authored value reaches the driver config', () => { + for (const { retired, replacement } of retirements()) { + if (!replacement) continue; // a removal with no successor has nothing to read + const value = sampleFor(replacement); + const built = build({ url: 'libsql://x', [replacement]: value }); + expect( + Object.values(built), + `no reader consults the canonical \`${replacement}\` (retired: \`${retired}\`) — ` + + 'the rename moved the authoring contract and left this seam behind', + ).toContain(value); + } + }); + + // The other half, and the one that pins the DECISION rather than the fix: no + // reader keeps a tolerance arm for a retired spelling. Authoring is refused at + // the door and a stored row is canonicalized by the ADR-0087 chain before it + // arrives (`loadDatasourceRows` / `loadDatasourceRow`), so a `??` fallback + // here would be a consumer-side dialect for a spelling both doors closed — + // the sqlite `filename` and mongo `url` arms say exactly this. + it('reads NO retired spelling — a config authored the old way yields only `url`', () => { + for (const { retired } of retirements()) { + // Probed at every candidate type, not at the replacement's: a tolerance + // arm could have been written with any type-test, and the tombstone + // itself accepts nothing to sample from. + for (const value of CANDIDATES) { + const built = build({ url: 'libsql://x', [retired]: value }); + expect( + Object.keys(built), + `a reader still consults the retired \`${retired}\` (probed with ${typeof value})`, + ).toEqual(['url']); + } + } + }); + + // The named, readable instance of the property above — the case #16023 + // reported, spelled out so a reader of this file does not have to run the + // derivation in their head. + it('the reported instance: an authored `timeoutMs` lands on the driver `timeout`', () => { + expect(build({ url: 'libsql://x', timeoutMs: 9000 })) + .toEqual({ url: 'libsql://x', timeout: 9000 }); + // ...and the retired spelling is not a second way to say it. + expect(build({ url: 'libsql://x', timeout: 9000 })).toEqual({ url: 'libsql://x' }); + }); +}); + describe('resolveTursoUrl (#7314)', () => { it('trims, and reports a whitespace-only or absent url as none', () => { expect(resolveTursoUrl({ driver: 'turso', config: { url: ' libsql://x ' } })).toBe('libsql://x'); diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index ecdd75d0d2..113d83dcd5 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -71,7 +71,12 @@ export interface TursoDriverConfigInput { syncUrl?: string; /** Embedded-replica sync settings (requires `syncUrl`). */ sync?: { intervalSeconds?: number; onConnect?: boolean }; - /** Operation timeout in ms for remote operations. */ + /** + * Operation timeout in ms for remote operations. + * + * The DRIVER's key name. The datasource authors it as `config.timeoutMs` + * (#15680); this one keeps the bare spelling on purpose (#16024). + */ timeout?: number; /** Force a transport mode instead of detecting it from the url. */ mode?: 'local' | 'replica' | 'remote'; @@ -108,7 +113,7 @@ interface TursoConfigSource { * The type-tests are the open-core arm's — including the truthiness check on the * string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is an unset one, * never a credential of length zero) and its absence on the number keys - * (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + * (`concurrency: 0` and `timeoutMs: 0` are meaningful values the driver reads). * * A reader is not obliged to read only `config`: `schemaMode` and `authToken` * both consult the spec itself. `authToken`'s reason is a credential route, and @@ -167,7 +172,36 @@ const TURSO_CONFIG_READERS: { config.sync && typeof config.sync === 'object' ? (config.sync as TursoDriverConfigInput['sync']) : undefined, - timeout: ({ config }) => (typeof config.timeout === 'number' ? config.timeout : undefined), + /** + * The AUTHORED key is `timeoutMs`; the DRIVER key is `timeout`. + * + * This is the one reader whose two spellings differ, and the split is + * deliberate on both sides. `TursoConfig.timeout` was renamed to `timeoutMs` + * (#15680, ruling B on #14478) because the unit of a duration-shaped number + * belongs in the key name; `TursoDriverConfig.timeout` was NOT renamed with + * it, because renaming a published-but-inert driver key would ratify it as + * real (#16024) — which is the outcome ADR-0049 exists to prevent. + * + * ⚠️ NO fallback arm for the retired `config.timeout`, and that is the + * precedent rather than a new rule. Both sibling arms in + * `default-datasource-driver-factory.ts` say it in the same words: sqlite's + * "`filename` is the whole contract … so no `??` tolerance survives here", + * mongo's "`url` is the one spelling". Each renamed key reaches a reader + * ALREADY canonical, from two directions — authoring refuses the retired + * spelling at the door (`retiredKey()`, tsc `never` + a parse-time + * prescription), and a stored `sys_metadata` row replays the full ADR-0087 + * chain, retired entries included, at `loadDatasourceRows` / + * `loadDatasourceRow` in `datasource-admin-plugin.ts` — so the D2 conversion + * `turso-config-timeout-to-timeout-ms` has already rewritten the key before + * this table ever sees it. A `??` arm here would be a consumer-side dialect + * (Prime Directive #12) reintroducing the spelling both doors just closed. + * + * `authToken`'s legacy arm above is NOT a counter-precedent: it is kept for a + * LIVE route (host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it + * constructs itself, which never meets the authoring schema), not for a + * retired spelling. + */ + timeout: ({ config }) => (typeof config.timeoutMs === 'number' ? config.timeoutMs : undefined), mode: ({ config }) => typeof config.mode === 'string' ? (config.mode as TursoDriverConfigInput['mode']) : undefined, schemaMode: ({ spec }) => resolveDatasourceSchemaMode(spec), diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 21ccd54454..7897628cc2 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -45,7 +45,7 @@ "data/Field:required = false", "data/Field:searchable = false", "data/Field:sortable = true", - "data/FilePersistenceConfig:autoSaveInterval = 2000", + "data/FilePersistenceConfig:autoSaveIntervalMs = 2000", "data/FormatValidation:active = true", "data/FormatValidation:events = [\"insert\",\"update\"]", "data/FormatValidation:priority = 100", diff --git a/packages/spec/authorable-defaults/integration.json b/packages/spec/authorable-defaults/integration.json index 40ab04a917..2c74177ff7 100644 --- a/packages/spec/authorable-defaults/integration.json +++ b/packages/spec/authorable-defaults/integration.json @@ -4,7 +4,7 @@ "defaults": [ "integration/CircuitBreakerConfig:failureThreshold = 5", "integration/CircuitBreakerConfig:halfOpenMaxRequests = 1", - "integration/CircuitBreakerConfig:monitoringWindow = 60000", + "integration/CircuitBreakerConfig:monitoringWindowMs = 60000", "integration/CircuitBreakerConfig:resetTimeoutMs = 30000", "integration/Connector:authentication = {\"type\":\"none\"}", "integration/Connector:connectionTimeoutMs = 30000", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 5ab5ff1c11..e1140c81d4 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -39,7 +39,7 @@ "system/CacheConfig:encryption = false", "system/CacheConfig:prefetch = false", "system/CacheTier:strategy = \"lru\"", - "system/CacheTier:ttl = 300", + "system/CacheTier:ttlSeconds = 300", "system/CacheTier:warmup = false", "system/CacheWarmup:concurrency = 10", "system/CacheWarmup:enabled = false", @@ -48,7 +48,7 @@ "system/CollaborationSessionConfig:enableAwareness = true", "system/CollaborationSessionConfig:enableCursorSharing = true", "system/CollaborationSessionConfig:enablePresence = true", - "system/CollaborationSessionConfig:idleTimeout = 300000", + "system/CollaborationSessionConfig:idleTimeoutMs = 300000", "system/CollaborationSessionConfig:persistence = true", "system/CollaborativeCursor:isTyping = false", "system/ComplianceAuditRequirement:alertOnMissing = true", @@ -100,7 +100,7 @@ "system/FacetConfig:sort = \"count\"", "system/FailoverConfig:autoFailover = true", "system/FailoverConfig:failureThreshold = 3", - "system/FailoverConfig:healthCheckInterval = 30", + "system/FailoverConfig:healthCheckIntervalSeconds = 30", "system/FailoverConfig:mode = \"active_passive\"", "system/Feature:type = \"boolean\"", "system/FieldEncryption:indexable = false", @@ -187,7 +187,7 @@ "system/RTO:unit = \"minutes\"", "system/RegistryConfig:visibility = \"private\"", "system/RegistryUpstream:syncPolicy = \"auto\"", - "system/RegistryUpstream:timeout = 30000", + "system/RegistryUpstream:timeoutMs = 30000", "system/RetryPolicy:backoffMs = 1000", "system/RetryPolicy:backoffMultiplier = 1", "system/RetryPolicy:jitter = false", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 323933ab95..aad48547be 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -89,7 +89,8 @@ "ai/CodeContent:type", "ai/ConversationAnalytics:assistantMessages", "ai/ConversationAnalytics:averageTokensPerMessage", - "ai/ConversationAnalytics:duration", + "ai/ConversationAnalytics:duration [RETIRED]", + "ai/ConversationAnalytics:durationSeconds", "ai/ConversationAnalytics:firstMessageAt", "ai/ConversationAnalytics:lastMessageAt", "ai/ConversationAnalytics:peakTokenUsage", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 6a2c4222ea..4efcf67767 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -35,7 +35,8 @@ "data/AnalyticsQuery:timeDimensions", "data/AnalyticsQuery:timezone", "data/AnalyticsQuery:where", - "data/AutoPersistenceConfig:autoSaveInterval", + "data/AutoPersistenceConfig:autoSaveInterval [RETIRED]", + "data/AutoPersistenceConfig:autoSaveIntervalMs", "data/AutoPersistenceConfig:key", "data/AutoPersistenceConfig:path", "data/AutoPersistenceConfig:type", @@ -416,7 +417,8 @@ "data/FieldMaskingKeep:keepTail", "data/FieldReference:$field", "data/FieldReference:addDays", - "data/FilePersistenceConfig:autoSaveInterval", + "data/FilePersistenceConfig:autoSaveInterval [RETIRED]", + "data/FilePersistenceConfig:autoSaveIntervalMs", "data/FilePersistenceConfig:path", "data/FilePersistenceConfig:type", "data/FileValue:alt", @@ -609,7 +611,8 @@ "data/NoSQLQueryOptions:profile", "data/NoSQLQueryOptions:projection", "data/NoSQLQueryOptions:readFromSecondary", - "data/NoSQLQueryOptions:timeout", + "data/NoSQLQueryOptions:timeout [RETIRED]", + "data/NoSQLQueryOptions:timeoutMs", "data/NoSQLQueryOptions:useCursor", "data/NoSQLTransactionOptions:maxCommitTimeMS", "data/NoSQLTransactionOptions:readConcern", @@ -888,7 +891,8 @@ "data/TursoConfig:mode", "data/TursoConfig:sync", "data/TursoConfig:syncUrl", - "data/TursoConfig:timeout", + "data/TursoConfig:timeout [RETIRED]", + "data/TursoConfig:timeoutMs", "data/TursoConfig:url" ] } diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 7a9cbfe364..04e2dcceb3 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -6,7 +6,8 @@ "integration/CircuitBreakerConfig:failureThreshold", "integration/CircuitBreakerConfig:fallbackStrategy", "integration/CircuitBreakerConfig:halfOpenMaxRequests", - "integration/CircuitBreakerConfig:monitoringWindow", + "integration/CircuitBreakerConfig:monitoringWindow [RETIRED]", + "integration/CircuitBreakerConfig:monitoringWindowMs", "integration/CircuitBreakerConfig:resetTimeoutMs", "integration/Connector:_lock", "integration/Connector:_lockDocsUrl", @@ -64,7 +65,8 @@ "integration/ConnectorInstanceBearerAuth:type", "integration/ConnectorInstanceNoAuth:type", "integration/ConnectorTrigger:description", - "integration/ConnectorTrigger:interval", + "integration/ConnectorTrigger:interval [RETIRED]", + "integration/ConnectorTrigger:intervalSeconds", "integration/ConnectorTrigger:key", "integration/ConnectorTrigger:label", "integration/ConnectorTrigger:type", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 82dd537b92..963f4e83f6 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -10,7 +10,8 @@ "system/AccessControlConfig:blockedIps", "system/AccessControlConfig:corsEnabled", "system/AccessControlConfig:exposeHeaders", - "system/AccessControlConfig:maxAge", + "system/AccessControlConfig:maxAge [RETIRED]", + "system/AccessControlConfig:maxAgeSeconds", "system/AccessControlConfig:publicAccess", "system/ActionResultDialogTranslation:acknowledge", "system/ActionResultDialogTranslation:description", @@ -190,7 +191,8 @@ "system/CacheTier:maxSize", "system/CacheTier:name", "system/CacheTier:strategy", - "system/CacheTier:ttl", + "system/CacheTier:ttl [RETIRED]", + "system/CacheTier:ttlSeconds", "system/CacheTier:type", "system/CacheTier:warmup", "system/CacheWarmup:concurrency", @@ -240,7 +242,8 @@ "system/CollaborationSessionConfig:enableAwareness", "system/CollaborationSessionConfig:enableCursorSharing", "system/CollaborationSessionConfig:enablePresence", - "system/CollaborationSessionConfig:idleTimeout", + "system/CollaborationSessionConfig:idleTimeout [RETIRED]", + "system/CollaborationSessionConfig:idleTimeoutMs", "system/CollaborationSessionConfig:maxUsers", "system/CollaborationSessionConfig:mode", "system/CollaborationSessionConfig:persistence", @@ -438,7 +441,8 @@ "system/FailoverConfig:autoFailover", "system/FailoverConfig:dns", "system/FailoverConfig:failureThreshold", - "system/FailoverConfig:healthCheckInterval", + "system/FailoverConfig:healthCheckInterval [RETIRED]", + "system/FailoverConfig:healthCheckIntervalSeconds", "system/FailoverConfig:mode", "system/FailoverConfig:regions", "system/Feature:code", @@ -963,9 +967,11 @@ "system/RegistryConfig:visibility", "system/RegistryUpstream:auth", "system/RegistryUpstream:retry", - "system/RegistryUpstream:syncInterval", + "system/RegistryUpstream:syncInterval [RETIRED]", + "system/RegistryUpstream:syncIntervalSeconds", "system/RegistryUpstream:syncPolicy", - "system/RegistryUpstream:timeout", + "system/RegistryUpstream:timeout [RETIRED]", + "system/RegistryUpstream:timeoutMs", "system/RegistryUpstream:tls", "system/RegistryUpstream:url", "system/RemoveFieldOperation:fieldName", @@ -1096,7 +1102,8 @@ "system/SettingsNamespacePayload:values", "system/Span:attributes", "system/Span:context", - "system/Span:duration", + "system/Span:duration [RETIRED]", + "system/Span:durationMs", "system/Span:endTime", "system/Span:events", "system/Span:instrumentationLibrary", @@ -1158,7 +1165,8 @@ "system/StorageConnection:sasToken", "system/StorageConnection:secretAccessKey", "system/StorageConnection:sessionToken", - "system/StorageConnection:timeout", + "system/StorageConnection:timeout [RETIRED]", + "system/StorageConnection:timeoutMs", "system/StorageConnection:useSSL", "system/StructuredLogEntry:context", "system/StructuredLogEntry:environment", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7b90554c15..c17200d3b5 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -256,7 +256,8 @@ "ui/Dashboard:name", "ui/Dashboard:performance [RETIRED]", "ui/Dashboard:protection", - "ui/Dashboard:refreshInterval", + "ui/Dashboard:refreshInterval [RETIRED]", + "ui/Dashboard:refreshIntervalSeconds", "ui/Dashboard:widgets", "ui/DashboardHeader:actions", "ui/DashboardHeader:showDescription", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index e0cedc8009..af8580e934 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -160,9 +160,14 @@ "status": "live", "note": "objectui: DashboardRenderer.tsx:262 — grid gap." }, - "refreshInterval": { + "refreshIntervalSeconds": { "status": "live", - "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer." + "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer. RENAMED 2026-09-05 (#15680, #14478 ruling B) from `refreshInterval`: the unit lived only in the describe prose. The consumer is in ANOTHER REPOSITORY, so unlike every other rename in this stack its reader could not move in the same PR — objectui at pin a472b07167a3 reads `schema.refreshInterval` at packages/plugin-dashboard/src/DashboardRenderer.tsx:448-453 and DashboardGridLayout.tsx:146-151, multiplies by 1000, and publishes the input name at packages/plugin-dashboard/src/index.tsx:94 (which is what sdui.manifest.json:805 in this repo records). Those sites move in a follow-up objectui card sequenced BEHIND a release that ships this rename; until then the renderer sees an absent key and simply does not start its timer." + }, + "refreshInterval": { + "status": "dead", + "verifiedAt": "2026-09-05", + "note": "REMOVED 2026-09-05 (#15680, #14478 ruling B) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of stored sources by the protocol-18 conversion `dashboard-refresh-interval-to-refresh-interval-seconds`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `refreshIntervalSeconds` — rename the key, the value (seconds) is unchanged, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/ui/dashboard.zod.ts#refreshInterval. The three rename-hint aliases `refresh` / `autoRefresh` / `pollInterval` were repointed to the new spelling in the same edit." }, "globalFilters": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 207b7becff..41cec46c46 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -41,7 +41,7 @@ for both corollaries. | `page` | 23 | 0 | 0 | 0 | 1 | 24 | | `view` | 79 | 0 | 0 | 9 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 34 | 0 | 0 | 7 | 0 | 41 | +| `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **93** | **12** | **956** | +| **total** | **845** | **5** | **1** | **94** | **12** | **957** | diff --git a/packages/spec/src/ai/conversation.test.ts b/packages/spec/src/ai/conversation.test.ts index e39bc7d5ba..67a38a4ee0 100644 --- a/packages/spec/src/ai/conversation.test.ts +++ b/packages/spec/src/ai/conversation.test.ts @@ -427,7 +427,7 @@ describe('ConversationAnalyticsSchema', () => { summarizationEvents: 1, tokensSavedByPruning: 500, tokensSavedBySummarization: 2000, - duration: 1800, + durationSeconds: 1800, firstMessageAt: '2024-01-15T10:00:00Z', lastMessageAt: '2024-01-15T10:30:00Z', }; @@ -582,3 +582,45 @@ describe('Real-World Conversation Examples', () => { expect(() => ConversationSessionSchema.parse(session)).not.toThrow(); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — a bare throw assertion passes just as happily +// on the unrecognized-key error the rename is meant to replace. +// `ConversationAnalytics` is runtime-emitted, so the silent-strip alternative +// is the real hazard: this shape is not strict, and a producer still writing +// `duration` would have lost the one measurement on the row with no error at all. +describe('ConversationAnalytics.duration carries its unit (#15680)', () => { + const base = { + sessionId: 'session-1', + totalMessages: 10, + userMessages: 5, + assistantMessages: 5, + systemMessages: 0, + totalTokens: 1000, + averageTokensPerMessage: 100, + peakTokenUsage: 1000, + }; + + it('REFUSES the retired `duration` with the rename in the message', () => { + const result = ConversationAnalyticsSchema.safeParse({ ...base, duration: 1800 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConversationAnalytics.duration` was renamed to `durationSeconds`'); + }); + + it('accepts `durationSeconds` at the same magnitude and still refuses a negative one', () => { + expect(ConversationAnalyticsSchema.parse({ ...base, durationSeconds: 1800 }).durationSeconds).toBe(1800); + expect(ConversationAnalyticsSchema.safeParse({ ...base, durationSeconds: -1 }).success).toBe(false); + }); + + it('leaves the twelve sibling COUNTS alone — a count has no unit to carry', () => { + const parsed = ConversationAnalyticsSchema.parse({ ...base, pruningEvents: 3, tokensSavedByPruning: 500 }); + expect(parsed.totalMessages).toBe(10); + expect(parsed.totalTokens).toBe(1000); + expect(parsed.pruningEvents).toBe(3); + expect(parsed.tokensSavedByPruning).toBe(500); + }); +}); diff --git a/packages/spec/src/ai/conversation.zod.ts b/packages/spec/src/ai/conversation.zod.ts index e3c3bf2520..ec02509479 100644 --- a/packages/spec/src/ai/conversation.zod.ts +++ b/packages/spec/src/ai/conversation.zod.ts @@ -14,6 +14,7 @@ import { TokenUsageSchema } from './usage.zod'; * Message Role */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MessageRoleSchema = lazySchema(() => z.enum([ 'system', 'user', @@ -305,7 +306,19 @@ export const ConversationAnalyticsSchema = lazySchema(() => z.object({ tokensSavedBySummarization: z.number().int().nonnegative().default(0), /** Duration */ - duration: z.number().nonnegative().optional().describe('Session duration in seconds'), + // Renamed from `duration` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, on a shape whose two neighbouring instants already spell + // themselves `firstMessageAt` / `lastMessageAt`. Every other number on this + // shape is a COUNT (messages, tokens, events), so the one measurement carrying + // a unit was the one key that named none. + durationSeconds: z.number().nonnegative().optional().describe('Session duration in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + duration: retiredKey( + '`ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged.', + ), firstMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), lastMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), })); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 5dad0b918c..07e13ffe12 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8683,6 +8683,317 @@ const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { }, }; +/** + * `dashboards[].refreshInterval` → `refreshIntervalSeconds` (protocol 18, + * #15680 for #14478) — the `ui` half of the same rename + * {@link hookTimeoutToTimeoutMs} documents, and the one key in this whole stack + * whose CONSUMER lives in another repository. + * + * Three rename-hint aliases already pointed at the old spelling — `refresh`, + * `autoRefresh`, `pollInterval` — which is the measure of how many spellings + * authors reach for; none of them named a unit either, so every door into the + * key left the cadence ambiguous. All three were repointed at the schema in the + * same edit, so an author arriving through any of them is now prescribed the + * unit-carrying name. + * + * ⚠️ objectui's dashboard renderer reads this key and multiplies by 1000, and + * republishes it as a registry input the console offers to authors. That reader + * could not move in this PR, so unlike every other rename in this card the + * consumer lags by a release: this conversion is what keeps stored dashboards + * and `os migrate meta` correct in the meantime. + * + * Same posture as its siblings: retired from the load path, tombstoned at the + * schema, replayable here. + */ +const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { + id: 'dashboard-refresh-interval-to-refresh-interval-seconds', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'dashboard.refreshInterval', + summary: "dashboard key 'refreshInterval' → 'refreshIntervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'dashboards', (dashboard, path) => { + const renamed = renameKey(dashboard, 'refreshInterval', 'refreshIntervalSeconds'); + if (!renamed) return dashboard; + emit({ from: 'refreshInterval', to: 'refreshIntervalSeconds', path: `${path}.refreshIntervalSeconds` }); + return renamed; + }); + }, + fixture: { + before: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshInterval: 300 }, + // A dashboard that never authored the key keeps its identity (copy-on-write). + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + after: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshIntervalSeconds: 300 }, + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + expectedNotices: 1, + }, +}; + +/** + * The two connector duration keys whose name carried no unit → suffixed + * (protocol 18, #15680 for #14478): `health.circuitBreaker.monitoringWindow` → + * `monitoringWindowMs`, and `triggers[].interval` → `intervalSeconds`. + * + * One entry because they are one authored document and one authoring session — + * a connector and the resilience block that guards it. The circuit-breaker case + * is the sharpest in this card: `monitoringWindow` (ms) sat ONE key below + * `resetTimeoutMs`, which already spelled its unit, so a single six-key shape + * carried both conventions and a reader had no rule to apply, only two examples + * that disagreed. The trigger case is the widest: the bare token `interval` + * means MILLISECONDS elsewhere in this same spec, so the identical spelling + * carried two units a thousandfold apart. + * + * A published connector row lands whole in `sys_metadata` (`ConnectorSchema`'s + * own docblock says so, which is why #7990 forbids inline secrets on it), so + * the chain has a seam that sees both keys — hence a conversion rather than the + * semantic entries this card's two runtime-emitted keys took. + * + * The two are walked in one pass but emit SEPARATELY: a connector may author + * either, both, or neither, and an operator reading the notice list needs to see + * which of its own keys moved. Retired from the load path, tombstoned at the + * schema, replayable here. + */ +const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { + id: 'connector-health-and-trigger-durations-unit-in-key', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.health.circuitBreaker.monitoringWindow, connector.triggers[].interval', + summary: "connector keys 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs' and 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; both values are unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'connectors', (connector, path) => { + let next = connector; + + const health = next.health; + if (isDict(health)) { + const breaker = health.circuitBreaker; + if (isDict(breaker)) { + const renamedBreaker = renameKey(breaker, 'monitoringWindow', 'monitoringWindowMs'); + if (renamedBreaker) { + emit({ + from: 'monitoringWindow', + to: 'monitoringWindowMs', + path: `${path}.health.circuitBreaker.monitoringWindowMs`, + }); + next = { ...next, health: { ...health, circuitBreaker: renamedBreaker } }; + } + } + } + + const triggers = next.triggers; + if (Array.isArray(triggers)) { + let triggersChanged = false; + const nextTriggers = triggers.map((trigger, i) => { + if (!isDict(trigger)) return trigger; + const renamed = renameKey(trigger, 'interval', 'intervalSeconds'); + if (!renamed) return trigger; + emit({ + from: 'interval', + to: 'intervalSeconds', + path: `${path}.triggers[${i}].intervalSeconds`, + }); + triggersChanged = true; + return renamed; + }); + if (triggersChanged) next = { ...next, triggers: nextTriggers }; + } + + return next; + }); + }, + fixture: { + before: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindow: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }, + // A webhook trigger authors no interval and keeps its identity. + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + // A connector that authored neither key keeps its identity (copy-on-write). + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + after: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindowMs: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.persistence.autoSaveInterval` → `autoSaveIntervalMs` + * for the memory driver (protocol 18, #15680 for #14478). + * + * BOTH arms of the persistence union move, and that is the load-bearing detail. + * The gate listed only the `file` arm, because the `auto` arm's describe named + * no unit at all and the predicate judges prose against name. But `auto` + * resolves to the same Node.js file adapter and forwards the same value to the + * same `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound — so converting one arm and not the other would leave + * ONE value with TWO spellings across sibling arms of one union, and the driver + * reading both. That is the consumer-side dialect Prime Directive #12 forbids. + * + * Driver-awareness is load-bearing here for the reason + * {@link datasourceConfigDriverKeyAliases} states: `persistence` is a memory + * driver key, and a `persistence` block under some other driver is not this + * shape. `resolveDriverId` keeps the rewrite where it belongs. + * + * A string `persistence` (`'file'` / `'local'` / `'auto'`) and a custom-adapter + * block carry no interval and pass through untouched. + */ +const memoryPersistenceAutoSaveIntervalToMs: MetadataConversion = { + id: 'memory-persistence-auto-save-interval-to-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.persistence.autoSaveInterval', + summary: "memory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'memory') return ds; + const config = ds.config; + if (!isDict(config)) return ds; + const persistence = config.persistence; + if (!isDict(persistence)) return ds; + const renamed = renameKey(persistence, 'autoSaveInterval', 'autoSaveIntervalMs'); + if (!renamed) return ds; + emit({ + from: 'autoSaveInterval', + to: 'autoSaveIntervalMs', + path: `${path}.config.persistence.autoSaveIntervalMs`, + }); + return { ...ds, config: { ...config, persistence: renamed } }; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveInterval: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveInterval: 5000 } }, + }, + // A memory datasource with string persistence carries no interval. + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + // Another driver's config is not this shape and is never touched. + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + after: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveIntervalMs: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveIntervalMs: 5000 } }, + }, + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol + * 18, #15680 for #14478). + * + * Driver-awareness is load-bearing exactly as it is for + * {@link datasourceConfigDriverKeyAliases}: a bare `config.timeout` under some + * OTHER driver is that driver's own key and must not be touched, so the rewrite + * is gated on `resolveDriverId(ds.driver) === 'turso'` (which also catches a + * stored `driver: 'libsql'` through the alias table). + * + * The neighbour is why this key was worth the rename: `sync.intervalSeconds`, + * two keys above, already spelled ITS unit. One shape, both conventions. + * + * ⚠️ This converts the SPEC's turso contract. The driver package ships its own + * parallel `turso.zod.ts` whose `timeout` is outside this card's declared + * population; it is renamed by the card that widens that population, and until + * then the two declarations disagree by design. + */ +const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { + id: 'turso-config-timeout-to-timeout-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.timeout (turso)', + summary: "turso datasource key 'config.timeout' → 'config.timeoutMs' (#14478 — the unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'turso') return ds; + const renamed = renameConfigKey(ds, 'timeout', 'timeoutMs'); + if (!renamed) return ds; + emit({ from: 'timeout', to: 'timeoutMs', path: `${path}.config.timeoutMs` }); + return renamed; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeout: 30000 }, + }, + // A turso datasource that never authored the key keeps its identity. + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + // `timeout` under another driver is that driver's own key — untouched. + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + after: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeoutMs: 30000 }, + }, + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8775,6 +9086,10 @@ export const CONVERSIONS_BY_MAJOR: Readonly { + const tursoShape: any = (getDriverConfigSchema('turso') as any).shape; + const shape = typeof tursoShape === 'function' ? tursoShape() : tursoShape; + // The tombstone really is in the shape and really is a `z.never()` — without + // this leg the assertion below would pass just as well on a key that had + // been deleted outright, which is the silent strip the tombstone prevents. + expect(Object.keys(shape)).toContain('timeout'); + expect(String(shape.timeout.description)).toMatch(/^\[REMOVED\] /); + expect(refusedCredentialKeys('turso')).not.toContain('timeout'); + expect(redactableConfigKeys('turso')).not.toContain('timeout'); + // The credential beside it is untouched — the narrowing must not cost a + // single real refusal, which is the only direction that could leak. + expect(refusedCredentialKeys('turso')).toContain('authToken'); + }); + + it('the exclusion is negative, so an UNMARKED z.never() is still a credential', () => { + // Fail-safe direction. A future credential slot whose author forgets + // `refusedInlineCredentialKey`'s marker must still be scrubbed; only a key + // that has explicitly declared itself retired may drop out. Constructed, + // because no builtin driver ships a bare `z.never()` today. + const unmarked = z.object({ apiSecret: z.never().optional(), url: z.string() }); + expect(refusedCredentialPathsOfSchema(unmarked)).toEqual([['apiSecret']]); + + const tombstoned = z.object({ + legacyKey: z.never().optional().describe('[REMOVED] `legacyKey` was renamed to `legacyKeyMs`.'), + url: z.string(), + }); + expect(refusedCredentialPathsOfSchema(tombstoned)).toEqual([]); + }); + it('every z.never() key across every builtin driver is covered by the unknown-driver fallback', () => { // Guards the one hand-written canonical list: if a driver refuses a NEW // credential key, the fallback used for contract-less drivers must learn diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index 553deaf554..0bbfc19db0 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -28,6 +28,24 @@ * refusal list rather than re-typing it. A driver that refuses a new * credential key tomorrow is covered here the day it lands, which a * hand-maintained list in a consumer package would not be. + * + * ⚠️ **One exception, and it is declared on the schema:** a `retiredKey()` + * RETIREMENT TOMBSTONE is also a `z.never()`, and it is not a credential + * — it is a key that used to exist under another name. Until #15680 no + * driver contract carried one, so "never ⇒ credential" held by accident of + * population rather than by construction; the duration renames of #14478 + * put the first one in (`TursoConfig.timeout` → `timeoutMs`) and the + * derivation answered that a millisecond budget was a secret. Tombstones + * are excluded by the `[REMOVED] ` prefix `retiredKey()` itself stamps on + * the description — the producer's own marker, the same one the reference + * pages and the authorable-surface ratchet already read. + * + * The exclusion is deliberately NEGATIVE (skip declared tombstones) rather + * than POSITIVE (keep only keys marked `format: 'password'`), even though + * every credential slot in every builtin contract does carry that marker + * today. Under-redacting is the dangerous direction: a future credential + * key whose author forgets the marker must still be scrubbed, and only a + * key that has explicitly declared itself retired may drop out. * 2. **Former alias spellings** ({@link FORMER_CREDENTIAL_ALIASES}) — `passwd` * / `pwd` / `token` / `jwt` / `auth_token` / `authtoken` used to be * `aliases` that the parse RENAMED onto the canonical key; #8078 moved them @@ -247,6 +265,25 @@ function baseTypeOf(schema: unknown): string | undefined { return def?.type; } +/** + * Is this shape member a `retiredKey()` tombstone rather than a refused + * credential slot? Both are `z.never()`; only the tombstone carries the + * `[REMOVED] ` prescription prefix `retiredKey()` stamps on the description. + * + * Read from the member AND from its unwrapped base node: `retiredKey()` calls + * `.describe()` last, so the prefix sits on the outer `.optional()` clone, + * while a caller that re-wraps a tombstone could leave it further in. + */ +function isRetirementTombstone(member: unknown, node: unknown): boolean { + const described = (m: unknown): string | undefined => { + const d = (m as any)?.description; + if (typeof d === 'string') return d; + const meta = (m as any)?.meta?.(); + return typeof meta?.description === 'string' ? meta.description : undefined; + }; + return (described(member) ?? described(node) ?? '').startsWith('[REMOVED] '); +} + /** The `shape` record of an object-typed schema node, or `undefined`. */ function shapeOf(schema: unknown): Record | undefined { const raw = (schema as any)?.shape; @@ -279,7 +316,8 @@ export function refusedCredentialPathsOfSchema(schema: unknown): (readonly strin const def = node?.def ?? node?._def; const type: string | undefined = def?.type; if (type === 'never') { - out.push([...prefix, key]); + // A retirement tombstone is a `z.never()` that is not a credential. + if (!isRetirementTombstone(member, node)) out.push([...prefix, key]); continue; } if (type === 'object') { @@ -323,7 +361,8 @@ export function refusedCredentialKeys(driver: unknown): string[] { } if (!shape) return []; return Object.entries(shape) - .filter(([, member]) => baseTypeOf(member) === 'never') + .filter(([, member]) => baseTypeOf(member) === 'never' + && !isRetirementTombstone(member, baseNodeOf(member))) .map(([key]) => key); } diff --git a/packages/spec/src/data/driver-nosql.test.ts b/packages/spec/src/data/driver-nosql.test.ts index 0113ced4ae..e71eb985bf 100644 --- a/packages/spec/src/data/driver-nosql.test.ts +++ b/packages/spec/src/data/driver-nosql.test.ts @@ -224,7 +224,7 @@ describe('NoSQL Driver Protocol', () => { consistency: 'quorum' as const, readFromSecondary: true, projection: { name: 1, email: 1, _id: 0 }, - timeout: 5000, + timeoutMs: 5000, useCursor: true, batchSize: 100, profile: true, @@ -232,7 +232,7 @@ describe('NoSQL Driver Protocol', () => { }; const result = NoSQLQueryOptionsSchema.parse(options); - expect(result.timeout).toBe(5000); + expect(result.timeoutMs).toBe(5000); expect(result.batchSize).toBe(100); expect(result.hint).toBe('name_1_email_1'); }); @@ -266,7 +266,7 @@ describe('NoSQL Driver Protocol', () => { }, ], options: { - timeout: 10000, + timeoutMs: 10000, }, }; @@ -366,3 +366,28 @@ describe('NoSQL Driver Protocol', () => { }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The shape is not strict, so without the +// tombstone a query authored with `timeout` would have run with NO deadline at +// all — the failure a driver timeout exists to prevent — and reported nothing. +describe('NoSQLQueryOptions.timeout carries its unit (#15680)', () => { + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = NoSQLQueryOptionsSchema.safeParse({ timeout: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`NoSQLQueryOptions.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(NoSQLQueryOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + expect(NoSQLQueryOptionsSchema.safeParse({ timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves the neighbouring `batchSize` alone — it is a COUNT of documents, not a duration', () => { + expect(NoSQLQueryOptionsSchema.parse({ batchSize: 100 }).batchSize).toBe(100); + }); +}); diff --git a/packages/spec/src/data/driver-nosql.zod.ts b/packages/spec/src/data/driver-nosql.zod.ts index b5d81c7254..b95b445ac7 100644 --- a/packages/spec/src/data/driver-nosql.zod.ts +++ b/packages/spec/src/data/driver-nosql.zod.ts @@ -8,6 +8,7 @@ import { DriverConfigSchema } from './driver.zod'; * Supported NoSQL database types */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const NoSQLDatabaseTypeSchema = lazySchema(() => z.enum([ 'mongodb', 'couchdb', @@ -306,8 +307,20 @@ export const NoSQLQueryOptionsSchema = lazySchema(() => z.object({ /** * Query timeout in milliseconds + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only in + * the describe prose. It sits beside `batchSize`, a plain row COUNT, with + * nothing at the call site to tell a reader which of the two numbers carries + * a unit. */ - timeout: z.number().int().positive().optional().describe('Query timeout (ms)'), + timeoutMs: z.number().int().positive().optional().describe('Query timeout (ms)'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Use cursor for large result sets diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index ca80929848..f1c889fe94 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -96,18 +96,18 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }, }); expect(config.persistence).toBeDefined(); - const p = config.persistence as { type: 'file'; path?: string; autoSaveInterval: number }; + const p = config.persistence as { type: 'file'; path?: string; autoSaveIntervalMs: number }; expect(p.type).toBe('file'); expect(p.path).toBe('/tmp/data.json'); - expect(p.autoSaveInterval).toBe(10000); + expect(p.autoSaveIntervalMs).toBe(10000); }); - it('should apply file persistence autoSaveInterval default', () => { + it('should apply file persistence autoSaveIntervalMs default', () => { const config = MemoryConfigSchema.parse({ persistence: { type: 'file', @@ -115,8 +115,8 @@ describe('MemoryConfigSchema', () => { }, }); - const p = config.persistence as { type: 'file'; autoSaveInterval: number }; - expect(p.autoSaveInterval).toBe(2000); + const p = config.persistence as { type: 'file'; autoSaveIntervalMs: number }; + expect(p.autoSaveIntervalMs).toBe(2000); }); it('should accept persistence with local object config', () => { @@ -138,15 +138,15 @@ describe('MemoryConfigSchema', () => { type: 'auto', path: '/var/data/memory.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }, }); - const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveInterval?: number }; + const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveIntervalMs?: number }; expect(p.type).toBe('auto'); expect(p.path).toBe('/var/data/memory.json'); expect(p.key).toBe('myapp:db'); - expect(p.autoSaveInterval).toBe(5000); + expect(p.autoSaveIntervalMs).toBe(5000); }); it('should accept auto persistence without overrides', () => { @@ -208,7 +208,7 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/var/data/memory.json', - autoSaveInterval: 3000, + autoSaveIntervalMs: 3000, }, }); @@ -218,12 +218,12 @@ describe('MemoryConfigSchema', () => { expect(p.path).toBe('/var/data/memory.json'); }); - it('should reject file persistence with invalid autoSaveInterval', () => { + it('should reject file persistence with invalid autoSaveIntervalMs', () => { expect(() => MemoryConfigSchema.parse({ persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 }, })).toThrow(); }); @@ -270,21 +270,21 @@ describe('FilePersistenceConfigSchema', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }); expect(config.type).toBe('file'); expect(config.path).toBe('/data/store.json'); - expect(config.autoSaveInterval).toBe(10000); + expect(config.autoSaveIntervalMs).toBe(10000); }); - it('should apply default autoSaveInterval', () => { + it('should apply default autoSaveIntervalMs', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', }); - expect(config.autoSaveInterval).toBe(2000); + expect(config.autoSaveIntervalMs).toBe(2000); }); it('should accept without path (uses default)', () => { @@ -426,7 +426,7 @@ describe('AutoPersistenceConfigSchema', () => { expect(config.type).toBe('auto'); expect(config.path).toBeUndefined(); expect(config.key).toBeUndefined(); - expect(config.autoSaveInterval).toBeUndefined(); + expect(config.autoSaveIntervalMs).toBeUndefined(); }); it('should accept auto config with all overrides', () => { @@ -434,19 +434,19 @@ describe('AutoPersistenceConfigSchema', () => { type: 'auto', path: '/data/store.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }); expect(config.type).toBe('auto'); expect(config.path).toBe('/data/store.json'); expect(config.key).toBe('myapp:db'); - expect(config.autoSaveInterval).toBe(5000); + expect(config.autoSaveIntervalMs).toBe(5000); }); - it('should reject auto config with invalid autoSaveInterval', () => { + it('should reject auto config with invalid autoSaveIntervalMs', () => { expect(() => AutoPersistenceConfigSchema.parse({ type: 'auto', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 })).toThrow(); }); @@ -454,7 +454,7 @@ describe('AutoPersistenceConfigSchema', () => { it('rejects an unrecognised key instead of silently stripping it', () => { const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', - interval: 5000, // meant `autoSaveInterval`, not a real field + interval: 5000, // meant `autoSaveIntervalMs`, not a real field }); expect(result.success).toBe(false); @@ -486,7 +486,7 @@ describe('MemoryPersistenceConfigSchema', () => { type: 'file', path: '/tmp/data.json', }); - expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveInterval: 2000 }); + expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveIntervalMs: 2000 }); }); it('should accept local object config', () => { @@ -539,3 +539,45 @@ describe('MemoryDriverSpec', () => { expect(MemoryDriverSpec.icon).toBe('memory'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — both shapes ARE `strictObject`, so a bare throw +// assertion would pass identically on the unrecognized-key error the tombstone +// exists to replace, which is exactly the case that cannot carry a rename. +describe('memory persistence auto-save interval carries its unit (#15680)', () => { + it('REFUSES the retired `autoSaveInterval` on the file arm, with the rename in the message', () => { + const result = FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + // The `auto` arm was NOT on the gate's list — its describe named no unit, so + // the predicate never judged it. It moves anyway because it is the same value: + // `auto` resolves to the same Node.js file adapter and forwards this number to + // the same `FileSystemPersistenceAdapter` field. Renaming one arm and not the + // other would leave one value with two spellings across sibling arms of one + // union — the dialect Prime Directive #12 forbids. This pin is what stops a + // later reader "restoring" the bare spelling on the arm the gate never listed. + it('REFUSES the retired `autoSaveInterval` on the auto arm too, with its own prescription', () => { + const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + it('accepts `autoSaveIntervalMs` on both arms, keeps the 2000 default and the min(100) bound', () => { + expect(FilePersistenceConfigSchema.parse({ type: 'file', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + expect(FilePersistenceConfigSchema.parse({ type: 'file' }).autoSaveIntervalMs).toBe(2000); + expect(AutoPersistenceConfigSchema.parse({ type: 'auto', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + // 100 reads as a plausible number of SECONDS — the bound is the whole + // reason the bare name was dangerous rather than merely untidy. + expect(FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveIntervalMs: 50 }).success).toBe(false); + expect(AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveIntervalMs: 50 }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index f5a2a21311..eb82214af9 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -60,6 +60,7 @@ const PERSISTENCE_HISTORY = * via `PersistenceAdapterInterface` in the driver implementation. */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const PersistenceAdapterSchema = lazySchema(() => strictObject( { surface: "this memory datasource's custom persistence adapter", @@ -108,8 +109,25 @@ export const FilePersistenceConfigSchema = lazySchema(() => strictObject( * config-material, not data. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path to persist data'), - /** Auto-save interval in milliseconds. Default: 2000ms. */ - autoSaveInterval: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + /** + * Auto-save interval in milliseconds. Default: 2000ms. + * + * Renamed from `autoSaveInterval` (#15680, ruling B on #14478): the unit + * lived only in the describe prose. The `min(100)` bound is the tell that + * made the bare name dangerous — 100 reads as a plausible number of + * SECONDS, so an author who guessed the unit wrong was refused nowhere and + * saved 1000x more often than intended. + */ + autoSaveIntervalMs: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) and the 2000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), }, ).describe('File-system persistence configuration')); @@ -185,8 +203,28 @@ export const AutoPersistenceConfigSchema = lazySchema(() => strictObject( * `file` branch's `path`; the auto-detected file adapter resolves nothing. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path override for Node.js environments'), - /** Auto-save interval override when running in Node.js. */ - autoSaveInterval: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments'), + /** + * Auto-save interval override when running in Node.js, in milliseconds. + * + * Renamed from `autoSaveInterval` alongside the `file` arm's key (#15680, + * ruling B on #14478). It is not a second key: `type: 'auto'` resolves to + * the same Node.js file adapter, and this value is forwarded to the same + * `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound. Renaming one arm and not the other would have left + * ONE value with TWO spellings across sibling arms of one union — and the + * driver reading both, which is the consumer-side dialect Prime Directive + * #12 forbids. + */ + autoSaveIntervalMs: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments, in milliseconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * localStorage key override when running in a browser. * `${…}` placeholder syntax is refused (#8495) — same judgment as the diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 7facb9df07..3125cd7893 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -27,7 +27,7 @@ describe('TursoConfigSchema', () => { { url: 'file:./data/objectstack.db' }, { url: ':memory:' }, { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, - { url: 'libsql://x.turso.io', concurrency: 10, timeout: 5000, mode: 'remote' }, + { url: 'libsql://x.turso.io', concurrency: 10, timeoutMs: 5000, mode: 'remote' }, ]) { const result = TursoConfigSchema.safeParse(config); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); @@ -112,3 +112,35 @@ describe('TursoDriverSpec', () => { expect(Object.keys(json.properties ?? {})).toContain('url'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — this shape IS `strictObject`, so a bare throw +// assertion passes identically on the unrecognized-key error, which is precisely +// the error that cannot carry a FROM → TO mapping. +describe('TursoConfig.timeout carries its unit (#15680)', () => { + const base = { url: 'libsql://app.turso.io' }; + + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = TursoConfigSchema.safeParse({ ...base, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`turso config.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(TursoConfigSchema.parse({ ...base, timeoutMs: 30000 }).timeoutMs).toBe(30000); + expect(TursoConfigSchema.safeParse({ ...base, timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves `sync.intervalSeconds` alone — it already carried its unit, and is the neighbour that made the bare `timeout` a collision', () => { + const parsed = TursoConfigSchema.parse({ + ...base, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 60 }, + }); + expect(parsed.sync!.intervalSeconds).toBe(60); + }); +}); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 4375edd1bd..9a394a8ad5 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { @@ -211,11 +212,27 @@ export const TursoConfigSchema = lazySchema(() => strictObject( onConnect: z.boolean().optional().describe('Sync immediately on connect'), }).optional().describe('Embedded-replica sync configuration (requires `syncUrl`)'), - /** Operation timeout in ms for remote operations (replica/remote modes). */ - timeout: z.number().int().positive().optional() + /** + * Operation timeout in ms for remote operations (replica/remote modes). + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only + * in the describe prose and in a `.meta({ title })` no parse reads. It sat + * two keys below `sync.intervalSeconds`, which already spelled ITS unit — + * one shape carrying both conventions, and the suffixed one was the honest + * half. + */ + timeoutMs: z.number().int().positive().optional() .describe('Operation timeout in milliseconds for remote operations') .meta({ title: 'Timeout (ms)' }), + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename the ' + + 'key to `timeoutMs`; the value (milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), + /** Pin the transport instead of inferring it from `url`. */ mode: TursoTransportModeSchema.optional().meta({ title: 'Transport mode' }), }) diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index cb1fb8ef0f..3a1289e46d 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -29,6 +29,10 @@ import { HealthCheckConfigSchema, CircuitBreakerConfigSchema, ConnectorHealthSchema, + + // Trigger (declared-but-unread, #3197 — the pin block at the bottom judges + // its unit-carrying key name, not a runtime it does not have) + ConnectorTriggerSchema, // Types type Connector, @@ -558,7 +562,7 @@ describe('CircuitBreakerConfigSchema', () => { expect(config.failureThreshold).toBe(5); expect(config.resetTimeoutMs).toBe(30000); expect(config.halfOpenMaxRequests).toBe(1); - expect(config.monitoringWindow).toBe(60000); + expect(config.monitoringWindowMs).toBe(60000); }); it('should accept full circuit breaker config', () => { @@ -567,7 +571,7 @@ describe('CircuitBreakerConfigSchema', () => { failureThreshold: 10, resetTimeoutMs: 60000, halfOpenMaxRequests: 3, - monitoringWindow: 120000, + monitoringWindowMs: 120000, fallbackStrategy: 'cache', }); @@ -1473,3 +1477,43 @@ describe('[#14676] ADR-0087 registration', () => { expect(step.conversionIds).toContain('connector-error-mapping-removed'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. Neither shape is strict, so without the +// tombstones the old keys would be STRIPPED in silence: a breaker would fall +// back to its 60-second default window while the author believed they had +// widened it, and a polling trigger would lose its cadence entirely. +describe('connector durations carry their unit (#15680)', () => { + it('REFUSES the retired `monitoringWindow` with the rename in the message', () => { + const result = CircuitBreakerConfigSchema.safeParse({ enabled: true, monitoringWindow: 120000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'monitoringWindow'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs`'); + }); + + it('REFUSES the retired trigger `interval` with the rename in the message', () => { + const result = ConnectorTriggerSchema.safeParse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConnectorTrigger.interval` was renamed to `intervalSeconds`'); + }); + + it('accepts both new spellings and keeps the 60000 breaker default', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).monitoringWindowMs).toBe(60000); + expect(CircuitBreakerConfigSchema.parse({ enabled: true, monitoringWindowMs: 120000 }).monitoringWindowMs).toBe(120000); + expect(ConnectorTriggerSchema.parse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60, + }).intervalSeconds).toBe(60); + }); + + it('leaves `resetTimeoutMs` alone — it already carried its unit, and is the neighbour that made the bare `monitoringWindow` a collision', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).resetTimeoutMs).toBe(30000); + }); +}); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 988ef8cbeb..6e83add4c0 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -538,7 +538,20 @@ export const CircuitBreakerConfigSchema = lazySchema(() => z.object({ failureThreshold: z.number().optional().default(5).describe('Failures before opening circuit'), resetTimeoutMs: z.number().optional().default(30000).describe('Time in open state before half-open'), halfOpenMaxRequests: z.number().optional().default(1).describe('Requests allowed in half-open state'), - monitoringWindow: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + // Renamed from `monitoringWindow` (#15680, ruling B on #14478): the unit lived + // only in the describe prose, one key below `resetTimeoutMs`, which already + // spelled ITS unit. One shape carrying both conventions — the suffixed one was + // the honest half. + monitoringWindowMs: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + monitoringWindow: retiredKey( + '`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `monitoringWindowMs`; the value ' + + '(milliseconds) and the 60000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), fallbackStrategy: z.enum(['cache', 'default_value', 'error', 'queue']).optional().describe('Fallback strategy when circuit is open'), }).describe('Circuit breaker configuration')); @@ -672,7 +685,19 @@ export const ConnectorTriggerSchema = lazySchema(() => z.object({ label: z.string().describe('Trigger label'), description: z.string().optional(), type: z.enum(['polling', 'webhook']).describe('Trigger type'), - interval: z.number().optional().describe('Polling interval in seconds'), + // Renamed from `interval` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, and a polling cadence is exactly the number a reader + // guesses at — the same bare `interval` means MILLISECONDS elsewhere in this + // spec, so the identical name carried two units a thousandfold apart. + intervalSeconds: z.number().optional().describe('Polling interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + interval: retiredKey( + '`ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), })); export type ConnectorTrigger = z.input; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts new file mode 100644 index 0000000000..be10bd55db --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): +// a duration-shaped `z.number()` key carries its unit in its NAME, and no +// existing offender is grandfathered. `ConversationAnalytics.duration` said +// "Session duration in seconds" in prose and nothing else, on a shape where +// every OTHER number is a count (messages, tokens, pruning events) and the two +// neighbouring instants already spell themselves `firstMessageAt` / +// `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. +// Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare +// deletion would strip the key in silence and the analytics row would lose the +// one measurement it carries, with no error anywhere. No D2 conversion: +// conversation analytics are computed and emitted at runtime, never authored +// and never a stored `sys_metadata` row, so the chain has no seam that sees +// one. See `ai-conversation-analytics-duration-unit-in-key`. +export const entry = 'ai/ConversationAnalytics:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..cccb824566 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card +// that the gate did NOT list. It is here because it is not a second key: the +// `auto` persistence arm resolves to the same Node.js file adapter as the +// `file` arm, and this value is forwarded to the same +// `FileSystemPersistenceAdapter` field, in the same milliseconds, under the +// same `min(100)` bound. Its describe named no unit at all, which is why the +// predicate skipped it — and precisely why renaming only the `file` arm would +// have left ONE value with TWO spellings across sibling arms of one union, with +// the driver reading both. That is the consumer-side dialect Prime Directive +// #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` +// and its describe now names the unit too. Tombstoned with `retiredKey()`; +// covered by `memory-persistence-auto-save-interval-to-ms`, which converts both +// arms in one pass. +export const entry = 'data/AutoPersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..1b8cb1904b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in +// prose and nothing else. Its `min(100)` bound is what made the bare name +// dangerous rather than merely untidy: 100 reads as a plausible number of +// SECONDS, so an author who guessed the unit wrong cleared the bound, was +// refused nowhere, and saved a thousand times more often than intended. +// Renamed to `autoSaveIntervalMs`; the value and the 2000 default are +// unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so +// a bare deletion is not silent, but an unknown-key rejection cannot carry the +// FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 +// conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource +// is a `datasources[]` stack collection member whose `config` is stored whole in +// `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'data/FilePersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts new file mode 100644 index 0000000000..7f191b9d52 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` +// said "Query timeout (ms)" in prose and nothing else, directly beside +// `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a +// unit and one not, with nothing at the call site to tell them apart. Renamed +// to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape is not `.strict()`, so a bare deletion would strip in silence and the +// query would run without the limit its author set. No D2 conversion: query +// options are a per-call driver argument reached only through +// `AggregationPipeline.options`, which no `stack.zod.ts` collection declares +// and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. +export const entry = 'data/NoSQLQueryOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts new file mode 100644 index 0000000000..181d258858 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said +// "Operation timeout in milliseconds" in prose and carried a `.meta({ title: +// 'Timeout (ms)' })` no parse reads — and sat two keys below +// `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying +// both conventions, and the suffixed one was the honest half. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape IS `strictObject`, so the tombstone is here for the prescription an +// unknown-key rejection cannot carry. Covered by the D2 conversion +// `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` +// stack collection member whose `config` is stored whole in `sys_metadata`. +// ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). +// The driver package ships its own parallel `turso.zod.ts` whose `timeout` is +// outside this card's declared population and is renamed by the card that +// widens that population. +export const entry = 'data/TursoConfig:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts new file mode 100644 index 0000000000..4e6b33bcfa --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure +// count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, +// which already spelled its unit, on the same six-key shape. That is the +// sharpest case in this card: a single schema already carried both +// conventions, so a reader had no rule to apply, only two examples that +// disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default +// are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, +// so a bare deletion would strip in silence and the breaker would fall back to +// its default window while the author believed they had widened it. Covered by +// the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: +// `connectors:` is a stack collection and a published connector row lands whole +// in `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'integration/CircuitBreakerConfig:monitoringWindow'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts new file mode 100644 index 0000000000..724af5404b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` +// said "Polling interval in seconds" in prose and nothing else. A polling +// cadence is exactly the number a reader guesses at, and the bare name `interval` +// means MILLISECONDS elsewhere in this same spec — the identical spelling +// carrying two units a thousandfold apart is the collision that got this whole +// population ruled rather than merely noted. Renamed to `intervalSeconds`; the +// value is unchanged. Tombstoned with `retiredKey()`; the shape is not +// `.strict()`, so a bare deletion would strip in silence. Covered by the D2 +// conversion `connector-health-and-trigger-durations-unit-in-key`. +// ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven +// by it). The rename does not change that; it makes the declaration honest +// about its unit for whoever implements the loop. +export const entry = 'integration/ConnectorTrigger:interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts new file mode 100644 index 0000000000..d83c51754a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said +// "CORS preflight cache duration in seconds" in prose and nothing else. +// ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, +// and the asymmetry with its twin is load-bearing: every bucket-CORS standard +// this value is forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, +// GCS `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so marking it would have +// exempted a DEVIATION from the cited standard rather than a mirror of it. The +// twin `shared/CorsConfig.maxAge` DID get the marker, because the Fetch response +// header it mirrors — `Access-Control-Max-Age` — genuinely carries no unit token. +// Two `maxAge` keys, opposite sides of the line; do not harmonise them. Renamed +// to `maxAgeSeconds`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: not a stack collection member, not a stored row. +// See `system-object-storage-durations-unit-in-key`. +export const entry = 'system/AccessControlConfig:maxAge'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts new file mode 100644 index 0000000000..b0cdc8ae5d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` +// said "Seconds before half-open state" in prose only, while the `lockout` block +// three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, +// two conventions, and the two are not even the same unit. Renamed to +// `resetTimeoutSeconds`; the value and the 30 default are unchanged. Tombstoned +// with `retiredKey()`. No D2 conversion: not a stack collection member, not a +// stored row. See `system-cache-durations-unit-in-key`. +export const entry = 'system/CacheAvalanchePrevention:circuitBreaker.resetTimeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts new file mode 100644 index 0000000000..7f7198f8de --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `CacheTier.ttl` said "Default TTL +// in seconds" in prose and nothing else, on a tier whose sibling `maxSize` is a +// size in MB: two bare numbers side by side, neither naming its unit at the +// authoring site. Renamed to `ttlSeconds`; the value and the 300 default are +// unchanged. Tombstoned with `retiredKey()` — `CacheTierSchema` is a plain +// `z.object()`, so a bare deletion would strip the old key in silence. No D2 +// conversion: `stack.zod.ts` declares no `cache` collection and a cache tier is +// never a stored metadata row, so the conversion chain has no seam that sees it. +// See `system-cache-durations-unit-in-key`. +export const entry = 'system/CacheTier:ttl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts new file mode 100644 index 0000000000..8043fdf00b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. This is the live 1000x collision +// that got the whole population ruled: `CollaborationSessionConfig.idleTimeout` +// is MILLISECONDS while the tenant surface carried its own `idleTimeout` in +// SECONDS, so `idleTimeout: 300000` meant five minutes here and three and a half +// days there, with nothing at either authoring site to tell them apart. Renamed +// to `idleTimeoutMs`; the value and the 300000 default are unchanged. Tombstoned +// with `retiredKey()`. No D2 conversion: `stack.zod.ts` declares no +// `collaboration` collection and a session config is a runtime call argument, +// not a stored metadata row. See `system-collaboration-durations-unit-in-key`. +export const entry = 'system/CollaborationSessionConfig:idleTimeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts new file mode 100644 index 0000000000..9a86e6ad4c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `snapshot.interval` said +// "Snapshot interval in milliseconds" in prose and nothing else. It moves in the +// same stroke as its parent's `idleTimeout`: both are session-lifetime durations +// on one config object, and leaving one bare would have kept exactly the +// ambiguity the rename removes. Renamed to `intervalMs`; the value is unchanged. +// Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. +// See `system-collaboration-durations-unit-in-key`. +export const entry = 'system/CollaborationSessionConfig:snapshot.interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts new file mode 100644 index 0000000000..f3d857a57b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` +// said "Health check interval in seconds" in prose and nothing else. Renamed to +// `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. +// ⚠️ Its neighbour `FailoverConfig.dns.ttl` on this same schema keeps its bare +// name and is NOT part of this rename — that key carries an `externalVocabulary` +// marker because it mirrors the DNS resource-record TTL field (RFC 1035 §4.1.3), +// spelled `ttl` by every provider API it is forwarded to. This one mirrors +// nothing outside the repo. Tombstoned with `retiredKey()`. No D2 conversion: +// not a stack collection member, not a stored row. +// See `system-failover-health-check-interval-unit-in-key`. +export const entry = 'system/FailoverConfig:healthCheckInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts new file mode 100644 index 0000000000..b35aa4ae24 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `MetricAggregationConfig.window.size` +// said "Window size in seconds" in prose and nothing else. Renamed to +// `durationSeconds`, NOT to the gate's mechanical `sizeSeconds`: `size` is +// byte/row-count vocabulary everywhere else in this spec (`CacheTier.maxSize` is +// MB, `RegistryConfig.cache.maxSize` is bytes, this file's own `batch.size` is a +// row count), so `sizeSeconds` would have preserved the misleading half of the +// name and bolted a unit onto it. `windowSeconds` was rejected too — the parent +// key is already `window`, so it would read `window.windowSeconds`. The value is +// unchanged. Tombstoned with `retiredKey()`. No D2 conversion: `stack.zod.ts` +// declares no `metrics` collection and an aggregation config is not a stored +// metadata row. See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/MetricAggregationConfig:window.size'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts new file mode 100644 index 0000000000..65e81eb040 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` +// said "Duration in milliseconds" in prose and nothing else — while +// `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled +// the identical measurement correctly. The counter-example was in the file, which +// is what makes this one a drift rather than a convention. Renamed to +// `durationMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 +// conversion: `stack.zod.ts` declares `jobs`, not `queues`, and a queue config is +// worker host configuration rather than a stored metadata row. +// See `system-worker-queue-rate-limit-duration-unit-in-key`. +export const entry = 'system/QueueConfig:rateLimit.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts new file mode 100644 index 0000000000..21d5a0df46 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryConfig.cache.ttl` said +// "Cache TTL in seconds" in prose and nothing else, next to a `maxSize` in the +// same cache block measured in BYTES. Renamed to `ttlSeconds`; the value and the +// 3600 default are unchanged. Tombstoned with `retiredKey()`. No D2 conversion: +// not a stack collection member, not a stored row. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryConfig:cache.ttl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts new file mode 100644 index 0000000000..3747bec065 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.syncInterval` +// said "Auto-sync interval in seconds" in prose and nothing else, on a block +// whose `timeout` two keys down was MILLISECONDS: one upstream declaration, two +// units, neither spelled at the authoring site. Renamed to `syncIntervalSeconds`; +// the value and the min-60 bound are unchanged. Tombstoned with `retiredKey()`. +// No D2 conversion: `stack.zod.ts` declares no `registry` collection and a +// registry config is host configuration, not a stored metadata row. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryUpstream:syncInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts new file mode 100644 index 0000000000..ecf8f27619 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.timeout` said +// "Request timeout in milliseconds" in prose and nothing else, beside a +// seconds-valued `syncInterval` on the same block. Its `min(1000)` bound is the +// sharpest reading of why the rule exists: under the wrong unit that floor reads +// as sixteen minutes rather than one second, and no parse can catch the mistake +// because both readings are in range. Renamed to `timeoutMs`; the value, the +// 30000 default and the min-1000 bound are unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion, for its sibling's reason. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryUpstream:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts new file mode 100644 index 0000000000..f835dc1436 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. The second of the two +// byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the +// same prose and takes the same new name, `durationSeconds`, for the reason +// recorded on its twin (`system/MetricAggregationConfig:window.size`). Registered +// as its own row because the authorable surface is per DEF, not per source line: +// an author migrating an SLI never reads the aggregation-config entry. The value +// is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. +// See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/ServiceLevelIndicator:window.size'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts new file mode 100644 index 0000000000..b09eec90d2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `ServiceLevelObjective.period.duration` +// said "Duration in seconds" in prose and nothing else. Renamed to +// `durationSeconds`; the value is unchanged. This key is why the two `window.size` +// keys above land on `durationSeconds` rather than `sizeSeconds`: the file already +// spelled a window length as a `duration` one schema down, so the three +// measurements now read alike instead of one of them borrowing byte vocabulary. +// Tombstoned with `retiredKey()`. No D2 conversion: an SLO is not a stack +// collection member and not a stored metadata row. +// See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/ServiceLevelObjective:period.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts new file mode 100644 index 0000000000..18d4ca0f35 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `Span.duration` said "Duration in +// milliseconds" in prose and nothing else, on a shape that already spells its two +// instants `startTime` / `endTime`. Renamed to `durationMs`; the value is +// unchanged. Not an `externalVocabulary` mirror: OpenTelemetry, which this shape +// follows, carries span length as a start/end nanosecond PAIR and declares no key +// named `duration` at all, so there is no external spelling to mirror here. +// Tombstoned with `retiredKey()`. No D2 conversion: a span is a runtime-emitted +// measurement, never authored metadata and never a stored `sys_metadata` row. +// See `system-tracing-span-duration-unit-in-key`. +export const entry = 'system/Span:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts new file mode 100644 index 0000000000..e0d8a64564 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `StorageConnection.timeout` said +// "Connection timeout in milliseconds" in prose and nothing else. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 +// conversion: `stack.zod.ts` declares no `objectStorage` collection and a storage +// connection is host configuration, not a stored metadata row. +// See `system-object-storage-durations-unit-in-key`. +export const entry = 'system/StorageConnection:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts new file mode 100644 index 0000000000..e4bab96f3c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` +// said "Auto-refresh interval in seconds" in prose and nothing else. The three +// rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — +// measure how many spellings authors actually reach for, and not one of them +// named a unit either, so every door into this key left the cadence ambiguous. +// All three were repointed to the new spelling in the same edit. Renamed to +// `refreshIntervalSeconds`; the value is unchanged. Tombstoned with +// `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the +// prescription an unknown-key rejection cannot carry. Covered by the D2 +// conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: +// `dashboards:` is a stack collection and a dashboard is a registered metadata +// kind stored as a row. +// ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's +// dashboard renderer reads this key and multiplies by 1000, and publishes it as +// a registry input, so its reader could not move in this PR the way every other +// reader in this card did. Sequenced as a follow-up card behind a release that +// actually ships the rename; until then the renderer sees an absent key and +// does not start its timer. +export const entry = 'ui/Dashboard:refreshInterval'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts new file mode 100644 index 0000000000..df74927cb7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts new file mode 100644 index 0000000000..9ec8d4ebfc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts new file mode 100644 index 0000000000..749f2f66f1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-cache-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two cache durations whose name carried no unit: CacheTier.ttl and ' + + 'CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)', + replacement: 'ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 ' + + 'TTL default and the 30 reset default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These two are one entry because they are one file and one authoring session: a ' + + 'cache tier and the avalanche-prevention block that protects it. Each sat beside a ' + + 'number in a DIFFERENT unit with nothing at the authoring site to separate them — ' + + 'CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout ' + + '(seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That ' + + 'last pair is the sharpest case on this file: one shape already carried both ' + + 'conventions, and the suffixed one was the honest half. Both are retiredKey() ' + + 'tombstones; neither shape is strict, so a bare deletion would strip in silence and ' + + 'the unknown-key error could not carry the rename. Why a semantic entry and not a D2 ' + + 'conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor ' + + 'an avalanche-prevention block is a registered metadata kind stored as a sys_metadata ' + + 'row, so the conversion chain has no seam that would see one. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every author of a CacheTier spells ttlSeconds and every author of a ' + + 'CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either ' + + 'old spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier ' + + 'given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted ' + + 'key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing ' + + 'this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its ' + + 'MILLISECOND unit — the two timeouts on this schema were never the same unit and must ' + + 'not be migrated as if they were.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts new file mode 100644 index 0000000000..224dd2177e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-collaboration-durations-unit-in-key', + surface: 'the two collaboration-session durations whose name carried no unit: ' + + 'CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval ' + + '(system/collaboration.zod.ts)', + replacement: 'idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the ' + + '300000 idle-timeout default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'idleTimeout is the collision that got this whole population ruled rather than merely ' + + 'noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout ' + + 'in SECONDS at the same time — so the identical bare name meant five minutes on one ' + + 'shape and three and a half days on the other, a 1000x divergence no parse could catch ' + + 'because both readings are positive integers. The tenant half was already renamed ' + + '(#15626); this is the half that remained. snapshot.interval rides in the same entry ' + + 'because it is the same object graph and the same authoring session — leaving one bare ' + + 'beside the other would have preserved exactly the ambiguity the rename removes. Both ' + + 'are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip ' + + 'in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no ' + + 'collaboration collection, and a session config is a runtime call argument rather than ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see it. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot ' + + 'block spells intervalMs. Authoring either old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, ' + + 'an omitted key still defaults to 300000, and the positive-integer bounds ride along ' + + 'with the renamed keys so a zero or negative interval is still refused. The migration ' + + 'is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — ' + + 'the seconds-valued tenant twin is already gone, so a surviving bare spelling is now ' + + 'unambiguously a missed edit rather than the other key.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts new file mode 100644 index 0000000000..5e52bfc9fd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-failover-health-check-interval-unit-in-key', + surface: 'FailoverConfig.healthCheckInterval, the disaster-recovery health-check period ' + + 'whose name carried no unit (system/disaster-recovery.zod.ts)', + replacement: 'healthCheckIntervalSeconds — rename the key; the value and the 30 default ' + + 'are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because its file has exactly one offender left — and because the key ' + + 'directly beside it is the counter-example that shows where the line falls. ' + + 'FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: ' + + 'it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL ' + + 'field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is ' + + 'forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this ' + + 'repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is ' + + 'not strict, so a bare deletion would strip in silence. Why a semantic entry and not a ' + + 'D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover ' + + 'config is host configuration, never a stored sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every FailoverConfig author spells healthCheckIntervalSeconds; authoring ' + + 'healthCheckInterval fails to compile (input type `never`) and fails to parse with the ' + + 'rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes ' + + 'every thirty seconds exactly as before, and an omitted key still defaults to 30. The ' + + 'migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed ' + + 'it too has over-applied the rule and stripped a declared exemption.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts new file mode 100644 index 0000000000..82af569b9a --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-metrics-window-durations-unit-in-key', + surface: 'the three metrics window/period lengths whose name carried no unit: ' + + 'MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ' + + 'ServiceLevelObjective.period.duration (system/metrics.zod.ts)', + replacement: 'window.durationSeconds, window.durationSeconds and period.durationSeconds — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one measurement expressed three times on one ' + + 'file: how long a window or period is. The new name is deliberately NOT the mechanical ' + + 'sizeSeconds the gate prints. size means a byte or row count everywhere else in this ' + + 'spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this ' + + 'very file spells a batch row count size — so sizeSeconds would have kept the ' + + 'misleading half of the name and bolted a unit onto it, leaving a reader to decide ' + + 'whether a window is measured in bytes-per-second or in time. windowSeconds was ' + + 'rejected for a plainer reason: the parent key is already window, so it would read ' + + 'window.windowSeconds. durationSeconds names what the number IS, and the file itself ' + + 'supplied the precedent — ServiceLevelObjective.period already called its length a ' + + 'duration, so after the rename all three read alike instead of one borrowing byte ' + + 'vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare ' + + 'deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI ' + + 'or an SLO is a registered metadata kind stored as a sys_metadata row. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every aggregation window and SLI window spells durationSeconds, and every SLO period ' + + 'spells durationSeconds. Authoring window.size or period.duration fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as ' + + 'size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two ' + + 'keys on this same file deliberately do NOT move, and a sweep that renamed either has ' + + 'over-applied the rule: the error-budget burn-rate window ' + + 'names its unit only in the JSDoc above it ("Window size in seconds"), a channel the ' + + 'gate does not read: it reads `.describe()` and `.meta({ description })`, and that ' + + 'key\'s describe ("Window size") names none. So the gate lists it among the ' + + 'duration-shaped keys without judging it — neither an offender nor an exemption — and ' + + 'it is outside this rename, not outside the gate population; that JSDoc-channel gap is ' + + '#15939; ' + + 'and the exporter batch size is a COUNT of records, not a duration, so it has no unit ' + + 'to carry. Both keep their names.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts new file mode 100644 index 0000000000..4190f1bb9d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-object-storage-durations-unit-in-key', + surface: 'the two object-storage durations whose name carried no unit: ' + + 'AccessControlConfig.maxAge and StorageConnection.timeout ' + + '(system/object-storage.zod.ts)', + replacement: 'maxAgeSeconds and timeoutMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'AccessControlConfig.maxAge is the one key in this stack where the two structural ' + + 'exemptions and the rename look alike from a distance, so the reasoning is recorded ' + + 'rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on ' + + 'evidence: every bucket-CORS standard the value is forwarded to spells the field WITH ' + + 'its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it ' + + 'would have exempted a DEVIATION from the cited standard rather than a mirror of it, ' + + 'which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge ' + + 'DID get the marker and keeps its bare name, because the Fetch response header that one ' + + 'mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on ' + + 'opposite sides of the same line; the asymmetry is the point and must not be ' + + 'harmonised. StorageConnection.timeout rides along as the plain case on the same file. ' + + 'Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would ' + + 'strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'no objectStorage collection, and neither shape is a registered metadata kind stored as ' + + 'a sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every bucket access-control block spells maxAgeSeconds and every storage connection ' + + 'spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and ' + + 'the non-negative bounds ride along with the renamed keys. The migration is proved ' + + 'correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace ' + + 'that renamed both has destroyed a declared external-vocabulary mirror, and the gate ' + + 'will not catch it because the marker exempts the key either way.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts new file mode 100644 index 0000000000..12d980ceb3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-registry-config-durations-unit-in-key', + surface: 'the three package-registry durations whose name carried no unit: ' + + 'RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl ' + + '(system/registry-config.zod.ts)', + replacement: 'syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every ' + + 'value, the 30000 timeout default and the 3600 TTL default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one file and, for the first two, one object: ' + + 'RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five ' + + 'lines apart, both bare. That pair carries the clearest demonstration in this card of ' + + 'why a bound is no substitute for a name — timeout is min(1000), which reads as one ' + + 'second under the right unit and as sixteen minutes under the wrong one, and both ' + + 'readings satisfy the validator. The cache TTL is the same defect one schema over, ' + + 'beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes ' + + 'are not strict, so a bare deletion would strip in silence. Why a semantic entry and ' + + 'not a D2 conversion: stack.zod.ts declares no registry collection, and a registry ' + + 'config is host configuration read at startup rather than a stored sys_metadata row, so ' + + 'the conversion chain has no seam that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry ' + + 'cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an ' + + 'omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to ' + + '3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a ' + + 'too-small interval or timeout is still refused. The pair on RegistryUpstream is the ' + + 'one to check by hand rather than by search-and-replace: after the migration a reader ' + + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts new file mode 100644 index 0000000000..a1d3cbba2f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-tracing-span-duration-unit-in-key', + surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' + + '(system/tracing.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file and the only one in this ' + + 'card that is a pure runtime-emitted measurement: a span is written by an exporter and ' + + 'read by a backend, never authored by hand. That is also why it is a rename and not an ' + + 'externalVocabulary mirror, which is the exemption a tracing shape would most plausibly ' + + 'claim: OpenTelemetry, whose model this schema follows, carries span length as a ' + + 'start/end nanosecond PAIR and declares no key named duration at all, so there is no ' + + 'external spelling for the marker to point at. The shape already spells its two ' + + 'instants startTime and endTime, so the bare duration was the one measurement on the ' + + 'span that did not say what it was. Tombstoned with retiredKey(); the shape is not ' + + 'strict, so a bare deletion would strip in silence and an exporter emitting the old ' + + 'spelling would lose the value without an error. Why a semantic entry and not a D2 ' + + 'conversion: an emitted span is never a stack collection member and never a stored ' + + 'sys_metadata row — the same disposition every runtime-emitted measurement in this ' + + 'stack has taken. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a ' + + 'span length reads durationMs. Authoring duration fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription rather than silently dropping the ' + + 'measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and ' + + 'the non-negative bound rides along with the renamed key so a negative span length is ' + + 'still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, ' + + 'not numbers, and are untouched by this rename.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts new file mode 100644 index 0000000000..2189309894 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-worker-queue-rate-limit-duration-unit-in-key', + surface: 'QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried ' + + 'no unit (system/worker.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender left on its file, and the file itself ' + + 'is what makes it a drift rather than a convention: TaskResult.durationMs, declared ' + + 'ninety lines earlier in the SAME source, already spelled the identical measurement ' + + 'with its unit. One file, one unit, two spellings, and the correct one was already ' + + 'there — so this rename removes an internal inconsistency rather than imposing an ' + + 'external one. Tombstoned with retiredKey(); the shape is not strict, so a bare ' + + 'deletion would strip in silence and a queue would fall back to no rate limit at all ' + + 'without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'jobs, not queues, so a QueueConfig is worker host configuration rather than a stack ' + + 'collection member or a stored sys_metadata row, and the conversion chain has no seam ' + + 'that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than silently dropping the window and leaving the queue unthrottled. Behaviour is ' + + 'unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as ' + + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' + + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 03e246a539..86d12708fb 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5347,7 +5347,20 @@ const step18: MigrationStep = { 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' + 'reference pages never published (#14519) — are retiredKey tombstones with a ' + - 'semantic entry each, naming the suffixed key.', + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' + + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' + + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' + + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds`, and the two ' + + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' + + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' + + 'same file adapter, so splitting them would have left one value with two spellings) ' + + 'and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a ' + + 'connector and a datasource are stack collection members stored as rows; the two with ' + + 'no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, ' + + 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' + + 'tombstones with a semantic entry each. That remainder is what takes ' + + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' + + 'goes red again by design when its declared population widens beyond that subtree.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5368,6 +5381,10 @@ const step18: MigrationStep = { 'hook-timeout-to-timeout-ms', 'job-timeout-to-timeout-ms', 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + 'dashboard-refresh-interval-to-refresh-interval-seconds', + 'connector-health-and-trigger-durations-unit-in-key', + 'memory-persistence-auto-save-interval-to-ms', + 'turso-config-timeout-to-timeout-ms', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -5527,6 +5544,38 @@ const step18: MigrationStep = { + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' + 'ever read the container, so removing it removes no behaviour.', }, + { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', + }, { id: 'analytics-authorable-unknown-keys-refused', // Same-major bookkeeping (#10414): batch D also closed the nested @@ -6101,6 +6150,33 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', + }, { id: 'datasource-config-mongo-options-credential-refused', surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + @@ -8719,6 +8795,260 @@ const step18: MigrationStep = { + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, + { + id: 'system-cache-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two cache durations whose name carried no unit: CacheTier.ttl and ' + + 'CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)', + replacement: 'ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 ' + + 'TTL default and the 30 reset default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These two are one entry because they are one file and one authoring session: a ' + + 'cache tier and the avalanche-prevention block that protects it. Each sat beside a ' + + 'number in a DIFFERENT unit with nothing at the authoring site to separate them — ' + + 'CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout ' + + '(seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That ' + + 'last pair is the sharpest case on this file: one shape already carried both ' + + 'conventions, and the suffixed one was the honest half. Both are retiredKey() ' + + 'tombstones; neither shape is strict, so a bare deletion would strip in silence and ' + + 'the unknown-key error could not carry the rename. Why a semantic entry and not a D2 ' + + 'conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor ' + + 'an avalanche-prevention block is a registered metadata kind stored as a sys_metadata ' + + 'row, so the conversion chain has no seam that would see one. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every author of a CacheTier spells ttlSeconds and every author of a ' + + 'CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either ' + + 'old spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier ' + + 'given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted ' + + 'key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing ' + + 'this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its ' + + 'MILLISECOND unit — the two timeouts on this schema were never the same unit and must ' + + 'not be migrated as if they were.', + }, + { + id: 'system-collaboration-durations-unit-in-key', + surface: 'the two collaboration-session durations whose name carried no unit: ' + + 'CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval ' + + '(system/collaboration.zod.ts)', + replacement: 'idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the ' + + '300000 idle-timeout default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'idleTimeout is the collision that got this whole population ruled rather than merely ' + + 'noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout ' + + 'in SECONDS at the same time — so the identical bare name meant five minutes on one ' + + 'shape and three and a half days on the other, a 1000x divergence no parse could catch ' + + 'because both readings are positive integers. The tenant half was already renamed ' + + '(#15626); this is the half that remained. snapshot.interval rides in the same entry ' + + 'because it is the same object graph and the same authoring session — leaving one bare ' + + 'beside the other would have preserved exactly the ambiguity the rename removes. Both ' + + 'are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip ' + + 'in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no ' + + 'collaboration collection, and a session config is a runtime call argument rather than ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see it. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot ' + + 'block spells intervalMs. Authoring either old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, ' + + 'an omitted key still defaults to 300000, and the positive-integer bounds ride along ' + + 'with the renamed keys so a zero or negative interval is still refused. The migration ' + + 'is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — ' + + 'the seconds-valued tenant twin is already gone, so a surviving bare spelling is now ' + + 'unambiguously a missed edit rather than the other key.', + }, + { + id: 'system-failover-health-check-interval-unit-in-key', + surface: 'FailoverConfig.healthCheckInterval, the disaster-recovery health-check period ' + + 'whose name carried no unit (system/disaster-recovery.zod.ts)', + replacement: 'healthCheckIntervalSeconds — rename the key; the value and the 30 default ' + + 'are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because its file has exactly one offender left — and because the key ' + + 'directly beside it is the counter-example that shows where the line falls. ' + + 'FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: ' + + 'it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL ' + + 'field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is ' + + 'forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this ' + + 'repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is ' + + 'not strict, so a bare deletion would strip in silence. Why a semantic entry and not a ' + + 'D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover ' + + 'config is host configuration, never a stored sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every FailoverConfig author spells healthCheckIntervalSeconds; authoring ' + + 'healthCheckInterval fails to compile (input type `never`) and fails to parse with the ' + + 'rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes ' + + 'every thirty seconds exactly as before, and an omitted key still defaults to 30. The ' + + 'migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed ' + + 'it too has over-applied the rule and stripped a declared exemption.', + }, + { + id: 'system-metrics-window-durations-unit-in-key', + surface: 'the three metrics window/period lengths whose name carried no unit: ' + + 'MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ' + + 'ServiceLevelObjective.period.duration (system/metrics.zod.ts)', + replacement: 'window.durationSeconds, window.durationSeconds and period.durationSeconds — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one measurement expressed three times on one ' + + 'file: how long a window or period is. The new name is deliberately NOT the mechanical ' + + 'sizeSeconds the gate prints. size means a byte or row count everywhere else in this ' + + 'spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this ' + + 'very file spells a batch row count size — so sizeSeconds would have kept the ' + + 'misleading half of the name and bolted a unit onto it, leaving a reader to decide ' + + 'whether a window is measured in bytes-per-second or in time. windowSeconds was ' + + 'rejected for a plainer reason: the parent key is already window, so it would read ' + + 'window.windowSeconds. durationSeconds names what the number IS, and the file itself ' + + 'supplied the precedent — ServiceLevelObjective.period already called its length a ' + + 'duration, so after the rename all three read alike instead of one borrowing byte ' + + 'vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare ' + + 'deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI ' + + 'or an SLO is a registered metadata kind stored as a sys_metadata row. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every aggregation window and SLI window spells durationSeconds, and every SLO period ' + + 'spells durationSeconds. Authoring window.size or period.duration fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as ' + + 'size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two ' + + 'keys on this same file deliberately do NOT move, and a sweep that renamed either has ' + + 'over-applied the rule: the error-budget burn-rate window ' + + 'names its unit only in the JSDoc above it ("Window size in seconds"), a channel the ' + + 'gate does not read: it reads `.describe()` and `.meta({ description })`, and that ' + + 'key\'s describe ("Window size") names none. So the gate lists it among the ' + + 'duration-shaped keys without judging it — neither an offender nor an exemption — and ' + + 'it is outside this rename, not outside the gate population; that JSDoc-channel gap is ' + + '#15939; ' + + 'and the exporter batch size is a COUNT of records, not a duration, so it has no unit ' + + 'to carry. Both keep their names.', + }, + { + id: 'system-object-storage-durations-unit-in-key', + surface: 'the two object-storage durations whose name carried no unit: ' + + 'AccessControlConfig.maxAge and StorageConnection.timeout ' + + '(system/object-storage.zod.ts)', + replacement: 'maxAgeSeconds and timeoutMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'AccessControlConfig.maxAge is the one key in this stack where the two structural ' + + 'exemptions and the rename look alike from a distance, so the reasoning is recorded ' + + 'rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on ' + + 'evidence: every bucket-CORS standard the value is forwarded to spells the field WITH ' + + 'its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it ' + + 'would have exempted a DEVIATION from the cited standard rather than a mirror of it, ' + + 'which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge ' + + 'DID get the marker and keeps its bare name, because the Fetch response header that one ' + + 'mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on ' + + 'opposite sides of the same line; the asymmetry is the point and must not be ' + + 'harmonised. StorageConnection.timeout rides along as the plain case on the same file. ' + + 'Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would ' + + 'strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'no objectStorage collection, and neither shape is a registered metadata kind stored as ' + + 'a sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every bucket access-control block spells maxAgeSeconds and every storage connection ' + + 'spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and ' + + 'the non-negative bounds ride along with the renamed keys. The migration is proved ' + + 'correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace ' + + 'that renamed both has destroyed a declared external-vocabulary mirror, and the gate ' + + 'will not catch it because the marker exempts the key either way.', + }, + { + id: 'system-registry-config-durations-unit-in-key', + surface: 'the three package-registry durations whose name carried no unit: ' + + 'RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl ' + + '(system/registry-config.zod.ts)', + replacement: 'syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every ' + + 'value, the 30000 timeout default and the 3600 TTL default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one file and, for the first two, one object: ' + + 'RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five ' + + 'lines apart, both bare. That pair carries the clearest demonstration in this card of ' + + 'why a bound is no substitute for a name — timeout is min(1000), which reads as one ' + + 'second under the right unit and as sixteen minutes under the wrong one, and both ' + + 'readings satisfy the validator. The cache TTL is the same defect one schema over, ' + + 'beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes ' + + 'are not strict, so a bare deletion would strip in silence. Why a semantic entry and ' + + 'not a D2 conversion: stack.zod.ts declares no registry collection, and a registry ' + + 'config is host configuration read at startup rather than a stored sys_metadata row, so ' + + 'the conversion chain has no seam that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry ' + + 'cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an ' + + 'omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to ' + + '3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a ' + + 'too-small interval or timeout is still refused. The pair on RegistryUpstream is the ' + + 'one to check by hand rather than by search-and-replace: after the migration a reader ' + + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', + }, + { + id: 'system-tracing-span-duration-unit-in-key', + surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' + + '(system/tracing.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file and the only one in this ' + + 'card that is a pure runtime-emitted measurement: a span is written by an exporter and ' + + 'read by a backend, never authored by hand. That is also why it is a rename and not an ' + + 'externalVocabulary mirror, which is the exemption a tracing shape would most plausibly ' + + 'claim: OpenTelemetry, whose model this schema follows, carries span length as a ' + + 'start/end nanosecond PAIR and declares no key named duration at all, so there is no ' + + 'external spelling for the marker to point at. The shape already spells its two ' + + 'instants startTime and endTime, so the bare duration was the one measurement on the ' + + 'span that did not say what it was. Tombstoned with retiredKey(); the shape is not ' + + 'strict, so a bare deletion would strip in silence and an exporter emitting the old ' + + 'spelling would lose the value without an error. Why a semantic entry and not a D2 ' + + 'conversion: an emitted span is never a stack collection member and never a stored ' + + 'sys_metadata row — the same disposition every runtime-emitted measurement in this ' + + 'stack has taken. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a ' + + 'span length reads durationMs. Authoring duration fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription rather than silently dropping the ' + + 'measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and ' + + 'the non-negative bound rides along with the renamed key so a negative span length is ' + + 'still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, ' + + 'not numbers, and are untouched by this rename.', + }, + { + id: 'system-worker-queue-rate-limit-duration-unit-in-key', + surface: 'QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried ' + + 'no unit (system/worker.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender left on its file, and the file itself ' + + 'is what makes it a drift rather than a convention: TaskResult.durationMs, declared ' + + 'ninety lines earlier in the SAME source, already spelled the identical measurement ' + + 'with its unit. One file, one unit, two spellings, and the correct one was already ' + + 'there — so this rename removes an internal inconsistency rather than imposing an ' + + 'external one. Tombstoned with retiredKey(); the shape is not strict, so a bare ' + + 'deletion would strip in silence and a queue would fall back to no rate limit at all ' + + 'without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'jobs, not queues, so a QueueConfig is worker host configuration rather than a stack ' + + 'collection member or a stored sys_metadata row, and the conversion chain has no seam ' + + 'that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than silently dropping the window and leaving the queue unthrottled. Behaviour is ' + + 'unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as ' + + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' + + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', + }, { id: 'tenant-timeouts-unit-in-key', surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' @@ -9408,6 +9738,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, and no + // existing offender is grandfathered. `ConversationAnalytics.duration` said + // "Session duration in seconds" in prose and nothing else, on a shape where + // every OTHER number is a count (messages, tokens, pruning events) and the two + // neighbouring instants already spell themselves `firstMessageAt` / + // `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. + // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare + // deletion would strip the key in silence and the analytics row would lose the + // one measurement it carries, with no error anywhere. No D2 conversion: + // conversation analytics are computed and emitted at runtime, never authored + // and never a stored `sys_metadata` row, so the chain has no seam that sees + // one. See `ai-conversation-analytics-duration-unit-in-key`. + 'ai/ConversationAnalytics:duration', // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): // a duration-shaped `z.number()` key carries its unit in its NAME, and no // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response @@ -9840,6 +10184,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // construction configuration, never a stored row; the semantic entry // `websocket-durations-unit-in-key` carries the prescription. 'api/WebSocketServerConfig:heartbeatInterval', + // #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card + // that the gate did NOT list. It is here because it is not a second key: the + // `auto` persistence arm resolves to the same Node.js file adapter as the + // `file` arm, and this value is forwarded to the same + // `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + // same `min(100)` bound. Its describe named no unit at all, which is why the + // predicate skipped it — and precisely why renaming only the `file` arm would + // have left ONE value with TWO spellings across sibling arms of one union, with + // the driver reading both. That is the consumer-side dialect Prime Directive + // #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` + // and its describe now names the unit too. Tombstoned with `retiredKey()`; + // covered by `memory-persistence-auto-save-interval-to-ms`, which converts both + // arms in one pass. + 'data/AutoPersistenceConfig:autoSaveInterval', // #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 @@ -9852,6 +10210,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `driver-options-timeout-to-timeout-ms` carries the prescription. Registered // under 18 for the launch-window reason its neighbours state. 'data/DriverOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in + // prose and nothing else. Its `min(100)` bound is what made the bare name + // dangerous rather than merely untidy: 100 reads as a plausible number of + // SECONDS, so an author who guessed the unit wrong cleared the bound, was + // refused nowhere, and saved a thousand times more often than intended. + // Renamed to `autoSaveIntervalMs`; the value and the 2000 default are + // unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so + // a bare deletion is not silent, but an unknown-key rejection cannot carry the + // FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 + // conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource + // is a `datasources[]` stack collection member whose `config` is stored whole in + // `sys_metadata`, so the chain has a seam that sees it. + 'data/FilePersistenceConfig:autoSaveInterval', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a @@ -9879,6 +10251,46 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // conversion `metric-filters-removed`, which strips the key from every metric // in `analyticsCubes[].measures`. 'data/Metric:filters', + // #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` + // said "Query timeout (ms)" in prose and nothing else, directly beside + // `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a + // unit and one not, with nothing at the call site to tell them apart. Renamed + // to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape is not `.strict()`, so a bare deletion would strip in silence and the + // query would run without the limit its author set. No D2 conversion: query + // options are a per-call driver argument reached only through + // `AggregationPipeline.options`, which no `stack.zod.ts` collection declares + // and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. + 'data/NoSQLQueryOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said + // "Operation timeout in milliseconds" in prose and carried a `.meta({ title: + // 'Timeout (ms)' })` no parse reads — and sat two keys below + // `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying + // both conventions, and the suffixed one was the honest half. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape IS `strictObject`, so the tombstone is here for the prescription an + // unknown-key rejection cannot carry. Covered by the D2 conversion + // `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` + // stack collection member whose `config` is stored whole in `sys_metadata`. + // ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). + // The driver package ships its own parallel `turso.zod.ts` whose `timeout` is + // outside this card's declared population and is renamed by the card that + // widens that population. + 'data/TursoConfig:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure + // count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, + // which already spelled its unit, on the same six-key shape. That is the + // sharpest case in this card: a single schema already carried both + // conventions, so a reader had no rule to apply, only two examples that + // disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default + // are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, + // so a bare deletion would strip in silence and the breaker would fall back to + // its default window while the author believed they had widened it. Covered by + // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: + // `connectors:` is a stack collection and a published connector row lands whole + // in `sys_metadata`, so the chain has a seam that sees it. + 'integration/CircuitBreakerConfig:monitoringWindow', // #14676 — ADR-0049 enforce-or-remove on `ConnectorSchema.errorMapping` (triage // ruling 2026-09-02: removal via the `spec-property-retirement` playbook; the // split condition — a downstream consumer in objectui or a customer stack — @@ -9906,6 +10318,19 @@ 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 #12497 / #13823 grading). 'integration/Connector:errorMapping', + // #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` + // said "Polling interval in seconds" in prose and nothing else. A polling + // cadence is exactly the number a reader guesses at, and the bare name `interval` + // means MILLISECONDS elsewhere in this same spec — the identical spelling + // carrying two units a thousandfold apart is the collision that got this whole + // population ruled rather than merely noted. Renamed to `intervalSeconds`; the + // value is unchanged. Tombstoned with `retiredKey()`; the shape is not + // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 + // conversion `connector-health-and-trigger-durations-unit-in-key`. + // ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven + // by it). The rename does not change that; it makes the declaration honest + // about its unit for whoever implements the loop. + 'integration/ConnectorTrigger:interval', // #14676 — the same tombstone seen through the second carrier. // `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the // `errorMapping` tombstone on the base is inherited by the shape that @@ -10732,6 +11157,38 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // D2 conversion `permission-allow-restore-purge-removed`, which strips the // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowRestore', + // #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said + // "CORS preflight cache duration in seconds" in prose and nothing else. + // ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, + // and the asymmetry with its twin is load-bearing: every bucket-CORS standard + // this value is forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, + // GCS `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so marking it would have + // exempted a DEVIATION from the cited standard rather than a mirror of it. The + // twin `shared/CorsConfig.maxAge` DID get the marker, because the Fetch response + // header it mirrors — `Access-Control-Max-Age` — genuinely carries no unit token. + // Two `maxAge` keys, opposite sides of the line; do not harmonise them. Renamed + // to `maxAgeSeconds`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: not a stack collection member, not a stored row. + // See `system-object-storage-durations-unit-in-key`. + 'system/AccessControlConfig:maxAge', + // #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` + // said "Seconds before half-open state" in prose only, while the `lockout` block + // three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, + // two conventions, and the two are not even the same unit. Renamed to + // `resetTimeoutSeconds`; the value and the 30 default are unchanged. Tombstoned + // with `retiredKey()`. No D2 conversion: not a stack collection member, not a + // stored row. See `system-cache-durations-unit-in-key`. + 'system/CacheAvalanchePrevention:circuitBreaker.resetTimeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `CacheTier.ttl` said "Default TTL + // in seconds" in prose and nothing else, on a tier whose sibling `maxSize` is a + // size in MB: two bare numbers side by side, neither naming its unit at the + // authoring site. Renamed to `ttlSeconds`; the value and the 300 default are + // unchanged. Tombstoned with `retiredKey()` — `CacheTierSchema` is a plain + // `z.object()`, so a bare deletion would strip the old key in silence. No D2 + // conversion: `stack.zod.ts` declares no `cache` collection and a cache tier is + // never a stored metadata row, so the conversion chain has no seam that sees it. + // See `system-cache-durations-unit-in-key`. + 'system/CacheTier:ttl', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10788,6 +11245,35 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/BatchEndpointsConfig:operations.upsertMany` are. // D3 semantic entry: `change-management-duration-keys-retired`. 'system/ChangeRequest:implementation.steps.estimatedMinutes', + // #15679 (stack card 4/6 of #14478) — ruling B. This is the live 1000x collision + // that got the whole population ruled: `CollaborationSessionConfig.idleTimeout` + // is MILLISECONDS while the tenant surface carried its own `idleTimeout` in + // SECONDS, so `idleTimeout: 300000` meant five minutes here and three and a half + // days there, with nothing at either authoring site to tell them apart. Renamed + // to `idleTimeoutMs`; the value and the 300000 default are unchanged. Tombstoned + // with `retiredKey()`. No D2 conversion: `stack.zod.ts` declares no + // `collaboration` collection and a session config is a runtime call argument, + // not a stored metadata row. See `system-collaboration-durations-unit-in-key`. + 'system/CollaborationSessionConfig:idleTimeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `snapshot.interval` said + // "Snapshot interval in milliseconds" in prose and nothing else. It moves in the + // same stroke as its parent's `idleTimeout`: both are session-lifetime durations + // on one config object, and leaving one bare would have kept exactly the + // ambiguity the rename removes. Renamed to `intervalMs`; the value is unchanged. + // Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. + // See `system-collaboration-durations-unit-in-key`. + 'system/CollaborationSessionConfig:snapshot.interval', + // #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` + // said "Health check interval in seconds" in prose and nothing else. Renamed to + // `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. + // ⚠️ Its neighbour `FailoverConfig.dns.ttl` on this same schema keeps its bare + // name and is NOT part of this rename — that key carries an `externalVocabulary` + // marker because it mirrors the DNS resource-record TTL field (RFC 1035 §4.1.3), + // spelled `ttl` by every provider API it is forwarded to. This one mirrors + // nothing outside the repo. Tombstoned with `retiredKey()`. No D2 conversion: + // not a stack collection member, not a stored row. + // See `system-failover-health-check-interval-unit-in-key`. + 'system/FailoverConfig:healthCheckInterval', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10930,6 +11416,54 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (launch-window convention) and the prescription lives at the major boundary // where `migrate meta` users look. 'system/Job:timeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `MetricAggregationConfig.window.size` + // said "Window size in seconds" in prose and nothing else. Renamed to + // `durationSeconds`, NOT to the gate's mechanical `sizeSeconds`: `size` is + // byte/row-count vocabulary everywhere else in this spec (`CacheTier.maxSize` is + // MB, `RegistryConfig.cache.maxSize` is bytes, this file's own `batch.size` is a + // row count), so `sizeSeconds` would have preserved the misleading half of the + // name and bolted a unit onto it. `windowSeconds` was rejected too — the parent + // key is already `window`, so it would read `window.windowSeconds`. The value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: `stack.zod.ts` + // declares no `metrics` collection and an aggregation config is not a stored + // metadata row. See `system-metrics-window-durations-unit-in-key`. + 'system/MetricAggregationConfig:window.size', + // #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` + // said "Duration in milliseconds" in prose and nothing else — while + // `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled + // the identical measurement correctly. The counter-example was in the file, which + // is what makes this one a drift rather than a convention. Renamed to + // `durationMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 + // conversion: `stack.zod.ts` declares `jobs`, not `queues`, and a queue config is + // worker host configuration rather than a stored metadata row. + // See `system-worker-queue-rate-limit-duration-unit-in-key`. + 'system/QueueConfig:rateLimit.duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryConfig.cache.ttl` said + // "Cache TTL in seconds" in prose and nothing else, next to a `maxSize` in the + // same cache block measured in BYTES. Renamed to `ttlSeconds`; the value and the + // 3600 default are unchanged. Tombstoned with `retiredKey()`. No D2 conversion: + // not a stack collection member, not a stored row. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryConfig:cache.ttl', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.syncInterval` + // said "Auto-sync interval in seconds" in prose and nothing else, on a block + // whose `timeout` two keys down was MILLISECONDS: one upstream declaration, two + // units, neither spelled at the authoring site. Renamed to `syncIntervalSeconds`; + // the value and the min-60 bound are unchanged. Tombstoned with `retiredKey()`. + // No D2 conversion: `stack.zod.ts` declares no `registry` collection and a + // registry config is host configuration, not a stored metadata row. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryUpstream:syncInterval', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.timeout` said + // "Request timeout in milliseconds" in prose and nothing else, beside a + // seconds-valued `syncInterval` on the same block. Its `min(1000)` bound is the + // sharpest reading of why the rule exists: under the wrong unit that floor reads + // as sixteen minutes rather than one second, and no parse can catch the mistake + // because both readings are in range. Renamed to `timeoutMs`; the value, the + // 30000 default and the min-1000 bound are unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion, for its sibling's reason. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryUpstream:timeout', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10958,6 +11492,42 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/BatchEndpointsConfig:operations.upsertMany` are. // D3 semantic entry: `change-management-duration-keys-retired`. 'system/RollbackPlan:steps.estimatedMinutes', + // #15679 (stack card 4/6 of #14478) — ruling B. The second of the two + // byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the + // same prose and takes the same new name, `durationSeconds`, for the reason + // recorded on its twin (`system/MetricAggregationConfig:window.size`). Registered + // as its own row because the authorable surface is per DEF, not per source line: + // an author migrating an SLI never reads the aggregation-config entry. The value + // is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. + // See `system-metrics-window-durations-unit-in-key`. + 'system/ServiceLevelIndicator:window.size', + // #15679 (stack card 4/6 of #14478) — ruling B. `ServiceLevelObjective.period.duration` + // said "Duration in seconds" in prose and nothing else. Renamed to + // `durationSeconds`; the value is unchanged. This key is why the two `window.size` + // keys above land on `durationSeconds` rather than `sizeSeconds`: the file already + // spelled a window length as a `duration` one schema down, so the three + // measurements now read alike instead of one of them borrowing byte vocabulary. + // Tombstoned with `retiredKey()`. No D2 conversion: an SLO is not a stack + // collection member and not a stored metadata row. + // See `system-metrics-window-durations-unit-in-key`. + 'system/ServiceLevelObjective:period.duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `Span.duration` said "Duration in + // milliseconds" in prose and nothing else, on a shape that already spells its two + // instants `startTime` / `endTime`. Renamed to `durationMs`; the value is + // unchanged. Not an `externalVocabulary` mirror: OpenTelemetry, which this shape + // follows, carries span length as a start/end nanosecond PAIR and declares no key + // named `duration` at all, so there is no external spelling to mirror here. + // Tombstoned with `retiredKey()`. No D2 conversion: a span is a runtime-emitted + // measurement, never authored metadata and never a stored `sys_metadata` row. + // See `system-tracing-span-duration-unit-in-key`. + 'system/Span:duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `StorageConnection.timeout` said + // "Connection timeout in milliseconds" in prose and nothing else. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 + // conversion: `stack.zod.ts` declares no `objectStorage` collection and a storage + // connection is host configuration, not a stored metadata row. + // See `system-object-storage-durations-unit-in-key`. + 'system/StorageConnection:timeout', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -11063,6 +11633,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // parse) and the D3 semantic entry named below. // D3 semantic entry: `training-deadline-keys-retired`. 'system/TrainingPlan:reminderDaysBefore', + // #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` + // said "Auto-refresh interval in seconds" in prose and nothing else. The three + // rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — + // measure how many spellings authors actually reach for, and not one of them + // named a unit either, so every door into this key left the cadence ambiguous. + // All three were repointed to the new spelling in the same edit. Renamed to + // `refreshIntervalSeconds`; the value is unchanged. Tombstoned with + // `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the + // prescription an unknown-key rejection cannot carry. Covered by the D2 + // conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: + // `dashboards:` is a stack collection and a dashboard is a registered metadata + // kind stored as a row. + // ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's + // dashboard renderer reads this key and multiplies by 1000, and publishes it as + // a registry input, so its reader could not move in this PR the way every other + // reader in this card did. Sequenced as a follow-up card behind a release that + // actually ships the rename; until then the renderer sees an absent key and + // does not start its timer. + 'ui/Dashboard:refreshInterval', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/system/cache.test.ts b/packages/spec/src/system/cache.test.ts index 840fd1c941..82f22587cb 100644 --- a/packages/spec/src/system/cache.test.ts +++ b/packages/spec/src/system/cache.test.ts @@ -41,7 +41,7 @@ describe('CacheTierSchema', () => { expect(tier.name).toBe('memory_cache'); expect(tier.type).toBe('memory'); - expect(tier.ttl).toBe(300); + expect(tier.ttlSeconds).toBe(300); expect(tier.strategy).toBe('lru'); expect(tier.warmup).toBe(false); }); @@ -59,13 +59,13 @@ describe('CacheTierSchema', () => { name: 'redis_tier', type: 'redis', maxSize: 512, - ttl: 600, + ttlSeconds: 600, strategy: 'lfu', warmup: true, }); expect(tier.maxSize).toBe(512); - expect(tier.ttl).toBe(600); + expect(tier.ttlSeconds).toBe(600); expect(tier.strategy).toBe('lfu'); expect(tier.warmup).toBe(true); }); @@ -145,7 +145,7 @@ describe('CacheConfigSchema', () => { enabled: true, tiers: [ { name: 'l1', type: 'memory', maxSize: 128 }, - { name: 'l2', type: 'redis', maxSize: 1024, ttl: 600 }, + { name: 'l2', type: 'redis', maxSize: 1024, ttlSeconds: 600 }, ], invalidation: [ { trigger: 'update', scope: 'pattern', pattern: '*' }, @@ -198,7 +198,7 @@ describe('CacheAvalanchePreventionSchema', () => { it('should accept circuit breaker config', () => { const result = CacheAvalanchePreventionSchema.parse({ - circuitBreaker: { enabled: true, failureThreshold: 10, resetTimeout: 60 }, + circuitBreaker: { enabled: true, failureThreshold: 10, resetTimeoutSeconds: 60 }, }); expect(result.circuitBreaker?.failureThreshold).toBe(10); }); @@ -267,8 +267,8 @@ describe('DistributedCacheConfigSchema', () => { const config = DistributedCacheConfigSchema.parse({ enabled: true, tiers: [ - { name: 'l1', type: 'memory', maxSize: 100, ttl: 60, strategy: 'lru' }, - { name: 'l2', type: 'redis', maxSize: 1000, ttl: 300, strategy: 'lru' }, + { name: 'l1', type: 'memory', maxSize: 100, ttlSeconds: 60, strategy: 'lru' }, + { name: 'l2', type: 'redis', maxSize: 1000, ttlSeconds: 300, strategy: 'lru' }, ], invalidation: [{ trigger: 'update', scope: 'key' }], consistency: 'write_behind', @@ -303,3 +303,52 @@ describe('DistributedCacheConfigSchema', () => { expect(config.compression).toBe(true); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` tombstones, +// so each refusal carries the RENAME rather than a bare unrecognized-key error. +// Asserted on the issue CODE and the prescription text, never on "it threw": +// a bare `toThrow()` would stay green against a schema that rejected for any +// other reason, which is the failure this pin exists to catch. +describe('cache duration keys carry their unit (#15679)', () => { + it('REFUSES the retired `CacheTier.ttl` with the rename in the message', () => { + const result = CacheTierSchema.safeParse({ name: 'l1', type: 'memory', ttl: 600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'ttl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`CacheTier.ttl` was renamed to `ttlSeconds`'); + }); + + it('accepts `ttlSeconds` at the same magnitude and keeps the 300 default', () => { + expect(CacheTierSchema.parse({ name: 'l1', type: 'memory', ttlSeconds: 600 }).ttlSeconds).toBe(600); + expect(CacheTierSchema.parse({ name: 'l1', type: 'memory' }).ttlSeconds).toBe(300); + }); + + it('REFUSES the retired `circuitBreaker.resetTimeout` with the rename in the message', () => { + const result = CacheAvalanchePreventionSchema.safeParse({ + circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeout: 30 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'circuitBreaker.resetTimeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to', + ); + }); + + it('accepts `resetTimeoutSeconds` and keeps the 30 default', () => { + const parsed = CacheAvalanchePreventionSchema.parse({ + circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutSeconds: 60 }, + }); + expect(parsed.circuitBreaker?.resetTimeoutSeconds).toBe(60); + expect(CacheAvalanchePreventionSchema.parse({ circuitBreaker: { enabled: true } }) + .circuitBreaker?.resetTimeoutSeconds).toBe(30); + }); + + it('leaves `lockout.lockTimeoutMs` alone — it was already correct, and it is a DIFFERENT unit', () => { + const parsed = CacheAvalanchePreventionSchema.parse({ lockout: { enabled: true } }); + expect(parsed.lockout?.lockTimeoutMs).toBe(5000); + }); +}); diff --git a/packages/spec/src/system/cache.zod.ts b/packages/spec/src/system/cache.zod.ts index 5f353d1277..e1f1860752 100644 --- a/packages/spec/src/system/cache.zod.ts +++ b/packages/spec/src/system/cache.zod.ts @@ -30,6 +30,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * @see ../../api/http-cache.zod.ts for HTTP-level caching */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; /** * Cache eviction strategy — the single declaration of the `CacheStrategy` @@ -48,11 +49,31 @@ export const CacheStrategySchema = lazySchema(() => z.enum([ export type CacheStrategy = z.input; +const CACHE_TIER_TTL_RETIRED = + '`CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose, and the ' + + 'sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; ' + + 'the value (seconds) and the 300 default are unchanged.'; + +const CIRCUIT_BREAKER_RESET_TIMEOUT_RETIRED = + '`CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to ' + + '`resetTimeoutSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose, and the sibling `lockout` block ' + + 'on this same schema already spells `lockTimeoutMs`. Rename the key to ' + + '`resetTimeoutSeconds`; the value (seconds) and the 30 default are unchanged. Note the ' + + 'two are NOT the same unit: this one is seconds, `lockTimeoutMs` is milliseconds.'; + export const CacheTierSchema = lazySchema(() => z.object({ name: z.string().describe('Unique cache tier name'), type: z.enum(['memory', 'redis', 'memcached', 'cdn']).describe('Cache backend type'), maxSize: z.number().optional().describe('Max size in MB'), - ttl: z.number().default(300).describe('Default TTL in seconds'), + // Renamed from `ttl` (#15679, #14478 ruling B): the unit lived only in the + // describe prose, while the sibling `maxSize` on this very tier is MB — two + // bare numbers side by side, neither naming its unit at the authoring site. + ttlSeconds: z.number().default(300).describe('Default TTL in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + ttl: retiredKey(CACHE_TIER_TTL_RETIRED), strategy: CacheStrategySchema.default('lru').describe('Eviction strategy'), warmup: z.boolean().default(false).describe('Pre-populate cache on startup'), }).describe('Configuration for a single cache tier in the hierarchy')); @@ -111,7 +132,7 @@ export type CacheConsistency = z.input; * ```typescript * const prevention: CacheAvalanchePrevention = { * jitterTtl: { enabled: true, maxJitterSeconds: 60 }, - * circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeout: 30 }, + * circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutSeconds: 30 }, * lockout: { enabled: true, lockTimeoutMs: 5000 }, * }; * ``` @@ -127,7 +148,13 @@ export const CacheAvalanchePreventionSchema = lazySchema(() => z.object({ circuitBreaker: z.object({ enabled: z.boolean().default(false).describe('Enable circuit breaker for backend protection'), failureThreshold: z.number().default(5).describe('Failures before circuit opens'), - resetTimeout: z.number().default(30).describe('Seconds before half-open state'), + // Renamed from `resetTimeout` (#15679, #14478 ruling B): the unit lived only + // in the describe prose, and the sibling `lockout.lockTimeoutMs` on this same + // schema already spelled its own unit — one shape, two conventions. + resetTimeoutSeconds: z.number().default(30).describe('Seconds before half-open state'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + resetTimeout: retiredKey(CIRCUIT_BREAKER_RESET_TIMEOUT_RETIRED), }).optional().describe('Circuit breaker for backend protection'), /** Cache lock to prevent thundering herd on key miss */ @@ -175,8 +202,8 @@ export type CacheWarmupParsed = z.infer; * const distributedCache: DistributedCacheConfig = { * enabled: true, * tiers: [ - * { name: 'l1', type: 'memory', maxSize: 100, ttl: 60, strategy: 'lru' }, - * { name: 'l2', type: 'redis', maxSize: 1000, ttl: 300, strategy: 'lru' }, + * { name: 'l1', type: 'memory', maxSize: 100, ttlSeconds: 60, strategy: 'lru' }, + * { name: 'l2', type: 'redis', maxSize: 1000, ttlSeconds: 300, strategy: 'lru' }, * ], * invalidation: [ * { trigger: 'update', scope: 'key' }, diff --git a/packages/spec/src/system/collaboration.test.ts b/packages/spec/src/system/collaboration.test.ts index 2bcc25bf0e..dd02ec023d 100644 --- a/packages/spec/src/system/collaboration.test.ts +++ b/packages/spec/src/system/collaboration.test.ts @@ -904,12 +904,12 @@ describe('CollaborationSessionConfigSchema', () => { enablePresence: true, enableAwareness: false, maxUsers: 50, - idleTimeout: 600000, + idleTimeoutMs: 600000, conflictResolution: 'crdt', persistence: true, snapshot: { enabled: true, - interval: 60000, + intervalMs: 60000, }, }; @@ -925,7 +925,7 @@ describe('CollaborationSessionConfigSchema', () => { expect(parsed.enableCursorSharing).toBe(true); expect(parsed.enablePresence).toBe(true); expect(parsed.enableAwareness).toBe(true); - expect(parsed.idleTimeout).toBe(300000); + expect(parsed.idleTimeoutMs).toBe(300000); expect(parsed.conflictResolution).toBe('ot'); expect(parsed.persistence).toBe(true); }); @@ -997,3 +997,45 @@ describe('CollaborationSessionSchema', () => { }); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. `idleTimeout` was the live 1000x +// collision that got this population ruled: milliseconds here, seconds on the +// tenant surface. Both old spellings are `retiredKey()` tombstones; asserted on +// the issue CODE and the prescription, never on a bare `toThrow()`. +describe('collaboration session durations carry their unit (#15679)', () => { + it('REFUSES the retired `idleTimeout` with the rename in the message', () => { + const result = CollaborationSessionConfigSchema.safeParse({ mode: 'ot', idleTimeout: 600000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'idleTimeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs`', + ); + }); + + it('REFUSES the retired `snapshot.interval` with the rename in the message', () => { + const result = CollaborationSessionConfigSchema.safeParse({ + mode: 'ot', + snapshot: { enabled: true, interval: 60000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'snapshot.interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs`', + ); + }); + + it('accepts the renamed keys at the same magnitude and keeps the 300000 default', () => { + const parsed = CollaborationSessionConfigSchema.parse({ + mode: 'crdt', + idleTimeoutMs: 600000, + snapshot: { enabled: true, intervalMs: 60000 }, + }); + expect(parsed.idleTimeoutMs).toBe(600000); + expect(parsed.snapshot?.intervalMs).toBe(60000); + expect(CollaborationSessionConfigSchema.parse({ mode: 'ot' }).idleTimeoutMs).toBe(300000); + }); +}); diff --git a/packages/spec/src/system/collaboration.zod.ts b/packages/spec/src/system/collaboration.zod.ts index 9be9905b4b..1feb4309a1 100644 --- a/packages/spec/src/system/collaboration.zod.ts +++ b/packages/spec/src/system/collaboration.zod.ts @@ -21,6 +21,7 @@ import { z } from 'zod'; * Types of operations in Operational Transformation */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const OTOperationType = z.enum([ 'insert', // Insert characters at position 'delete', // Delete characters at position @@ -460,18 +461,44 @@ export type CollaborationMode = z.input; * Collaboration Session Config * Configuration for a collaboration session */ +const SESSION_IDLE_TIMEOUT_RETIRED = + '`CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. This one was the live 1000x collision the rule exists to ' + + 'remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare ' + + 'name meant five minutes here and three and a half days there. Rename the key to ' + + '`idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged.'; + +const SNAPSHOT_INTERVAL_RETIRED = + '`CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `intervalMs`; the value (milliseconds) ' + + 'is unchanged.'; + export const CollaborationSessionConfigSchema = lazySchema(() => z.object({ mode: CollaborationMode.describe('Collaboration mode to use'), enableCursorSharing: z.boolean().optional().default(true).describe('Enable cursor sharing'), enablePresence: z.boolean().optional().default(true).describe('Enable presence tracking'), enableAwareness: z.boolean().optional().default(true).describe('Enable awareness state'), maxUsers: z.number().int().positive().optional().describe('Maximum concurrent users'), - idleTimeout: z.number().int().positive().optional().default(300000).describe('Idle timeout in milliseconds'), + // Renamed from `idleTimeout` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, and a SECONDS-valued `idleTimeout` existed on the tenant + // surface at the same time — 300000 meant five minutes here and three and a half + // days there, with nothing at either authoring site to tell them apart. + idleTimeoutMs: z.number().int().positive().optional().default(300000).describe('Idle timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + idleTimeout: retiredKey(SESSION_IDLE_TIMEOUT_RETIRED), conflictResolution: z.enum(['ot', 'crdt', 'manual']).optional().default('ot').describe('Conflict resolution strategy'), persistence: z.boolean().optional().default(true).describe('Enable operation persistence'), snapshot: z.object({ enabled: z.boolean().describe('Enable periodic snapshots'), - interval: z.number().int().positive().describe('Snapshot interval in milliseconds'), + // Renamed from `interval` (#15679, #14478 ruling B): the unit lived only in + // the describe prose. + intervalMs: z.number().int().positive().describe('Snapshot interval in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + interval: retiredKey(SNAPSHOT_INTERVAL_RETIRED), }).optional().describe('Snapshot configuration'), })); diff --git a/packages/spec/src/system/disaster-recovery.test.ts b/packages/spec/src/system/disaster-recovery.test.ts index f644b5a606..f099fd2c69 100644 --- a/packages/spec/src/system/disaster-recovery.test.ts +++ b/packages/spec/src/system/disaster-recovery.test.ts @@ -174,7 +174,7 @@ describe('DisasterRecoveryPlanSchema', () => { failover: { mode: 'active_passive', autoFailover: true, - healthCheckInterval: 30, + healthCheckIntervalSeconds: 30, failureThreshold: 3, regions: [ { name: 'us-east-1', role: 'primary', endpoint: 'https://primary.example.com' }, @@ -226,3 +226,41 @@ describe('DisasterRecoveryPlanSchema', () => { // Need z import for z.input type usage in tests import { z } from 'zod'; + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The second case is the load-bearing half: the +// neighbouring `dns.ttl` is a DECLARED `externalVocabulary` mirror and must +// survive this rename untouched, and no gate can catch its loss (the marker +// exempts the key either way), so the pin is the only guard. +describe('FailoverConfig.healthCheckInterval carries its unit (#15679)', () => { + const regions = [ + { name: 'us-east-1', role: 'primary' as const }, + { name: 'eu-west-1', role: 'secondary' as const }, + ]; + + it('REFUSES the retired `healthCheckInterval` with the rename in the message', () => { + const result = FailoverConfigSchema.safeParse({ regions, healthCheckInterval: 30 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'healthCheckInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds`', + ); + }); + + it('accepts `healthCheckIntervalSeconds` and keeps the 30 default', () => { + expect(FailoverConfigSchema.parse({ regions, healthCheckIntervalSeconds: 15 }) + .healthCheckIntervalSeconds).toBe(15); + expect(FailoverConfigSchema.parse({ regions }).healthCheckIntervalSeconds).toBe(30); + }); + + it('leaves the sibling `dns.ttl` bare — it is a declared externalVocabulary mirror', () => { + const parsed = FailoverConfigSchema.parse({ + regions, + dns: { ttl: 60, provider: 'route53' }, + }); + expect(parsed.dns?.ttl).toBe(60); + }); +}); diff --git a/packages/spec/src/system/disaster-recovery.zod.ts b/packages/spec/src/system/disaster-recovery.zod.ts index 0732edaea1..fef0d0530b 100644 --- a/packages/spec/src/system/disaster-recovery.zod.ts +++ b/packages/spec/src/system/disaster-recovery.zod.ts @@ -23,6 +23,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * ``` */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const BackupStrategySchema = lazySchema(() => z.enum([ 'full', 'incremental', @@ -112,7 +113,19 @@ export const FailoverConfigSchema = lazySchema(() => z.object({ /** Automatic failover enabled */ autoFailover: z.boolean().default(true).describe('Enable automatic failover'), /** Health check interval in seconds */ - healthCheckInterval: z.number().default(30).describe('Health check interval in seconds'), + // Renamed from `healthCheckInterval` (#15679, #14478 ruling B): the unit lived + // only in the describe prose. Its neighbour `dns.ttl` on this same schema keeps + // its bare name under the externalVocabulary exemption — that key mirrors the + // DNS resource-record field, this one mirrors nothing outside the repo. + healthCheckIntervalSeconds: z.number().default(30).describe('Health check interval in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + healthCheckInterval: retiredKey( + '`FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, ' + + 'not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the ' + + 'value (seconds) and the 30 default are unchanged.', + ), /** Number of consecutive failures before triggering failover */ failureThreshold: z.number().default(3).describe('Consecutive failures before failover'), /** Regions/zones for disaster recovery */ @@ -195,7 +208,7 @@ export type RTOParsed = z.infer; * failover: { * mode: 'active_passive', * autoFailover: true, - * healthCheckInterval: 30, + * healthCheckIntervalSeconds: 30, * failureThreshold: 3, * regions: [ * { name: 'us-east-1', role: 'primary' }, diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index e4098dc878..3e12157f4f 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -239,7 +239,7 @@ describe('MetricAggregationConfigSchema', () => { const config = MetricAggregationConfigSchema.parse({ type: 'avg', window: { - size: 300, + durationSeconds: 300, sliding: true, slideInterval: 60, }, @@ -247,7 +247,7 @@ describe('MetricAggregationConfigSchema', () => { }); expect(config.type).toBe('avg'); - expect(config.window?.size).toBe(300); + expect(config.window?.durationSeconds).toBe(300); }); }); @@ -263,7 +263,7 @@ describe('ServiceLevelIndicatorSchema', () => { operator: 'gte', }, window: { - size: 2592000, // 30 days + durationSeconds: 2592000, // 30 days }, }; @@ -282,7 +282,7 @@ describe('ServiceLevelIndicatorSchema', () => { percentile: 0.99, }, window: { - size: 86400, // 1 day + durationSeconds: 86400, // 1 day rolling: true, }, }; @@ -301,7 +301,7 @@ describe('ServiceLevelIndicatorSchema', () => { operator: 'gte', }, window: { - size: 3600, + durationSeconds: 3600, }, }); @@ -318,7 +318,7 @@ describe('ServiceLevelObjectiveSchema', () => { target: 99.9, period: { type: 'rolling', - duration: 2592000, // 30 days + durationSeconds: 2592000, // 30 days }, }; @@ -346,7 +346,7 @@ describe('ServiceLevelObjectiveSchema', () => { label: 'Error Budget SLO', sli: 'test_sli', target: 99.9, - period: { type: 'rolling', duration: 2592000 }, + period: { type: 'rolling', durationSeconds: 2592000 }, errorBudget: { enabled: true, alertThreshold: 75, @@ -366,7 +366,7 @@ describe('ServiceLevelObjectiveSchema', () => { label: 'Test SLO', sli: 'test_sli', target: 99, - period: { type: 'rolling', duration: 86400 }, + period: { type: 'rolling', durationSeconds: 86400 }, }); expect(slo.enabled).toBe(true); @@ -449,7 +449,7 @@ describe('MetricsConfigSchema', () => { threshold: 99.9, operator: 'gte', }, - window: { size: 2592000 }, + window: { durationSeconds: 2592000 }, }, ], slos: [ @@ -458,7 +458,7 @@ describe('MetricsConfigSchema', () => { label: 'API SLO', sli: 'api_availability', target: 99.9, - period: { type: 'rolling', duration: 2592000 }, + period: { type: 'rolling', durationSeconds: 2592000 }, }, ], exports: [ @@ -490,3 +490,84 @@ describe('MetricsConfigSchema', () => { })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. All three old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The new name is `durationSeconds` and NOT the +// gate's mechanical `sizeSeconds`: `size` is byte/row-count vocabulary elsewhere +// in this spec, and the parent key is already `window`, so `windowSeconds` would +// read `window.windowSeconds`. +describe('metrics window and period lengths carry their unit (#15679)', () => { + const sliBase = { + name: 'api_availability', + label: 'API Availability', + metric: 'http_requests_total', + type: 'availability' as const, + successCriteria: { threshold: 99.9, operator: 'gte' as const }, + }; + const sloBase = { + name: 'api_uptime_slo', label: 'API Uptime SLO', sli: 'api_availability', target: 99.9, + }; + + it('REFUSES the retired `MetricAggregationConfig.window.size`', () => { + const result = MetricAggregationConfigSchema.safeParse({ + type: 'avg', + window: { size: 300 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'window.size'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('renamed to `durationSeconds`'); + // The prescription must EXPLAIN the departure from the mechanical name, or the + // next author reads `durationSeconds` as a slip and "corrects" it back. + expect(issue!.message).toContain('The new name is not `sizeSeconds`'); + }); + + it('REFUSES the retired `ServiceLevelIndicator.window.size`', () => { + const result = ServiceLevelIndicatorSchema.safeParse({ ...sliBase, window: { size: 2592000 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'window.size'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('renamed to `durationSeconds`'); + }); + + it('REFUSES the retired `ServiceLevelObjective.period.duration`', () => { + const result = ServiceLevelObjectiveSchema.safeParse({ + ...sloBase, + period: { type: 'rolling', duration: 2592000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'period.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`ServiceLevelObjective.period.duration` was renamed to `durationSeconds`', + ); + }); + + it('accepts every renamed key at the same magnitude', () => { + expect(MetricAggregationConfigSchema.parse({ type: 'avg', window: { durationSeconds: 300 } }) + .window?.durationSeconds).toBe(300); + expect(ServiceLevelIndicatorSchema.parse({ ...sliBase, window: { durationSeconds: 2592000 } }) + .window.durationSeconds).toBe(2592000); + expect(ServiceLevelObjectiveSchema.parse({ + ...sloBase, period: { type: 'rolling', durationSeconds: 2592000 }, + }).period.durationSeconds).toBe(2592000); + }); + + it('leaves the two non-duration keys on this file alone', () => { + // The exporter batch `size` is a COUNT of records, not a duration. + expect(MetricExportConfigSchema.parse({ type: 'prometheus', batch: { size: 500 } }) + .batch?.size).toBe(500); + // The error-budget burn-rate `window` names no unit in its describe, so it is + // outside the gate population entirely and keeps its bare name. + const slo = ServiceLevelObjectiveSchema.parse({ + ...sloBase, + period: { type: 'rolling', durationSeconds: 2592000 }, + errorBudget: { burnRateWindows: [{ window: 3600, threshold: 14.4 }] }, + }); + expect(slo.errorBudget?.burnRateWindows?.[0]?.window).toBe(3600); + }); +}); diff --git a/packages/spec/src/system/metrics.zod.ts b/packages/spec/src/system/metrics.zod.ts index 93c1c73c7d..6e941789eb 100644 --- a/packages/spec/src/system/metrics.zod.ts +++ b/packages/spec/src/system/metrics.zod.ts @@ -19,6 +19,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Standard Prometheus metric types */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MetricType = z.enum([ 'counter', // Monotonically increasing value 'gauge', // Value that can go up and down @@ -319,6 +320,20 @@ export type TimeSeries = z.input; /** * Metric Aggregation Configuration */ +const WINDOW_SIZE_RETIRED = + 'The aggregation/SLI window key `size` was renamed to `durationSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or ' + + 'row count everywhere else in this spec, so the rename drops it rather than bolting a ' + + 'unit onto it. Rename `window.size` to `window.durationSeconds` on both ' + + 'MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged.'; + +const SLO_PERIOD_DURATION_RETIRED = + '`ServiceLevelObjective.period.duration` was renamed to `durationSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) ' + + 'is unchanged.'; + export const MetricAggregationConfigSchema = lazySchema(() => z.object({ /** * Aggregation type @@ -330,9 +345,20 @@ export const MetricAggregationConfigSchema = lazySchema(() => z.object({ */ window: z.object({ /** - * Window size in seconds + * Window duration in seconds */ - size: z.number().int().positive().describe('Window size in seconds'), + // Renamed from `size` (#15679, #14478 ruling B). NOT the gate's mechanical + // `sizeSeconds`: `size` is byte-count vocabulary everywhere else in this spec + // (`CacheTier.maxSize` is MB, `RegistryConfig.cache.maxSize` is bytes, this + // file's own `batch.size` is a row count), so `sizeSeconds` would have kept + // the misleading half of the name and bolted a unit onto it. The parent key is + // already `window`, so `windowSeconds` would stutter as `window.windowSeconds`. + // `durationSeconds` names what the number IS, and matches the sibling period + // length one schema down (`ServiceLevelObjective.period.durationSeconds`). + durationSeconds: z.number().int().positive().describe('Window duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + size: retiredKey(WINDOW_SIZE_RETIRED), /** * Sliding window (true) or tumbling window (false) @@ -415,9 +441,20 @@ export const ServiceLevelIndicatorSchema = lazySchema(() => z.object({ */ window: z.object({ /** - * Window size in seconds + * Window duration in seconds */ - size: z.number().int().positive().describe('Window size in seconds'), + // Renamed from `size` (#15679, #14478 ruling B). NOT the gate's mechanical + // `sizeSeconds`: `size` is byte-count vocabulary everywhere else in this spec + // (`CacheTier.maxSize` is MB, `RegistryConfig.cache.maxSize` is bytes, this + // file's own `batch.size` is a row count), so `sizeSeconds` would have kept + // the misleading half of the name and bolted a unit onto it. The parent key is + // already `window`, so `windowSeconds` would stutter as `window.windowSeconds`. + // `durationSeconds` names what the number IS, and matches the sibling period + // length one schema down (`ServiceLevelObjective.period.durationSeconds`). + durationSeconds: z.number().int().positive().describe('Window duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + size: retiredKey(WINDOW_SIZE_RETIRED), /** * Rolling window (true) or calendar-aligned (false) @@ -478,7 +515,12 @@ export const ServiceLevelObjectiveSchema = lazySchema(() => z.object({ /** * Duration in seconds (for rolling) */ - duration: z.number().int().positive().optional().describe('Duration in seconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in + // the describe prose. + durationSeconds: z.number().int().positive().optional().describe('Duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey(SLO_PERIOD_DURATION_RETIRED), /** * Calendar period (for calendar) diff --git a/packages/spec/src/system/object-storage.test.ts b/packages/spec/src/system/object-storage.test.ts index 3d3216e9d4..8f3d016ee6 100644 --- a/packages/spec/src/system/object-storage.test.ts +++ b/packages/spec/src/system/object-storage.test.ts @@ -295,12 +295,12 @@ describe('AccessControlConfigSchema', () => { allowedMethods: ['GET', 'PUT', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'], exposeHeaders: ['ETag', 'Content-Length'], - maxAge: 3600, + maxAgeSeconds: 3600, }); expect(config.corsEnabled).toBe(true); expect(config.allowedOrigins).toHaveLength(2); - expect(config.maxAge).toBe(3600); + expect(config.maxAgeSeconds).toBe(3600); }); it('should accept public access configuration', () => { @@ -629,11 +629,11 @@ describe('StorageConnectionSchema', () => { const connection = StorageConnectionSchema.parse({ endpoint: 'https://custom.storage.example.com', useSSL: true, - timeout: 30000, + timeoutMs: 30000, }); expect(connection.useSSL).toBe(true); - expect(connection.timeout).toBe(30000); + expect(connection.timeoutMs).toBe(30000); }); it('should default useSSL to true', () => { @@ -848,3 +848,52 @@ describe('ObjectStorageConfigSchema', () => { expect(storage.enabled).toBe(false); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. +// +// ⚠️ `AccessControlConfig.maxAge` is a RENAME and its twin +// `shared/CorsConfig.maxAge` is a DECLARED `externalVocabulary` mirror that keeps +// its bare name. The asymmetry is deliberate: every bucket-CORS standard spells +// the field with its unit (S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, Azure +// `MaxAgeInSeconds`), while the Fetch header `Access-Control-Max-Age` that the +// twin mirrors carries no unit token. No gate can catch the twin being renamed +// along with this one — the marker exempts it either way — so a find-and-replace +// that harmonised the two would land silently. The pin below is the only guard. +describe('object-storage durations carry their unit (#15679)', () => { + it('REFUSES the retired `AccessControlConfig.maxAge` with the rename in the message', () => { + const result = AccessControlConfigSchema.safeParse({ corsEnabled: true, maxAge: 3600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'maxAge'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`AccessControlConfig.maxAge` was renamed to `maxAgeSeconds`'); + }); + + it('REFUSES the retired `StorageConnection.timeout` with the rename in the message', () => { + const result = StorageConnectionSchema.safeParse({ endpoint: 'https://s.example.com', timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`StorageConnection.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts both renamed keys at the same magnitude', () => { + expect(AccessControlConfigSchema.parse({ corsEnabled: true, maxAgeSeconds: 3600 }).maxAgeSeconds).toBe(3600); + expect(StorageConnectionSchema.parse({ endpoint: 'https://s.example.com', timeoutMs: 30000 }).timeoutMs).toBe(30000); + }); + + it('leaves `FileMetadata.size` alone — it is bytes, not a duration', () => { + const parsed = FileMetadataSchema.parse({ + path: '/uploads/file.txt', + name: 'file.txt', + size: 1024, + mimeType: 'text/plain', + lastModified: '2024-01-15T10:30:00.000Z', + created: '2024-01-15T10:00:00.000Z', + }); + expect(parsed.size).toBe(1024); + }); +}); diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index d35b46744d..215e459d28 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -26,6 +26,7 @@ import { SystemIdentifierSchema } from '../shared/identifiers.zod'; * Defines the lifecycle and persistence guarantee of the storage area. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const StorageScopeSchema = lazySchema(() => z.enum([ 'global', // Global application-wide storage 'tenant', // Tenant-scoped storage (multi-tenant apps) @@ -252,13 +253,40 @@ export type MultipartUploadConfigParsed = z.infer z.object({ acl: StorageAclSchema.default('private').describe('Default access control level'), allowedOrigins: z.array(z.string()).optional().describe('CORS allowed origins'), allowedMethods: z.array(z.enum(['GET', 'PUT', 'POST', 'DELETE', 'HEAD'])).optional().describe('CORS allowed HTTP methods'), allowedHeaders: z.array(z.string()).optional().describe('CORS allowed headers'), exposeHeaders: z.array(z.string()).optional().describe('CORS exposed headers'), - maxAge: z.number().min(0).optional().describe('CORS preflight cache duration in seconds'), + // Renamed from `maxAge` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. ⚠️ Deliberately NOT an `externalVocabulary` mirror, unlike its + // twin `shared/CorsConfig.maxAge`: every bucket-CORS standard this key is + // forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, GCS + // `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so the bare spelling was a + // DEVIATION from the cited standard, not a mirror of it. The Fetch response + // header `Access-Control-Max-Age` that `CorsConfig.maxAge` mirrors genuinely + // carries no unit token, which is why that twin keeps its marker and this one + // is renamed. Preserve the asymmetry. + maxAgeSeconds: z.number().min(0).optional().describe('CORS preflight cache duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + maxAge: retiredKey(ACCESS_CONTROL_MAX_AGE_RETIRED), corsEnabled: z.boolean().default(false).describe('Enable CORS configuration'), publicAccess: z.object({ allowPublicRead: z.boolean().default(false).describe('Allow public read access'), @@ -447,7 +475,12 @@ export const StorageConnectionSchema = lazySchema(() => z.object({ endpoint: z.string().optional().describe('Custom endpoint URL'), region: z.string().optional().describe('Default region'), useSSL: z.boolean().default(true).describe('Use SSL/TLS for connections'), - timeout: z.number().min(0).optional().describe('Connection timeout in milliseconds'), + // Renamed from `timeout` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. + timeoutMs: z.number().min(0).optional().describe('Connection timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + timeout: retiredKey(STORAGE_CONNECTION_TIMEOUT_RETIRED), })); export type StorageConnection = z.input; diff --git a/packages/spec/src/system/registry-config.test.ts b/packages/spec/src/system/registry-config.test.ts index e5b00e73e8..0b0ecdf287 100644 --- a/packages/spec/src/system/registry-config.test.ts +++ b/packages/spec/src/system/registry-config.test.ts @@ -27,14 +27,14 @@ describe('RegistryUpstreamSchema', () => { expect(upstream.url).toBe('https://registry.objectstack.com'); expect(upstream.syncPolicy).toBe('auto'); - expect(upstream.timeout).toBe(30000); + expect(upstream.timeoutMs).toBe(30000); }); it('should accept full upstream configuration', () => { const upstream = RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', syncPolicy: 'manual', - syncInterval: 300, + syncIntervalSeconds: 300, auth: { type: 'bearer', token: 'my-token', @@ -43,7 +43,7 @@ describe('RegistryUpstreamSchema', () => { enabled: true, verifyCertificate: false, }, - timeout: 60000, + timeoutMs: 60000, retry: { maxAttempts: 5, backoff: 'linear', @@ -51,10 +51,10 @@ describe('RegistryUpstreamSchema', () => { }); expect(upstream.syncPolicy).toBe('manual'); - expect(upstream.syncInterval).toBe(300); + expect(upstream.syncIntervalSeconds).toBe(300); expect(upstream.auth?.type).toBe('bearer'); expect(upstream.tls?.verifyCertificate).toBe(false); - expect(upstream.timeout).toBe(60000); + expect(upstream.timeoutMs).toBe(60000); expect(upstream.retry?.maxAttempts).toBe(5); }); @@ -86,20 +86,20 @@ describe('RegistryUpstreamSchema', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'not-a-url' })).toThrow(); }); - it('should reject syncInterval below minimum', () => { + it('should reject syncIntervalSeconds below minimum', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', - syncInterval: 10, + syncIntervalSeconds: 10, }), ).toThrow(); }); - it('should reject timeout below minimum', () => { + it('should reject timeoutMs below minimum', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', - timeout: 500, + timeoutMs: 500, }), ).toThrow(); }); @@ -147,7 +147,7 @@ describe('RegistryConfigSchema', () => { }, cache: { enabled: true, - ttl: 7200, + ttlSeconds: 7200, maxSize: 1073741824, }, mirrors: [ @@ -161,7 +161,7 @@ describe('RegistryConfigSchema', () => { expect(config.storage?.backend).toBe('s3'); expect(config.visibility).toBe('internal'); expect(config.accessControl?.requireAuthForRead).toBe(true); - expect(config.cache?.ttl).toBe(7200); + expect(config.cache?.ttlSeconds).toBe(7200); expect(config.mirrors).toHaveLength(2); }); @@ -204,3 +204,51 @@ describe('RegistryConfigSchema', () => { expect(() => RegistryConfigSchema.parse({ type: 'distributed' })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. All three old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. `RegistryUpstream` is the sharpest case in this +// card: it declared a SECONDS interval and a MILLISECONDS timeout twenty-five +// lines apart, both bare, and the `min(1000)` bound on the timeout reads as one +// second under the right unit and sixteen minutes under the wrong one — both in +// range, so no parse could have caught the mistake. +describe('registry duration keys carry their unit (#15679)', () => { + const url = 'https://registry.example.com'; + + it('REFUSES the retired `syncInterval` with the rename in the message', () => { + const result = RegistryUpstreamSchema.safeParse({ url, syncInterval: 300 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'syncInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds`'); + }); + + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = RegistryUpstreamSchema.safeParse({ url, timeout: 60000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryUpstream.timeout` was renamed to `timeoutMs`'); + }); + + it('REFUSES the retired `cache.ttl` with the rename in the message', () => { + const result = RegistryConfigSchema.safeParse({ type: 'private', cache: { enabled: true, ttl: 7200 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cache.ttl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryConfig.cache.ttl` was renamed to `ttlSeconds`'); + }); + + it('accepts the renamed keys and keeps every default and bound', () => { + const upstream = RegistryUpstreamSchema.parse({ url, syncIntervalSeconds: 300, timeoutMs: 60000 }); + expect(upstream.syncIntervalSeconds).toBe(300); + expect(upstream.timeoutMs).toBe(60000); + expect(RegistryUpstreamSchema.parse({ url }).timeoutMs).toBe(30000); + expect(RegistryUpstreamSchema.safeParse({ url, syncIntervalSeconds: 10 }).success).toBe(false); + expect(RegistryUpstreamSchema.safeParse({ url, timeoutMs: 500 }).success).toBe(false); + expect(RegistryConfigSchema.parse({ type: 'private', cache: { enabled: true } }).cache?.ttlSeconds).toBe(3600); + }); +}); diff --git a/packages/spec/src/system/registry-config.zod.ts b/packages/spec/src/system/registry-config.zod.ts index 67dbf8a7f3..9c6bc71f03 100644 --- a/packages/spec/src/system/registry-config.zod.ts +++ b/packages/spec/src/system/registry-config.zod.ts @@ -14,6 +14,7 @@ import { z } from 'zod'; * Defines how registries synchronize with upstreams */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const RegistrySyncPolicySchema = lazySchema(() => z.enum([ 'manual', // Manual synchronization only 'auto', // Automatic synchronization @@ -24,6 +25,26 @@ export const RegistrySyncPolicySchema = lazySchema(() => z.enum([ * Registry Upstream Configuration * Configuration for upstream registry connection */ +const UPSTREAM_SYNC_INTERVAL_RETIRED = + '`RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose, and the `timeout` beside it on this same block is ' + + 'milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the ' + + 'min-60 bound are unchanged.'; + +const UPSTREAM_TIMEOUT_RETIRED = + '`RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the ' + + 'unit of a duration-shaped number lives in the key name, not only in the describe prose, ' + + 'and `syncIntervalSeconds` on this same block is seconds. Rename the key to ' + + '`timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are ' + + 'unchanged.'; + +const REGISTRY_CACHE_TTL_RETIRED = + '`RegistryConfig.cache.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the ' + + 'unit of a duration-shaped number lives in the key name, not only in the describe prose, ' + + 'and the sibling `maxSize` in this same cache block is bytes. Rename the key to ' + + '`ttlSeconds`; the value (seconds) and the 3600 default are unchanged.'; + export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Upstream registry URL @@ -39,8 +60,14 @@ export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Sync interval in seconds (for auto sync) */ - syncInterval: z.number().int().min(60).optional() + // Renamed from `syncInterval` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, and the `timeout` two keys down was MILLISECONDS — one + // upstream block, two units, neither spelled at the authoring site. + syncIntervalSeconds: z.number().int().min(60).optional() .describe('Auto-sync interval in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + syncInterval: retiredKey(UPSTREAM_SYNC_INTERVAL_RETIRED), /** * Authentication credentials @@ -66,8 +93,14 @@ export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Timeout settings */ - timeout: z.number().int().min(1000).default(30000) + // Renamed from `timeout` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. Milliseconds here, while `syncIntervalSeconds` above is + // seconds — the min(1000) bound reads as sixteen minutes under the wrong one. + timeoutMs: z.number().int().min(1000).default(30000) .describe('Request timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + timeout: retiredKey(UPSTREAM_TIMEOUT_RETIRED), /** * Retry configuration @@ -161,8 +194,13 @@ export const RegistryConfigSchema = lazySchema(() => z.object({ */ cache: z.object({ enabled: z.boolean().default(true), - ttl: z.number().int().min(0).default(3600) + // Renamed from `ttl` (#15679, #14478 ruling B): the unit lived only in the + // describe prose, and the sibling `maxSize` in this same cache block is BYTES. + ttlSeconds: z.number().int().min(0).default(3600) .describe('Cache TTL in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + ttl: retiredKey(REGISTRY_CACHE_TTL_RETIRED), maxSize: z.number().int().optional() .describe('Maximum cache size in bytes'), }).optional(), diff --git a/packages/spec/src/system/tracing.test.ts b/packages/spec/src/system/tracing.test.ts index 91edc71295..0b1c814c78 100644 --- a/packages/spec/src/system/tracing.test.ts +++ b/packages/spec/src/system/tracing.test.ts @@ -183,7 +183,7 @@ describe('SpanSchema', () => { kind: 'server', startTime: '2024-01-15T10:30:00.000Z', endTime: '2024-01-15T10:30:00.150Z', - duration: 150, + durationMs: 150, status: { code: 'ok', }, @@ -515,3 +515,38 @@ describe('TracingConfigSchema', () => { })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. A span is runtime-emitted, so the silent-strip +// alternative is the real hazard here: an exporter still writing `duration` +// would have lost the measurement without any error at all. +describe('Span.duration carries its unit (#15679)', () => { + const base = { + context: { traceId: '0123456789abcdef0123456789abcdef', spanId: '0123456789abcdef' }, + name: 'GET /api/users', + startTime: '2024-01-15T10:30:00.000Z', + }; + + it('REFUSES the retired `duration` with the rename in the message', () => { + const result = SpanSchema.safeParse({ ...base, duration: 150 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`Span.duration` was renamed to `durationMs`'); + }); + + it('accepts `durationMs` at the same magnitude and still refuses a negative one', () => { + expect(SpanSchema.parse({ ...base, durationMs: 150 }).durationMs).toBe(150); + expect(SpanSchema.safeParse({ ...base, durationMs: -1 }).success).toBe(false); + }); + + it('leaves the OTel exporter `timeout` alone — its describe names no unit, so it is outside the population', () => { + const config = OpenTelemetryCompatibilitySchema.parse({ + exporter: { type: 'console' }, + resource: { serviceName: 'test' }, + }); + expect(config.exporter.timeout).toBe(10000); + }); +}); diff --git a/packages/spec/src/system/tracing.zod.ts b/packages/spec/src/system/tracing.zod.ts index 1104dff411..976315c6c7 100644 --- a/packages/spec/src/system/tracing.zod.ts +++ b/packages/spec/src/system/tracing.zod.ts @@ -19,6 +19,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * W3C Trace Context tracestate header */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const TraceStateSchema = lazySchema(() => z.object({ /** * Vendor-specific key-value pairs @@ -211,7 +212,19 @@ export const SpanSchema = lazySchema(() => z.object({ /** * Duration in milliseconds */ - duration: z.number().nonnegative().optional().describe('Duration in milliseconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. OpenTelemetry, which this shape mirrors, carries the span + // length as a start/end nanosecond pair rather than a key named `duration`, so + // there is no external spelling to mirror here — this is a rename, not an + // `externalVocabulary` marker. + durationMs: z.number().nonnegative().optional().describe('Duration in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey( + '`Span.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename ' + + 'the key to `durationMs`; the value (milliseconds) is unchanged.', + ), /** * Span status diff --git a/packages/spec/src/system/worker.test.ts b/packages/spec/src/system/worker.test.ts index 3f2513fd59..dff2a12551 100644 --- a/packages/spec/src/system/worker.test.ts +++ b/packages/spec/src/system/worker.test.ts @@ -266,13 +266,13 @@ describe('QueueConfigSchema', () => { concurrency: 10, rateLimit: { max: 100, - duration: 60000, + durationMs: 60000, }, }; const parsed = QueueConfigSchema.parse(config); expect(parsed.rateLimit?.max).toBe(100); - expect(parsed.rateLimit?.duration).toBe(60000); + expect(parsed.rateLimit?.durationMs).toBe(60000); }); it('should accept queue with auto-scaling', () => { @@ -550,3 +550,33 @@ describe('Worker Integration', () => { expect(sorted[4].priority).toBe('background'); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The counter-example was already in this file: +// `TaskExecutionResult.durationMs` spelled the identical measurement correctly +// ninety lines up, which is what made the bare `rateLimit.duration` a drift +// rather than a convention. A silent strip here would have left a queue +// unthrottled with no error. +describe('QueueConfig.rateLimit.duration carries its unit (#15679)', () => { + it('REFUSES the retired `rateLimit.duration` with the rename in the message', () => { + const result = QueueConfigSchema.safeParse({ + name: 'rate_limited_queue', + rateLimit: { max: 100, duration: 60000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'rateLimit.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`QueueConfig.rateLimit.duration` was renamed to `durationMs`'); + }); + + it('accepts `rateLimit.durationMs` at the same magnitude; `max` stays a bare COUNT', () => { + const parsed = QueueConfigSchema.parse({ + name: 'rate_limited_queue', + rateLimit: { max: 100, durationMs: 60000 }, + }); + expect(parsed.rateLimit?.durationMs).toBe(60000); + expect(parsed.rateLimit?.max).toBe(100); + }); +}); diff --git a/packages/spec/src/system/worker.zod.ts b/packages/spec/src/system/worker.zod.ts index ab9fe743b3..785ff81cff 100644 --- a/packages/spec/src/system/worker.zod.ts +++ b/packages/spec/src/system/worker.zod.ts @@ -40,6 +40,7 @@ import { z } from 'zod'; * Lower numbers = higher priority */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const TaskPriority = z.enum([ 'critical', // 0 - Must execute immediately 'high', // 1 - Execute soon @@ -254,7 +255,7 @@ export type TaskExecutionResult = z.input; * "concurrency": 10, * "rateLimit": { * "max": 100, - * "duration": 60000 + * "durationMs": 60000 * } * } */ @@ -274,7 +275,18 @@ export const QueueConfigSchema = lazySchema(() => z.object({ */ rateLimit: z.object({ max: z.number().int().positive().describe('Maximum tasks per duration'), - duration: z.number().int().positive().describe('Duration in milliseconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, while `TaskResult.durationMs` earlier in this same file + // already spelled the identical measurement correctly. + durationMs: z.number().int().positive().describe('Duration in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey( + '`QueueConfig.rateLimit.duration` was renamed to `durationMs` in @objectstack/spec ' + + '17 — the unit of a duration-shaped number lives in the key name, not only in the ' + + 'describe prose, and `TaskResult.durationMs` on this same file already spelled it ' + + 'that way. Rename the key to `durationMs`; the value (milliseconds) is unchanged.', + ), }).optional().describe('Rate limit configuration'), /** diff --git a/packages/spec/src/ui/dashboard.form.ts b/packages/spec/src/ui/dashboard.form.ts index be4041f89e..e116b29a98 100644 --- a/packages/spec/src/ui/dashboard.form.ts +++ b/packages/spec/src/ui/dashboard.form.ts @@ -23,7 +23,7 @@ export const dashboardForm = defineForm({ fields: [ { field: 'columns', type: 'number', colSpan: 1, helpText: 'Grid columns (default 12)' }, { field: 'gap', type: 'number', colSpan: 1, helpText: 'Space between widgets, in steps of 0.25rem (4 = 1rem)' }, - { field: 'refreshInterval', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, + { field: 'refreshIntervalSeconds', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, { field: 'header', type: 'composite', colSpan: 3, helpText: 'Dashboard header config (title, subtitle, actions)' }, ], }, diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index bea614f18f..c67e50f14e 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -292,7 +292,7 @@ describe('DashboardSchema', () => { it('supports columns/gap/refresh/dateRange/globalFilters', () => { const d = DashboardSchema.parse({ - name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshInterval: 60, + name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshIntervalSeconds: 60, dateRange: { field: 'close_date', defaultRange: 'this_quarter' }, globalFilters: [{ field: 'owner', type: 'lookup' }], widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }], @@ -695,3 +695,41 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(c.aria).toEqual({ ariaLabel: 'Sidebar' }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — `DashboardSchema` IS `strictObject`, so a bare +// throw assertion passes identically on the unrecognized-key error, which is +// exactly the error that cannot carry the rename. +// +// The alias half is the part with no other guard: `refresh` / `autoRefresh` / +// `pollInterval` were rename hints pointing at the OLD spelling, and a hint +// left pointing at a tombstone would prescribe a key the shape refuses. +describe('dashboard.refreshInterval carries its unit (#15680)', () => { + const base = { name: 'dash_x', label: 'D', widgets: [] }; + + it('REFUSES the retired `refreshInterval` with the rename in the message', () => { + const result = DashboardSchema.safeParse({ ...base, refreshInterval: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'refreshInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`'); + }); + + it('accepts `refreshIntervalSeconds` at the same magnitude', () => { + expect(DashboardSchema.parse({ ...base, refreshIntervalSeconds: 60 }).refreshIntervalSeconds).toBe(60); + }); + + it('prescribes the NEW spelling from all three rename-hint aliases', () => { + for (const alias of ['refresh', 'autoRefresh', 'pollInterval']) { + const result = DashboardSchema.safeParse({ ...base, [alias]: 60 }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('refreshIntervalSeconds'); + // A hint still naming the tombstone would send the author to a key the + // shape refuses — the one failure this rename could introduce silently. + expect(message).not.toMatch(/`refreshInterval`(?!Seconds)/); + } + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 758a16a238..81ad8eadce 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -871,7 +871,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ filters: 'globalFilters', globalFilter: 'globalFilters', grid: 'columns', columnCount: 'columns', spacing: 'gap', - refresh: 'refreshInterval', autoRefresh: 'refreshInterval', pollInterval: 'refreshInterval', + refresh: 'refreshIntervalSeconds', autoRefresh: 'refreshIntervalSeconds', pollInterval: 'refreshIntervalSeconds', dateFilter: 'dateRange', timeRange: 'dateRange', }, guidance: { @@ -902,8 +902,25 @@ export const DashboardSchema = lazySchema(() => strictObject({ /** Space between widgets, in steps of 0.25rem (4 = 1rem) */ gap: z.number().int().min(0).optional().describe('Space between widgets, in steps of 0.25rem (4 = 1rem)'), - /** Auto-refresh */ - refreshInterval: z.number().optional().describe('Auto-refresh interval in seconds'), + /** + * Auto-refresh + * + * Renamed from `refreshInterval` (#15680, ruling B on #14478): the unit lived + * only in the describe prose. The three rename-hint aliases beside it + * (`refresh` / `autoRefresh` / `pollInterval`) are the measure of how many + * spellings authors reach for, and not one of them names a unit either — so + * every door into this key left the cadence ambiguous until the canonical + * spelling carried it. + */ + refreshIntervalSeconds: z.number().optional().describe('Auto-refresh interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + refreshInterval: retiredKey( + '`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 ' + + '— the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** Dashboard Date Range (Global time filter) */ dateRange: strictObject({ diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 44750af4f2..80236e9a52 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -145,7 +145,7 @@ export const SalesDashboard: Dashboard = { label: 'Sales Performance', columns: 12, gap: 4, - refreshInterval: 180, // seconds; auto-refresh + refreshIntervalSeconds: 180, // auto-refresh cadence header: { showTitle: true,