Skip to content
Merged
106 changes: 106 additions & 0 deletions .changeset/17783-metrics-jsdoc-durations-unit-in-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
"@objectstack/spec": minor
---

feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939)

<!-- adr-0087: registered system-metrics-jsdoc-durations-unit-in-key -->

**BREAKING** — the five metrics durations whose unit was stated only in a source JSDoc now carry
it in the key name, and each published `.describe()` states it too.

| def | before | after |
|:--|:--|:--|
| `MetricDefinition` | `summary.maxAge: 600` | `summary.maxAgeSeconds: 600` |
| `ServiceLevelObjective` | `errorBudget.burnRateWindows[].window: 3600` | `errorBudget.burnRateWindows[].durationSeconds: 3600` |
| `MetricExportConfig` | `interval: 60` | `intervalSeconds: 60` |
| `MetricsConfig` | `collectionInterval: 15` | `collectionIntervalSeconds: 15` |
| `MetricsConfig` | `retention.period: 604800` | `retention.durationSeconds: 604800` |

Every value is seconds, exactly as before, and every default (600, 3600 as authored, 60, 15,
604800) is unchanged.

## Migration

```diff
summary: {
- maxAge: 600,
+ maxAgeSeconds: 600,
}

errorBudget: {
- burnRateWindows: [{ window: 3600, threshold: 14.4 }],
+ burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }],
}

exports: [{
type: 'prometheus',
- interval: 60,
+ intervalSeconds: 60,
}],
- collectionInterval: 15,
+ collectionIntervalSeconds: 15,
retention: {
- period: 604800,
+ durationSeconds: 604800,
},
```

Rename the keys. Nothing else on these four defs moves, and the three same-named objects on this
file — `MetricAggregationConfig.window`, `ServiceLevelIndicator.window` and
`ServiceLevelObjective.period` — are untouched.

## Why

Each key named its unit in a source JSDoc — "Max age of observations in seconds", "Window size in
seconds", "Export interval in seconds", "Collection interval in seconds", "Retention period in
seconds" — and nowhere else. Four of the five carried no `.describe()` at all and the fifth read
"Window size", so the text `content/docs/references/system/metrics.mdx` publishes named no unit:
600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of
milliseconds, and nothing on the page decided between them. Executes director-seat ruling A on
#15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the
#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit
in prose, none in the name), so each key is renamed and its describe corrected together.

Three of the five new names are deliberately **not** the mechanical suffix, and this file supplied
the reason for each:

