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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
95 changes: 95 additions & 0 deletions .changeset/api-duration-keys-unit-in-key-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
"@objectstack/spec": minor
"@objectstack/runtime": patch
---

feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478)

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

**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned.
Shipped as `minor` under the repo's launch-window convention for breaking
changes; the hand-migration prescriptions are registered under protocol major
18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」).

`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit
in the key NAME, never only in its `.describe()` prose, and grandfathers no
existing offender. Stack card 1/6 (#15676) landed the rule's two structural
exemptions; this card clears the `api/` directory against it. Measured with the
gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree
count falls **48 → 36**.

## FROM → TO

| key | replacement | unit |
|:--|:--|:--|
| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds |
| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds |
| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds |
| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds |
| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds |
| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds |
| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds |
| `RouteDefinition.timeout` | `timeoutMs` | milliseconds |
| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds |
| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds |
| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds |
| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds |

**Every value is unchanged** — only key names move. Every old spelling is a
`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type
`never`) and fails the parse with the rename prescription rather than a bare
unrecognized-key error.

## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch

Ruling B put this key explicitly in scope with its own BREAKING note: the
runtime-emitted measurements are read by humans and agents even though nobody
authors them. A consumer meets two retry-after values on one 429 — this
ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After`
header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date.
Spelled identically they read as one value in two places.

**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its
name is fixed outside this repo and nothing here touches it. Do not "fix" the
header to match the envelope, and do not read a surviving `retry-after` in
transport code as leftover work.

## Dispositions — one D2 conversion, five semantic entries

Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only
one of the twelve that gets an ADR-0087 D2 conversion**
(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack
collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered
metadata kind stored as a row, so the conversion chain has a seam that sees it.
`os migrate meta --from 17` lists the mechanical edits.

The other eleven are wire payloads and construction arguments — a device-flow
response body, an error envelope, REST-plugin route registration, a batch-loader
config, a router registration, WebSocket client/server configuration. None is
ever a stack collection member or a `sys_metadata` row, so no conversion seam
runs on them and each carries a **semantic** entry instead: this is the
disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these
very shapes, and what ruling B prescribes for a runtime-emitted key.

## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror

Attributed to RFC 8628 by the campaign card; the attribution fails against the
schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as
a set — `code` is not `device_code`, `verificationUrl` is not
`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a
different type, an ISO-8601 instant where the RFC carries a relative lifetime).
A schema that already renames every RFC field it carries into house style cannot
claim the standard fixes the one name it left bare. Renamed rather than marked
deliberately: a wrongly marked key is exempted permanently and silently, while a
wrongly renamed one is visible.

## Readers moved in the same PR, at the same magnitude

`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads
`endpoint.cacheTtlSeconds`), the publish gate's issue path
(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase
example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead`
tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is
retargeted onto the live key — an alias must point at a key the schema really
accepts, and `cacheTtl` now accepts nothing.
11 changes: 11 additions & 0 deletions .changeset/cli-explain-dashboard-refresh-interval-seconds.md
Original file line number Diff line number Diff line change
@@ -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.
138 changes: 138 additions & 0 deletions .changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered 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, ai-conversation-analytics-duration-unit-in-key, data-nosql-query-options-timeout-unit-in-key -->

**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.
28 changes: 28 additions & 0 deletions .changeset/driver-memory-auto-save-interval-ms.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered memory-persistence-auto-save-interval-to-ms -->

**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 } })
```
41 changes: 41 additions & 0 deletions .changeset/driver-turso-config-timeout-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/driver-turso": minor
---

feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478)

<!-- adr-0087: registered turso-config-timeout-to-timeout-ms -->
<!-- The protocol-18 conversion above is registered by card 5/6 of the #14478 stack; relative to
`main` this combined diff is what adds it, so `registered` is the honest disposition at this
level even though `already-registered` was honest per-card. -->

**BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The
value is unchanged: the same milliseconds, the same `min(0)` bound, the same
optionality.

`@objectstack/spec`'s own turso contract renamed the same authored key in
#15680. This package publishes a parallel schema for the same connection config
— the Spec / Studio metadata a host reads to expose Turso configuration UI — so
until now the two declarations of one setting disagreed on its spelling. They
agree again.

The unit was never in the key name, only in the describe prose, while
`sync.intervalSeconds` — the same shape, three keys above — already spelled its
own. One published config carrying both conventions is what made the bare name
dangerous rather than untidy: an author who has just written
`intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and
nothing in the schema, the type or the parse would have told them otherwise.

The old spelling is not dropped in silence. `TursoConfigSchema` is a plain
`z.object`, so a bare deletion would have STRIPPED `timeout` and parsed
successfully. The key stays declared as a tombstone instead: `tsc` refuses it on
anything typed `TursoConfig`, and a value that reaches the parse raises a
message naming `timeoutMs` rather than a generic unrecognised-key error.

```diff
- TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 })
+ TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 })
```

`TursoDriverConfig` — this package's TypeScript constructor option, a separate
declaration — keeps its `timeout` spelling and is untouched here.
Loading
Loading