- `burnRateWindows[].window` → **`durationSeconds`**, not `windowSeconds`. It is the fourth window
length on this file, and #15679 already settled that a window length here reads `durationSeconds`
so the measurements read alike. `windowSeconds` would stutter against the enclosing
`burnRateWindows` array — the same objection #15679 recorded against `window.windowSeconds` — and
on this tree `windowSeconds` is not an authorable key at all: its only key-position occurrence is
an alias-map entry in `ServerRateLimitConfigSchema` that maps the spelling *away* to `windowMs`.
- `retention.period` → **`durationSeconds`**, not `periodSeconds`. `period` is calendar vocabulary
elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar,
`PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so `periodSeconds` would have
kept the ambiguous half of the name — the same objection #15679 raised against `sizeSeconds`.
- `collectionInterval` → **`collectionIntervalSeconds`**, keeping the qualifier, because
`MetricExportConfig.intervalSeconds` is a different cadence one def over that this same change
creates.

The two mechanical spellings are attested: `maxAgeSeconds` is the token
`AccessControlConfig.maxAgeSeconds` already carries after this same rule renamed it on
`system/object-storage.zod.ts`, and it keeps the `age` stem that the sibling `ageBuckets` counts
buckets of; `intervalSeconds` is the token four seconds-valued cadences already carry. Counted in
key position across `packages/spec/src` at `fc28c1d38`, the base of this change, the seconds
suffixes run `Seconds` 40, `Sec` 1 (`maxExecutionTimeSec`) and `S` 0 — the two bare `*S` keys on
that corpus, `maxCommitTimeMS` and `enableRLS`, are a millisecond spelling and a boolean. This
change takes `Seconds` to 45 at `9b62f54671`.

## The kit

- a `retiredKey()` tombstone on each old spelling, so `tsc` types it `never` and a value reaching
the parse raises the rename prescription instead of being silently stripped (none of the five
enclosing shapes is `.strict()`)
- the ADR-0087 D3 semantic entry `system-metrics-jsdoc-durations-unit-in-key` and five
`RETIRED_KEYS_BY_MAJOR[18]` rows. No D2 conversion: `stack.zod.ts` declares no metrics collection
and none of these defs is a stored metadata row — the reading
`system-metrics-window-durations-unit-in-key` already recorded for this file
- pin tests per key: the refusal carries the rename prescription and is not an `unrecognized_keys`
issue, the suffixed key parses at the magnitude the retired one carried with the same default,
and each describe publishes the unit
- two authorable-surface rows move, three do not: that ratchet records **top-level** keys per def,
so `MetricExportConfig:interval` and `MetricsConfig:collectionInterval` become `[RETIRED]` beside
their suffixed rows (and their `authorable-defaults` rows move with them), while
`summary.maxAge`, `burnRateWindows[].window` and `retention.period` are nested and move nothing
34 changes: 27 additions & 7 deletions content/docs/references/system/metrics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Metric definition
| **description** | `string` | optional | Metric description |
| **labelNames** | `string[]` | optional (default: `[]`) | Label names |
| **histogram** | `{ type: Enum<'linear' \| 'exponential' \| 'explicit'>; linear?: object; exponential?: object; explicit?: object }` | optional | Histogram bucket configuration |
| **summary** | `{ quantiles: number[]; maxAge: integer; ageBuckets: integer }` | optional | |
| **summary** | `{ quantiles: number[]; maxAgeSeconds: integer; ageBuckets: integer }` | optional | |
| **enabled** | `boolean` | optional (default: `true`) | |

### Allowed Values: `MetricDefinition.unit`
Expand Down Expand Up @@ -201,6 +201,15 @@ Metric definition
| **exponential** | `{ start: number; factor: number; count: integer }` | optional | |
| **explicit** | `{ boundaries: number[] }` | optional | |

### Nested Shape: `MetricDefinition.summary`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **quantiles** | `number[]` | optional (default: `[0.5,0.9,0.99]`) | |
| **maxAgeSeconds** | `integer` | optional (default: `600`) | Max age of observations in seconds |
| **maxAge** | `never` | optional | [REMOVED] `MetricDefinition.summary.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. Its unit (seconds) lived in a source JSDoc only and the published description named none, so a reader of the reference page could not tell 600 seconds from 600 milliseconds. The new name keeps the `age` stem instead of becoming `durationSeconds`, because the sibling key `ageBuckets` counts buckets of that same age. Rename the key to `maxAgeSeconds`; the value (seconds) is unchanged. |
| **ageBuckets** | `integer` | optional (default: `5`) | |


---

Expand All @@ -214,7 +223,8 @@ Metric export configuration
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| 'cloudwatch' \| 'stackdriver' \| 'azure_monitor' \| 'http' \| 'custom'>` | ✅ | Export type |
| **endpoint** | `string` | optional | Export endpoint |
| **interval** | `integer` | optional (default: `60`) | |
| **intervalSeconds** | `integer` | optional (default: `60`) | Export interval in seconds |
| **interval** | `never` | optional | [REMOVED] `MetricExportConfig.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. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 60 seconds from 60 milliseconds. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. |
| **batch** | `{ enabled: boolean; size: integer }` | optional | |
| **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api_key'>; username?: string; password?: string; token?: string; … }` | optional | |
| **config** | `Record<string, any>` | optional | Additional configuration |
Expand Down Expand Up @@ -301,9 +311,10 @@ Metrics configuration
| **aggregations** | `{ type: Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>; window?: object; groupBy?: string[]; filters?: Record<string, any> }[]` | optional (default: `[]`) | |
| **slis** | `{ name: string; label: string; description?: string; metric: string; … }[]` | optional | |
| **slos** | `{ name: string; label: string; description?: string; sli: string; … }[]` | optional (default: `[]`) | |
| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; interval?: integer; batch?: object; … }[]` | optional (default: `[]`) | |
| **collectionInterval** | `integer` | optional (default: `15`) | |
| **retention** | `{ period?: integer; downsampling?: object[] }` | optional | |
| **exports** | `{ type: Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>; endpoint?: string; intervalSeconds?: integer; batch?: object; … }[]` | optional (default: `[]`) | |
| **collectionIntervalSeconds** | `integer` | optional (default: `15`) | Collection interval in seconds |
| **collectionInterval** | `never` | optional | [REMOVED] `MetricsConfig.collectionInterval` was renamed to `collectionIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 15 seconds from 15 milliseconds. The qualifier is kept — `collectionIntervalSeconds`, not `intervalSeconds` — because `MetricExportConfig.intervalSeconds` is a different cadence one def over. Rename the key to `collectionIntervalSeconds`; the value (seconds) is unchanged. |
| **retention** | `{ durationSeconds?: integer; downsampling?: object[] }` | optional | |
| **cardinalityLimits** | `{ maxLabelCombinations?: integer; onLimitExceeded?: Enum<'drop' \| 'sample' \| 'alert'> }` | optional | |

### Nested Shape: `MetricsConfig.metrics[number]`
Expand All @@ -319,7 +330,7 @@ Metric definition
| **description** | `string` | optional | Metric description |
| **labelNames** | `string[]` | optional (default: `[]`) | Label names |
| **histogram** | `{ type: Enum<'linear' \| 'exponential' \| 'explicit'>; linear?: object; exponential?: object; explicit?: object }` | optional | Histogram bucket configuration |
| **summary** | `{ quantiles?: number[]; maxAge?: integer; ageBuckets?: integer }` | optional | |
| **summary** | `{ quantiles?: number[]; maxAgeSeconds?: integer; ageBuckets?: integer }` | optional | |
| **enabled** | `boolean` | optional (default: `true`) | |

### Nested Shape: `MetricsConfig.aggregations[number]`
Expand Down Expand Up @@ -372,11 +383,20 @@ Metric export configuration
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'prometheus' \| 'openmetrics' \| 'graphite' \| 'statsd' \| 'influxdb' \| 'datadog' \| …>` | ✅ | Export type |
| **endpoint** | `string` | optional | Export endpoint |
| **interval** | `integer` | optional (default: `60`) | |
| **intervalSeconds** | `integer` | optional (default: `60`) | Export interval in seconds |
| **interval** | `never` | optional | [REMOVED] `MetricExportConfig.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. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 60 seconds from 60 milliseconds. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. |
| **batch** | `{ enabled?: boolean; size?: integer }` | optional | |
| **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api_key'>; username?: string; password?: string; token?: string; … }` | optional | |
| **config** | `Record<string, any>` | optional | Additional configuration |

### Nested Shape: `MetricsConfig.retention`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **durationSeconds** | `integer` | optional (default: `604800`) | Retention duration in seconds |
| **period** | `never` | optional | [REMOVED] `MetricsConfig.retention.period` 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. Its unit (seconds) lived in a source JSDoc only and the key carried no describe at all, so a reader of the reference page could not tell 604800 seconds from 604800 milliseconds. The new name is not `periodSeconds`: `period` is calendar vocabulary elsewhere in this spec (`ServiceLevelObjective.period.type` selects rolling or calendar, `PluginRegistryEntry.pricing.billingPeriod` is monthly or yearly), so it would have kept the ambiguous half of the name, and `durationSeconds` is what this file already calls a length of time. Rename the key to `durationSeconds`; the value (seconds) is unchanged. |
| **downsampling** | `{ afterSeconds: integer; resolution: integer }[]` | optional | |


---

Expand Down
4 changes: 2 additions & 2 deletions packages/spec/authorable-defaults/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@
"system/MetadataSaveOptions:overwrite = true",
"system/MetricDefinition:enabled = true",
"system/MetricDefinition:labelNames = []",
"system/MetricExportConfig:interval = 60",
"system/MetricExportConfig:intervalSeconds = 60",
"system/MetricsConfig:aggregations = []",
"system/MetricsConfig:collectionInterval = 15",
"system/MetricsConfig:collectionIntervalSeconds = 15",
"system/MetricsConfig:defaultLabels = {}",
"system/MetricsConfig:enabled = true",
"system/MetricsConfig:exports = []",
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/authorable-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,13 @@
"system/MetricExportConfig:batch",
"system/MetricExportConfig:config",
"system/MetricExportConfig:endpoint",
"system/MetricExportConfig:interval",
"system/MetricExportConfig:interval [RETIRED]",
"system/MetricExportConfig:intervalSeconds",
"system/MetricExportConfig:type",
"system/MetricsConfig:aggregations",
"system/MetricsConfig:cardinalityLimits",
"system/MetricsConfig:collectionInterval",
"system/MetricsConfig:collectionInterval [RETIRED]",
"system/MetricsConfig:collectionIntervalSeconds",
"system/MetricsConfig:defaultLabels",
"system/MetricsConfig:enabled",
"system/MetricsConfig:exports",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #15939 ruling A (per-file remediation of #14478). `summary.maxAge` said "Max
// age of observations in seconds" in a source JSDoc and carried no
// `.describe()` at all, so the reference page published a bare 600 and the
// gate listed the key among the duration-shaped ones without judging it.
// Renamed to `maxAgeSeconds`, the same token `AccessControlConfig.maxAgeSeconds`
// on `system/object-storage.zod.ts` already carries after this same rule renamed
// it — NOT `durationSeconds`, the spelling the three window lengths on this file
// take, because the sibling key `ageBuckets` counts buckets of this very age and
// dropping the `age` stem would orphan the pair. The value is unchanged.
// Tombstoned with `retiredKey()`: the nested `summary` object is not strict, so
// a bare deletion would silently strip the key. A NESTED site: the
// authorable-surface ratchet walks top-level def properties only, so no
// `[RETIRED]` row exists for it and gate (b) of `build-schemas.ts` neither
// demands nor refuses this entry — it is here for the spec-changes /
// upgrade-guide projection. No D2 conversion: `stack.zod.ts` declares no metrics
// collection and a metric definition is not a stored metadata row.
// See `system-metrics-jsdoc-durations-unit-in-key`.
export const entry = 'system/MetricDefinition:summary.maxAge';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #15939 ruling A (per-file remediation of #14478). `MetricExportConfig.interval`
// said "Export interval in seconds" in a source JSDoc and carried no
// `.describe()` at all — one of the three sites the #15939 filing named — so the
// reference page published a bare 60, a plausible number of seconds and a
// plausible number of milliseconds. Renamed to `intervalSeconds`, the token
// every seconds-valued cadence in this spec already carries (`intervalSeconds`
// on `ai/model-registry`, `api/auth-endpoints`, `data/driver/turso` and
// `integration/connector`); no competing `intervalSec` or `intervalS` spelling
// exists. The 60 default is unchanged. Tombstoned with `retiredKey()`: this
// object is not strict, so a bare deletion would silently strip the key. A
// TOP-LEVEL site, so the authorable-surface ratchet moves: the row becomes
// `system/MetricExportConfig:interval [RETIRED]` beside a new
// `system/MetricExportConfig:intervalSeconds`, and the authorable-defaults row
// moves with it. No D2 conversion: `stack.zod.ts` declares no metrics collection
// and an export config is not a stored metadata row.
// See `system-metrics-jsdoc-durations-unit-in-key`.
export const entry = 'system/MetricExportConfig:interval';
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #15939 ruling A (per-file remediation of #14478). `MetricsConfig.collectionInterval`
// said "Collection interval in seconds" in a source JSDoc and carried no
// `.describe()` at all, so the reference page published a bare 15. Renamed to
// `collectionIntervalSeconds` and not to a bare `intervalSeconds`: the qualifier
// distinguishes it from `MetricExportConfig.intervalSeconds`, a different cadence
// one def over that this same card renames, and the qualifier-plus-IntervalSeconds
// compound is the attested form (`syncIntervalSeconds`, `refreshIntervalSeconds`,
// `healthCheckIntervalSeconds`). The 15 default is unchanged. Tombstoned with
// `retiredKey()`: this object is not strict, so a bare deletion would silently
// strip the key. A TOP-LEVEL site, so the authorable-surface ratchet moves: the
// row becomes `system/MetricsConfig:collectionInterval [RETIRED]` beside a new
// `system/MetricsConfig:collectionIntervalSeconds`, and the authorable-defaults
// row moves with it. No D2 conversion: `stack.zod.ts` declares no metrics
// collection and a metrics config is not a stored metadata row.
// See `system-metrics-jsdoc-durations-unit-in-key`.
export const entry = 'system/MetricsConfig:collectionInterval';
Loading
Loading