From b1b9d8d3e443f7d363b212a627052ed58350b1bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:56:57 +0000 Subject: [PATCH 1/6] feat(spec)!: metrics duration keys carry their unit in the key name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — five renames on system/metrics.zod.ts plus their ADR-0087 entries. Generated artifacts, tests and changeset still to come. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- ...ystem__MetricDefinition__summary.maxAge.ts | 20 +++ ...18.system__MetricExportConfig__interval.ts | 19 +++ ...stem__MetricsConfig__collectionInterval.ts | 18 +++ ...system__MetricsConfig__retention.period.ts | 21 +++ ...ive__errorBudget.burnRateWindows.window.ts | 30 ++++ ...tem-metrics-jsdoc-durations-unit-in-key.ts | 85 +++++++++++ packages/spec/src/system/metrics.zod.ts | 135 ++++++++++++++++-- 7 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__MetricDefinition__summary.maxAge.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__MetricExportConfig__interval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__collectionInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__retention.period.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__errorBudget.burnRateWindows.window.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricDefinition__summary.maxAge.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricDefinition__summary.maxAge.ts new file mode 100644 index 0000000000..b537e27f84 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricDefinition__summary.maxAge.ts @@ -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'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricExportConfig__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricExportConfig__interval.ts new file mode 100644 index 0000000000..4f9d0b958d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricExportConfig__interval.ts @@ -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'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__collectionInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__collectionInterval.ts new file mode 100644 index 0000000000..ad53511bd2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__collectionInterval.ts @@ -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'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__retention.period.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__retention.period.ts new file mode 100644 index 0000000000..f4e8c2f0da --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricsConfig__retention.period.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `MetricsConfig.retention.period` +// said "Retention period in seconds" in a source JSDoc and carried no +// `.describe()` at all, so the reference page published a bare 604800. Renamed to +// `durationSeconds`, not to the mechanical `periodSeconds`: `period` is calendar +// vocabulary elsewhere in this spec — `ServiceLevelObjective.period.type` selects +// rolling or calendar and `PluginRegistryEntry.pricing.billingPeriod` is monthly +// or yearly — so `periodSeconds` would have kept the ambiguous half of the name +// and bolted a unit onto it, the same objection #15679 raised against +// `sizeSeconds`. `durationSeconds` is what this file already calls a length of +// time, in three places. The 604800 (7 day) default is unchanged. Tombstoned with +// `retiredKey()`: the nested `retention` 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 — the top-level row is +// `system/MetricsConfig:retention` and it does not move — so gate (b) of +// `build-schemas.ts` neither demands nor refuses this entry. 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:retention.period'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__errorBudget.burnRateWindows.window.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__errorBudget.burnRateWindows.window.ts new file mode 100644 index 0000000000..71c2f4e543 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__errorBudget.burnRateWindows.window.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). The error-budget burn-rate +// `window` said "Window size in seconds" in a source JSDoc while its +// `.describe()` read "Window size" and named no unit — the exact site #15939 +// was filed on. Renamed to `durationSeconds`: it is the fourth window length on +// this file, and #15679 already settled that a window length here is spelled +// `durationSeconds` so the measurements read alike. `windowSeconds` is rejected +// for the reason #15679 recorded against `window.windowSeconds` — the enclosing +// array is already called `burnRateWindows`, so the key would stutter — and +// because on this tree `windowSeconds` is not an authorable key at all: its only +// key-position occurrence is an entry in `ServerRateLimitConfigSchema`'s alias +// map that maps the spelling AWAY to `windowMs`. The value is unchanged. +// Tombstoned with `retiredKey()`: this array-element object is not strict, so a +// bare deletion would silently strip the key. +// +// The path crosses an ARRAY (`errorBudget.burnRateWindows` is a `z.array`) and +// is spelled with plain dots, no bracket token, because that is the only +// notation this table uses: of its rows none carries a bracket, and the two +// historical rows that crossed an array — `system/RollbackPlan:steps.…` and +// `system/ChangeRequest:implementation.steps.…` — spelled it this way (their +// schemas have since been retired, so the precedent cannot be re-read on this +// tree; flagged for the contract review). 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. +// No D2 conversion: an SLO is not a stack collection member and not a stored +// metadata row — the reading `system-metrics-window-durations-unit-in-key` +// already recorded for this same def. +// See `system-metrics-jsdoc-durations-unit-in-key`. +export const entry = 'system/ServiceLevelObjective:errorBudget.burnRateWindows.window'; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts new file mode 100644 index 0000000000..1b06f7c740 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-metrics-jsdoc-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the five remaining metrics durations whose unit lived in a source JSDoc only: ' + + 'MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, ' + + 'MetricExportConfig.interval, MetricsConfig.collectionInterval and ' + + 'MetricsConfig.retention.period (system/metrics.zod.ts)', + replacement: 'summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, ' + + 'intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each ' + + 'key; every value is unchanged', + reason: + 'This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, ' + + 'and the two are meant to be read as a sequence — this one does not amend that record, ' + + 'which stays a true account of what #15679 did. #15679 renamed the three metrics window ' + + 'and period lengths whose describe named no unit, and recorded that the error-budget ' + + 'burn-rate window was "outside this rename, not outside the gate population", naming the ' + + 'JSDoc-channel gap #15939 as where it would be settled. #15939 is now ruled and this is ' + + 'its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer\'s 「同意」 ' + + '(decision batch #115), which remediates the 21-row JSDoc-channel population per file and ' + + 'lands the widened gate (#17635) last, into a tree already clean. ⚠️ One consequence for ' + + 'readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its ' + + 'name and that a sweep renaming it has over-applied the rule. That sentence was true of ' + + '#15679 and is superseded here, by the ruling it itself pointed at; the other key it ' + + 'names, the exporter batch size, is a COUNT of records and still does not move. All five ' + + 'keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a ' + + 'channel check:duration-unit-keys does not read — it reads .describe() and ' + + '.meta({ description }) — and four of the five carried no describe at all while the fifth ' + + 'read "Window size". So the reader who most needs the unit, the reader of the published ' + + 'reference page, got a bare integer: 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 ' + + 'it. Each key is renamed and its describe corrected in the same stroke, because under the ' + + '#14478 rule moving the unit into the describe alone is itself a violation. Three of the ' + + 'five spellings are not the mechanical suffix, and each departure has a reason this file ' + + 'already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, ' + + 'because the enclosing array is already called burnRateWindows so the key would stutter — ' + + 'the objection #15679 recorded against window.windowSeconds — and because on this tree ' + + 'windowSeconds is not an authorable key at all, its only key-position occurrence being an ' + + 'alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; ' + + 'retention.period becomes durationSeconds, not periodSeconds, because 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 keep the ambiguous half of the name; and collectionInterval keeps its qualifier as ' + + 'collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds ' + + 'this same card creates one def over. 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 the ' + + 'sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued ' + + 'cadences already carry. Counted on this tree, key-position seconds suffixes across ' + + 'packages/spec/src run Seconds 40, Sec 1 and S 0, so Seconds is the family. All five are ' + + 'retiredKey() tombstones; none of the five enclosing shapes is 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 a metric definition, an SLO, an export config ' + + 'or a metrics config is a registered metadata kind stored as a sys_metadata row — the same ' + + 'reading #15679 recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo ' + + 'code consumer reads any of the five — outside packages/spec the only occurrences of every ' + + 'distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, ' + + 'collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the ' + + 'generated content/docs/references/system/metrics.mdx, which this rename regenerates, ' + + 'against a lit control of 1195 defineStack occurrences on the same corpus; and the objectui ' + + 'checkout this repo builds against — this is the pin, ' + + '`.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, re-read from this tree — ' + + 'spells all six metrics def names and both distinctive keys 0 times across 6409 tracked ' + + 'files, against lit controls window 2710, timeout 832, period 160, interval 156 and ' + + 'metrics 301 on that same corpus, so no pin bump is owed. ' + + '#15939, #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window ' + + 'spells durationSeconds, every metric export config spells intervalSeconds, and every ' + + 'metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring ' + + 'any of the five old spellings fails to compile (input type `never`) and fails to parse ' + + 'with the rename prescription naming the suffixed key — not an unrecognized_keys issue. ' + + 'Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds ' + + 'exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and ' + + 'positive-integer bound rides along with its renamed key. Each new describe names the unit, ' + + 'so the reference page carries it. Verify the same-named decoys on this one file apart: ' + + 'MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already ' + + 'hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding ' + + 'a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any ' + + 'of them has over-applied this rule.', +}; diff --git a/packages/spec/src/system/metrics.zod.ts b/packages/spec/src/system/metrics.zod.ts index 6e941789eb..a747965b7d 100644 --- a/packages/spec/src/system/metrics.zod.ts +++ b/packages/spec/src/system/metrics.zod.ts @@ -135,6 +135,20 @@ export const MetricLabelsSchema = lazySchema(() => z.record(z.string(), z.string export type MetricLabels = z.input; +// Declared ABOVE its consumer on purpose: `gen:schema` and +// `check:authorable-surface` run with `OS_EAGER_SCHEMAS=1`, which makes +// `lazySchema` evaluate its factory at module load, so a const declared after +// the schema that reads it would be a temporal-dead-zone read. Every tombstone +// string on this file sits above the schema that consumes it. +const SUMMARY_MAX_AGE_RETIRED = + '`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.'; + /** * Metric Definition Schema */ @@ -186,9 +200,23 @@ export const MetricDefinitionSchema = lazySchema(() => z.object({ quantiles: z.array(z.number().min(0).max(1)).optional().default([0.5, 0.9, 0.99]), /** - * Max age of observations in seconds + * Max age of observations in seconds. + * + * Renamed from `maxAge` (#15939 ruling A, executing #14478): the unit lived + * in this JSDoc only, and the key carried no `.describe()` at all, so the + * reference page published a bare integer. Spelled `maxAgeSeconds` — the + * same token `AccessControlConfig.maxAgeSeconds` on `system/object-storage.zod.ts` + * already carries after the same rule renamed it — and NOT `durationSeconds`, + * the spelling the three window lengths on this file take: this number is an + * age bound whose sibling `ageBuckets` counts buckets of that very age, so + * dropping the `age` stem would orphan the pair. Tombstoned rather than + * deleted because this nested `summary` object is not `.strict()`. */ - maxAge: z.number().int().positive().optional().default(600), + maxAgeSeconds: z.number().int().positive().optional().default(600) + .describe('Max age of observations in seconds'), + + /** Tombstone for the rename above (#15939 ruling A, executing #14478). */ + maxAge: retiredKey(SUMMARY_MAX_AGE_RETIRED), /** * Number of age buckets @@ -334,6 +362,19 @@ const SLO_PERIOD_DURATION_RETIRED = + 'only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) ' + 'is unchanged.'; +// Above `ServiceLevelObjectiveSchema`, the schema that reads it — see the +// eager-evaluation note on `SUMMARY_MAX_AGE_RETIRED`. +const SLO_BURN_RATE_WINDOW_RETIRED = + '`ServiceLevelObjective.errorBudget.burnRateWindows[].window` 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 published description read "Window size", naming none, so a reader of the ' + + 'reference page could not tell 3600 seconds from 3600 milliseconds. The new name is not ' + + '`windowSeconds`: the array this object sits in is already called `burnRateWindows`, so the ' + + 'key would stutter, and it matches the three window and period lengths this file already ' + + 'spells `durationSeconds`. Rename the key to `durationSeconds`; the value (seconds) is ' + + 'unchanged.'; + export const MetricAggregationConfigSchema = lazySchema(() => z.object({ /** * Aggregation type @@ -547,9 +588,21 @@ export const ServiceLevelObjectiveSchema = lazySchema(() => z.object({ */ burnRateWindows: z.array(z.object({ /** - * Window size in seconds + * Window duration in seconds. + * + * Renamed from `window` (#15939 ruling A, executing #14478): the unit + * lived in this JSDoc only and the `.describe()` the reference pages + * publish read "Window size", naming none. The fourth window length on + * this file, so it takes the same `durationSeconds` the other three take + * (#15679); `windowSeconds` would stutter against the enclosing + * `burnRateWindows` array exactly as `window.windowSeconds` would have. + * Tombstoned rather than deleted because this array-element object is + * not `.strict()`. */ - window: z.number().int().positive().describe('Window size'), + durationSeconds: z.number().int().positive().describe('Window duration in seconds'), + + /** Tombstone for the rename above (#15939 ruling A, executing #14478). */ + window: retiredKey(SLO_BURN_RATE_WINDOW_RETIRED), /** * Burn rate multiplier threshold @@ -591,6 +644,15 @@ export type ServiceLevelObjective = z.input; /** Post-parse shape of {@link ServiceLevelObjective} — defaults applied, transforms run (ADR-0122). */ export type ServiceLevelObjectiveParsed = z.infer; +// Above `MetricExportConfigSchema`, the schema that reads it — see the +// eager-evaluation note on `SUMMARY_MAX_AGE_RETIRED`. +const EXPORT_INTERVAL_RETIRED = + '`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.'; + /** * Metric Export Configuration */ @@ -618,9 +680,19 @@ export const MetricExportConfigSchema = lazySchema(() => z.object({ endpoint: z.string().optional().describe('Export endpoint'), /** - * Export interval in seconds + * Export interval in seconds. + * + * Renamed from `interval` (#15939 ruling A, executing #14478): the unit lived + * in this JSDoc only and the key carried no `.describe()` at all. Spelled + * `intervalSeconds`, the token every seconds-valued cadence in this spec + * already carries. Tombstoned rather than deleted because this object is not + * `.strict()`. */ - interval: z.number().int().positive().optional().default(60), + intervalSeconds: z.number().int().positive().optional().default(60) + .describe('Export interval in seconds'), + + /** Tombstone for the rename above (#15939 ruling A, executing #14478). */ + interval: retiredKey(EXPORT_INTERVAL_RETIRED), /** * Batch configuration @@ -651,6 +723,30 @@ export type MetricExportConfig = z.input; /** Post-parse shape of {@link MetricExportConfig} — defaults applied, transforms run (ADR-0122). */ export type MetricExportConfigParsed = z.infer; +// Both above `MetricsConfigSchema`, the schema that reads them — see the +// eager-evaluation note on `SUMMARY_MAX_AGE_RETIRED`. +const COLLECTION_INTERVAL_RETIRED = + '`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.'; + +const RETENTION_PERIOD_RETIRED = + '`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.'; + /** * Metrics Configuration Schema */ @@ -704,18 +800,37 @@ export const MetricsConfigSchema = lazySchema(() => z.object({ exports: z.array(MetricExportConfigSchema).optional().default([]), /** - * Collection interval in seconds + * Collection interval in seconds. + * + * Renamed from `collectionInterval` (#15939 ruling A, executing #14478): the + * unit lived in this JSDoc only and the key carried no `.describe()` at all. + * Tombstoned rather than deleted because this object is not `.strict()`. */ - collectionInterval: z.number().int().positive().optional().default(15), + collectionIntervalSeconds: z.number().int().positive().optional().default(15) + .describe('Collection interval in seconds'), + + /** Tombstone for the rename above (#15939 ruling A, executing #14478). */ + collectionInterval: retiredKey(COLLECTION_INTERVAL_RETIRED), /** * Retention configuration */ retention: z.object({ /** - * Retention period in seconds + * Retention duration in seconds. + * + * Renamed from `period` (#15939 ruling A, executing #14478): the unit lived + * in this JSDoc only and the key carried no `.describe()` at all. Named + * `durationSeconds` and not `periodSeconds` because `period` is calendar + * vocabulary elsewhere in this spec, and because this file already spells a + * length of time `durationSeconds` (#15679). Tombstoned rather than deleted + * because this nested `retention` object is not `.strict()`. */ - period: z.number().int().positive().optional().default(604800), // 7 days + durationSeconds: z.number().int().positive().optional().default(604800) // 7 days + .describe('Retention duration in seconds'), + + /** Tombstone for the rename above (#15939 ruling A, executing #14478). */ + period: retiredKey(RETENTION_PERIOD_RETIRED), /** * Downsampling configuration From f47c20ab8cc0148c49ec69bc62a1cfa33345ed6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 11:05:58 +0000 Subject: [PATCH 2/6] feat(spec)!: metrics duration keys carry their unit in the key name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — tests, generated artifacts and reference docs regenerated. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- content/docs/references/system/metrics.mdx | 34 +++- packages/spec/authorable-defaults/system.json | 4 +- packages/spec/authorable-surface/system.json | 6 +- packages/spec/src/migrations/registry.ts | 179 ++++++++++++++++++ packages/spec/src/system/metrics.test.ts | 179 ++++++++++++++++-- 5 files changed, 377 insertions(+), 25 deletions(-) diff --git a/content/docs/references/system/metrics.mdx b/content/docs/references/system/metrics.mdx index d1b63b6d4c..b2b051fe4b 100644 --- a/content/docs/references/system/metrics.mdx +++ b/content/docs/references/system/metrics.mdx @@ -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` @@ -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`) | | + --- @@ -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` | optional | Additional configuration | @@ -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 }[]` | 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]` @@ -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]` @@ -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` | 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 | | + --- diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 6f73de81d9..b83867f5a5 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -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 = []", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 3d2fc43ca2..7da9681fc8 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -729,11 +729,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", diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4eb03331fc..ed12cc3932 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10317,6 +10317,87 @@ const step18: MigrationStep = { + '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-jsdoc-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the five remaining metrics durations whose unit lived in a source JSDoc only: ' + + 'MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, ' + + 'MetricExportConfig.interval, MetricsConfig.collectionInterval and ' + + 'MetricsConfig.retention.period (system/metrics.zod.ts)', + replacement: 'summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, ' + + 'intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each ' + + 'key; every value is unchanged', + reason: + 'This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, ' + + 'and the two are meant to be read as a sequence — this one does not amend that record, ' + + 'which stays a true account of what #15679 did. #15679 renamed the three metrics window ' + + 'and period lengths whose describe named no unit, and recorded that the error-budget ' + + 'burn-rate window was "outside this rename, not outside the gate population", naming the ' + + 'JSDoc-channel gap #15939 as where it would be settled. #15939 is now ruled and this is ' + + 'its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer\'s 「同意」 ' + + '(decision batch #115), which remediates the 21-row JSDoc-channel population per file and ' + + 'lands the widened gate (#17635) last, into a tree already clean. ⚠️ One consequence for ' + + 'readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its ' + + 'name and that a sweep renaming it has over-applied the rule. That sentence was true of ' + + '#15679 and is superseded here, by the ruling it itself pointed at; the other key it ' + + 'names, the exporter batch size, is a COUNT of records and still does not move. All five ' + + 'keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a ' + + 'channel check:duration-unit-keys does not read — it reads .describe() and ' + + '.meta({ description }) — and four of the five carried no describe at all while the fifth ' + + 'read "Window size". So the reader who most needs the unit, the reader of the published ' + + 'reference page, got a bare integer: 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 ' + + 'it. Each key is renamed and its describe corrected in the same stroke, because under the ' + + '#14478 rule moving the unit into the describe alone is itself a violation. Three of the ' + + 'five spellings are not the mechanical suffix, and each departure has a reason this file ' + + 'already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, ' + + 'because the enclosing array is already called burnRateWindows so the key would stutter — ' + + 'the objection #15679 recorded against window.windowSeconds — and because on this tree ' + + 'windowSeconds is not an authorable key at all, its only key-position occurrence being an ' + + 'alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; ' + + 'retention.period becomes durationSeconds, not periodSeconds, because 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 keep the ambiguous half of the name; and collectionInterval keeps its qualifier as ' + + 'collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds ' + + 'this same card creates one def over. 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 the ' + + 'sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued ' + + 'cadences already carry. Counted on this tree, key-position seconds suffixes across ' + + 'packages/spec/src run Seconds 40, Sec 1 and S 0, so Seconds is the family. All five are ' + + 'retiredKey() tombstones; none of the five enclosing shapes is 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 a metric definition, an SLO, an export config ' + + 'or a metrics config is a registered metadata kind stored as a sys_metadata row — the same ' + + 'reading #15679 recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo ' + + 'code consumer reads any of the five — outside packages/spec the only occurrences of every ' + + 'distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, ' + + 'collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the ' + + 'generated content/docs/references/system/metrics.mdx, which this rename regenerates, ' + + 'against a lit control of 1195 defineStack occurrences on the same corpus; and the objectui ' + + 'checkout this repo builds against — this is the pin, ' + + '`.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, re-read from this tree — ' + + 'spells all six metrics def names and both distinctive keys 0 times across 6409 tracked ' + + 'files, against lit controls window 2710, timeout 832, period 160, interval 156 and ' + + 'metrics 301 on that same corpus, so no pin bump is owed. ' + + '#15939, #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window ' + + 'spells durationSeconds, every metric export config spells intervalSeconds, and every ' + + 'metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring ' + + 'any of the five old spellings fails to compile (input type `never`) and fails to parse ' + + 'with the rename prescription naming the suffixed key — not an unrecognized_keys issue. ' + + 'Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds ' + + 'exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and ' + + 'positive-integer bound rides along with its renamed key. Each new describe names the unit, ' + + 'so the reference page carries it. Verify the same-named decoys on this one file apart: ' + + 'MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already ' + + 'hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding ' + + 'a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any ' + + 'of them has over-applied this rule.', + }, { id: 'system-metrics-window-durations-unit-in-key', surface: 'the three metrics window/period lengths whose name carried no unit: ' @@ -13169,6 +13250,76 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // 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', + // #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`. + 'system/MetricDefinition:summary.maxAge', + // #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`. + 'system/MetricExportConfig:interval', + // #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`. + 'system/MetricsConfig:collectionInterval', + // #15939 ruling A (per-file remediation of #14478). `MetricsConfig.retention.period` + // said "Retention period in seconds" in a source JSDoc and carried no + // `.describe()` at all, so the reference page published a bare 604800. Renamed to + // `durationSeconds`, not to the mechanical `periodSeconds`: `period` is calendar + // vocabulary elsewhere in this spec — `ServiceLevelObjective.period.type` selects + // rolling or calendar and `PluginRegistryEntry.pricing.billingPeriod` is monthly + // or yearly — so `periodSeconds` would have kept the ambiguous half of the name + // and bolted a unit onto it, the same objection #15679 raised against + // `sizeSeconds`. `durationSeconds` is what this file already calls a length of + // time, in three places. The 604800 (7 day) default is unchanged. Tombstoned with + // `retiredKey()`: the nested `retention` 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 — the top-level row is + // `system/MetricsConfig:retention` and it does not move — so gate (b) of + // `build-schemas.ts` neither demands nor refuses this entry. 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`. + 'system/MetricsConfig:retention.period', // #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 @@ -13253,6 +13404,34 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. // See `system-metrics-window-durations-unit-in-key`. 'system/ServiceLevelIndicator:window.size', + // #15939 ruling A (per-file remediation of #14478). The error-budget burn-rate + // `window` said "Window size in seconds" in a source JSDoc while its + // `.describe()` read "Window size" and named no unit — the exact site #15939 + // was filed on. Renamed to `durationSeconds`: it is the fourth window length on + // this file, and #15679 already settled that a window length here is spelled + // `durationSeconds` so the measurements read alike. `windowSeconds` is rejected + // for the reason #15679 recorded against `window.windowSeconds` — the enclosing + // array is already called `burnRateWindows`, so the key would stutter — and + // because on this tree `windowSeconds` is not an authorable key at all: its only + // key-position occurrence is an entry in `ServerRateLimitConfigSchema`'s alias + // map that maps the spelling AWAY to `windowMs`. The value is unchanged. + // Tombstoned with `retiredKey()`: this array-element object is not strict, so a + // bare deletion would silently strip the key. + // + // The path crosses an ARRAY (`errorBudget.burnRateWindows` is a `z.array`) and + // is spelled with plain dots, no bracket token, because that is the only + // notation this table uses: of its rows none carries a bracket, and the two + // historical rows that crossed an array — `system/RollbackPlan:steps.…` and + // `system/ChangeRequest:implementation.steps.…` — spelled it this way (their + // schemas have since been retired, so the precedent cannot be re-read on this + // tree; flagged for the contract review). 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. + // No D2 conversion: an SLO is not a stack collection member and not a stored + // metadata row — the reading `system-metrics-window-durations-unit-in-key` + // already recorded for this same def. + // See `system-metrics-jsdoc-durations-unit-in-key`. + 'system/ServiceLevelObjective:errorBudget.burnRateWindows.window', // #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` diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index 3e12157f4f..5ed1c66816 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -146,7 +146,7 @@ describe('MetricDefinitionSchema', () => { type: 'summary', summary: { quantiles: [0.5, 0.9, 0.95, 0.99], - maxAge: 300, + maxAgeSeconds: 300, }, }; @@ -351,8 +351,8 @@ describe('ServiceLevelObjectiveSchema', () => { enabled: true, alertThreshold: 75, burnRateWindows: [ - { window: 3600, threshold: 14.4 }, - { window: 86400, threshold: 6 }, + { durationSeconds: 3600, threshold: 14.4 }, + { durationSeconds: 86400, threshold: 6 }, ], }, }; @@ -382,21 +382,21 @@ describe('MetricExportConfigSchema', () => { }); expect(config.type).toBe('prometheus'); - expect(config.interval).toBe(60); + expect(config.intervalSeconds).toBe(60); }); it('should accept HTTP push export', () => { const config = MetricExportConfigSchema.parse({ type: 'http', endpoint: 'https://metrics.example.com', - interval: 30, + intervalSeconds: 30, auth: { type: 'bearer', token: 'secret-token', }, }); - expect(config.interval).toBe(30); + expect(config.intervalSeconds).toBe(30); expect(config.auth?.type).toBe('bearer'); }); }); @@ -420,7 +420,7 @@ describe('MetricsConfigSchema', () => { expect(config.enabled).toBe(true); expect(config.metrics).toEqual([]); expect(config.defaultLabels).toEqual({}); - expect(config.collectionInterval).toBe(15); + expect(config.collectionIntervalSeconds).toBe(15); }); it('should accept full configuration', () => { @@ -468,7 +468,7 @@ describe('MetricsConfigSchema', () => { }, ], retention: { - period: 604800, + durationSeconds: 604800, }, }; @@ -557,17 +557,168 @@ describe('metrics window and period lengths carry their unit (#15679)', () => { }).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. + // Was `leaves the two non-duration keys on this file alone`, and it pinned two + // subjects. The exporter batch `size` (below) is unchanged and still true. The + // other was the error-budget burn-rate `window`, held bare on the reading that + // it is "outside the gate population entirely". #15939 ruling A settled that it + // is outside #15679's RENAME, not outside the population — its unit lived in + // the JSDoc channel the gate does not read — so this card renames it and that + // guard succeeds by failing. Its replacement pins are in the #15939 block + // below; the header is narrowed here rather than left asserting a second key + // that no longer stays bare. + it('leaves the exporter batch size — a COUNT of records — alone', () => { 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({ + }); +}); + +// #15939 ruling A (executing #14478) — the five durations on this file whose +// unit lived in a source JSDoc only, a channel `check:duration-unit-keys` does +// not read: four carried no `.describe()` at all and the fifth read "Window +// size". Every old spelling is a `retiredKey()` tombstone (none of the five +// enclosing shapes is `.strict()`, so a bare deletion would silently strip); +// asserted on the issue CODE and the prescription, never on a bare `toThrow()`. +// Three of the five new names are not the mechanical suffix: see the tombstone +// prose on each key for why `windowSeconds`, `periodSeconds` and a bare +// `intervalSeconds` were rejected. +describe('metrics JSDoc-only durations carry their unit (#15939, #14478)', () => { + 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 `MetricDefinition.summary.maxAge` with a rename naming `maxAgeSeconds`', () => { + const result = MetricDefinitionSchema.safeParse({ + name: 'response_time', type: 'summary', summary: { maxAge: 600 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'summary.maxAge'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`MetricDefinition\.summary\.maxAge` was renamed.*Rename the key to `maxAgeSeconds`/s, + ); + // The prescription must EXPLAIN why it is not `durationSeconds` like the + // three window lengths on this same file, or the next author "corrects" it. + expect(issue!.message).toContain('ageBuckets'); + }); + + it('REFUSES the burn-rate `window` with a rename naming `durationSeconds`', () => { + const result = ServiceLevelObjectiveSchema.safeParse({ ...sloBase, period: { type: 'rolling', durationSeconds: 2592000 }, errorBudget: { burnRateWindows: [{ window: 3600, threshold: 14.4 }] }, }); - expect(slo.errorBudget?.burnRateWindows?.[0]?.window).toBe(3600); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'errorBudget.burnRateWindows.0.window', + ); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/was renamed to\s+`durationSeconds`/s); + // Why not the mechanical `windowSeconds`. + expect(issue!.message).toContain('burnRateWindows'); + }); + + it('REFUSES `MetricExportConfig.interval` with a rename naming `intervalSeconds`', () => { + const result = MetricExportConfigSchema.safeParse({ type: 'prometheus', 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).toMatch( + /`MetricExportConfig\.interval` was renamed.*Rename the key to `intervalSeconds`/s, + ); + }); + + it('REFUSES `MetricsConfig.collectionInterval` with a rename naming the qualified key', () => { + const result = MetricsConfigSchema.safeParse({ + name: 'm', label: 'M', collectionInterval: 15, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'collectionInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /was renamed to `collectionIntervalSeconds`/s, + ); + // Why the qualifier is kept rather than reusing the exporter's own key. + expect(issue!.message).toContain('MetricExportConfig.intervalSeconds'); + }); + + it('REFUSES `MetricsConfig.retention.period` with a rename naming `durationSeconds`', () => { + const result = MetricsConfigSchema.safeParse({ + name: 'm', label: 'M', retention: { period: 604800 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retention.period'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`MetricsConfig\.retention\.period` was renamed.*Rename the key to `durationSeconds`/s, + ); + // Why not the mechanical `periodSeconds`. + expect(issue!.message).toContain('calendar vocabulary'); + }); + + it('accepts every renamed key at the magnitude the retired one carried', () => { + expect(MetricDefinitionSchema.parse({ + name: 'response_time', type: 'summary', summary: { maxAgeSeconds: 600 }, + }).summary?.maxAgeSeconds).toBe(600); + + const slo = ServiceLevelObjectiveSchema.parse({ + ...sloBase, + period: { type: 'rolling', durationSeconds: 2592000 }, + errorBudget: { burnRateWindows: [{ durationSeconds: 3600, threshold: 14.4 }] }, + }); + expect(slo.errorBudget?.burnRateWindows?.[0]?.durationSeconds).toBe(3600); + expect(slo.errorBudget?.burnRateWindows?.[0]).not.toHaveProperty('window'); + + expect(MetricExportConfigSchema.parse({ type: 'http', intervalSeconds: 30 }) + .intervalSeconds).toBe(30); + expect(MetricsConfigSchema.parse({ + name: 'm', label: 'M', collectionIntervalSeconds: 30, retention: { durationSeconds: 86400 }, + }).retention?.durationSeconds).toBe(86400); + }); + + it('keeps every default the retired keys carried', () => { + expect(MetricDefinitionSchema.parse({ name: 'r', type: 'summary', summary: {} }) + .summary?.maxAgeSeconds).toBe(600); + expect(MetricExportConfigSchema.parse({ type: 'prometheus' }).intervalSeconds).toBe(60); + const config = MetricsConfigSchema.parse({ name: 'm', label: 'M', retention: {} }); + expect(config.collectionIntervalSeconds).toBe(15); + expect(config.retention?.durationSeconds).toBe(604800); + }); + + it('publishes the unit in every describe — the text the reference pages render', () => { + const summary = MetricDefinitionSchema.shape.summary.unwrap(); + expect(summary.shape.maxAgeSeconds.description).toBe('Max age of observations in seconds'); + expect(MetricExportConfigSchema.shape.intervalSeconds.description) + .toBe('Export interval in seconds'); + expect(MetricsConfigSchema.shape.collectionIntervalSeconds.description) + .toBe('Collection interval in seconds'); + expect(MetricsConfigSchema.shape.retention.unwrap().shape.durationSeconds.description) + .toBe('Retention duration in seconds'); + const burn = ServiceLevelObjectiveSchema.shape.errorBudget.unwrap() + .shape.burnRateWindows.unwrap().element; + expect(burn.shape.durationSeconds.description).toBe('Window duration in seconds'); + }); + + it('leaves the three same-named decoys on this file alone', () => { + // All three are `z.object({ … })`, not duration numbers, and two of them + // already hold a `durationSeconds` of their own from #15679. + 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); }); }); From 7f37084e8bf71e668f5e3b36e4fdb6cf216fd1dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 11:14:42 +0000 Subject: [PATCH 3/6] feat(spec)!: metrics duration keys carry their unit in the key name The five durations on system/metrics.zod.ts whose unit lived in a source JSDoc only now carry it in the key name, and each published describe states it: summary.maxAge -> maxAgeSeconds, errorBudget.burnRateWindows[].window -> durationSeconds, MetricExportConfig.interval -> intervalSeconds, collectionInterval -> collectionIntervalSeconds and retention.period -> durationSeconds. Every value and default is unchanged. Each old spelling is a retiredKey() tombstone carrying the FROM -> TO prescription; none of the five enclosing shapes is strict, so a bare deletion would have stripped in silence. One ADR-0087 D3 semantic entry plus five RETIRED_KEYS_BY_MAJOR[18] rows. Two of the five are top level, so the authorable-surface ratchet moves for those two only. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- ...783-metrics-jsdoc-durations-unit-in-key.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .changeset/17783-metrics-jsdoc-durations-unit-in-key.md diff --git a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md new file mode 100644 index 0000000000..b3867a29a1 --- /dev/null +++ b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md @@ -0,0 +1,103 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the five `system/metrics.zod.ts` durations carry their unit in the key name (#17783, ruling A on #15939) + + + +**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`, the seconds suffixes run `Seconds` 40, `Sec` 1, `S` 0. + +## 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 From e70404cdfb432ac27e306861d8ee03f380b951e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 12:40:50 +0000 Subject: [PATCH 4/6] docs(spec): state the measurement ref beside every count in the metrics migration prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A count without a ref is a claim a future reader cannot check — the defect class this epic exists to remove. The seconds-suffix family counts in the ADR-0087 semantic entry and the changeset now name the sha they were taken at, and the two bare *S keys the corpus contains are named so the zero is falsifiable. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- .../17783-metrics-jsdoc-durations-unit-in-key.md | 5 ++++- ...8.system-metrics-jsdoc-durations-unit-in-key.ts | 14 +++++++++----- packages/spec/src/migrations/registry.ts | 14 +++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md index b3867a29a1..4e5525fe7c 100644 --- a/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md +++ b/.changeset/17783-metrics-jsdoc-durations-unit-in-key.md @@ -83,7 +83,10 @@ 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`, the seconds suffixes run `Seconds` 40, `Sec` 1, `S` 0. +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 diff --git a/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts index 1b06f7c740..b29ceee97e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts @@ -50,8 +50,11 @@ export const entry: SemanticMigration = { + '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 the ' + 'sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued ' - + 'cadences already carry. Counted on this tree, key-position seconds suffixes across ' - + 'packages/spec/src run Seconds 40, Sec 1 and S 0, so Seconds is the family. All five are ' + + '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 — so Seconds is the family; this change takes Seconds ' + + 'to 45 at 9b62f54671. All five are ' + 'retiredKey() tombstones; none of the five enclosing shapes is 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 a metric definition, an SLO, an export config ' @@ -61,12 +64,13 @@ export const entry: SemanticMigration = { + 'distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, ' + 'collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the ' + 'generated content/docs/references/system/metrics.mdx, which this rename regenerates, ' - + 'against a lit control of 1195 defineStack occurrences on the same corpus; and the objectui ' + + 'against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 ' + + '(1195 again at 9b62f54671); and the objectui ' + 'checkout this repo builds against — this is the pin, ' + '`.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, re-read from this tree — ' + 'spells all six metrics def names and both distinctive keys 0 times across 6409 tracked ' - + 'files, against lit controls window 2710, timeout 832, period 160, interval 156 and ' - + 'metrics 301 on that same corpus, so no pin bump is owed. ' + + 'files at that sha, against lit controls window 2710, timeout 832, period 160, ' + + 'interval 156 and metrics 301 on that same corpus and sha, so no pin bump is owed. ' + '#15939, #15679, #14478, ADR-0087.', acceptanceCriteria: 'Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1f7bcda159..a813c592ba 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10424,8 +10424,11 @@ const step18: MigrationStep = { + '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 the ' + 'sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued ' - + 'cadences already carry. Counted on this tree, key-position seconds suffixes across ' - + 'packages/spec/src run Seconds 40, Sec 1 and S 0, so Seconds is the family. All five are ' + + '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 — so Seconds is the family; this change takes Seconds ' + + 'to 45 at 9b62f54671. All five are ' + 'retiredKey() tombstones; none of the five enclosing shapes is 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 a metric definition, an SLO, an export config ' @@ -10435,12 +10438,13 @@ const step18: MigrationStep = { + 'distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, ' + 'collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the ' + 'generated content/docs/references/system/metrics.mdx, which this rename regenerates, ' - + 'against a lit control of 1195 defineStack occurrences on the same corpus; and the objectui ' + + 'against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 ' + + '(1195 again at 9b62f54671); and the objectui ' + 'checkout this repo builds against — this is the pin, ' + '`.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694`, re-read from this tree — ' + 'spells all six metrics def names and both distinctive keys 0 times across 6409 tracked ' - + 'files, against lit controls window 2710, timeout 832, period 160, interval 156 and ' - + 'metrics 301 on that same corpus, so no pin bump is owed. ' + + 'files at that sha, against lit controls window 2710, timeout 832, period 160, ' + + 'interval 156 and metrics 301 on that same corpus and sha, so no pin bump is owed. ' + '#15939, #15679, #14478, ADR-0087.', acceptanceCriteria: 'Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window ' From bcfdd35609c5d880044a17d64ba44d7c1798d866 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 12:41:46 +0000 Subject: [PATCH 5/6] Merge origin/main and regenerate the authorable-surface shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second lander on packages/spec/authorable-surface/system.json and authorable-defaults/system.json after #17999 (#17782, logging). Both shards were merged by the os-regen driver, so step 2 took main's side and this commit regenerates them from the merged source — the only way both cards' rows are proven present rather than assumed. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- packages/spec/authorable-defaults/system.json | 2 +- packages/spec/authorable-surface/system.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index b83867f5a5..c7b23b1deb 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -107,7 +107,7 @@ "system/FileDestinationConfig:append = true", "system/FileDestinationConfig:encoding = \"utf8\"", "system/HttpDestinationConfig:method = \"POST\"", - "system/HttpDestinationConfig:timeout = 30000", + "system/HttpDestinationConfig:timeoutMs = 30000", "system/Job:enabled = true", "system/KeyRotationPolicy:autoRotate = true", "system/KeyRotationPolicy:enabled = false", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 7da9681fc8..c4df41b424 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -460,7 +460,8 @@ "system/HttpDestinationConfig:headers", "system/HttpDestinationConfig:method", "system/HttpDestinationConfig:retry", - "system/HttpDestinationConfig:timeout", + "system/HttpDestinationConfig:timeout [RETIRED]", + "system/HttpDestinationConfig:timeoutMs", "system/HttpDestinationConfig:url", "system/IntervalSchedule:intervalMs", "system/IntervalSchedule:type", From 84e62ed7129e7cd6e1b6a307530cdf4f9d3602a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:00:16 +0000 Subject: [PATCH 6/6] docs(spec): point #15679's metrics entry at the successor that renames the burn-rate window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on PR #18007 ruled that the "Both keep their names" sentence in `system-metrics-window-durations-unit-in-key`'s acceptanceCriteria must gain a pointer. That field's contract is "how the consumer proves the hand-migration correct" — it renders as "Done when:" in the upgrade guide and "verify:" in `migrate meta` — so a normative sentence saying the burn-rate window keeps its name does not merely go stale once the key is tombstoned, it instructs a reviewer that a correct sweep was an error. Not a rewrite: every existing word is left in place and the clause is appended, per Prime Directive #13. Nor is it amending published history — the protocol-18 step is unreleased and still assembling (PROTOCOL_VERSION is 17.0.0, the guide ends at 16 to 17, and both entry ids render nowhere today), so this finishes the step rather than editing a shipped record. Claude-Session: https://claude.ai/code/session_015c5G6TmpMKgnusmTpD7Ntt Co-authored-by: Claude --- .../18.system-metrics-window-durations-unit-in-key.ts | 10 +++++++++- packages/spec/src/migrations/registry.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) 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 index 82af569b9a..658c00914a 100644 --- 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 @@ -42,5 +42,13 @@ export const entry: SemanticMigration = { + '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.', + + 'to carry. Both keep their names. ' + // Pointer, not a rewrite (Prime Directive #13): the sentence above is #15679's, left + // word for word. The burn-rate window it names is renamed in this same, still-unreleased + // protocol-18 step, so a reader arriving here through `migrate meta` needs the successor. + + 'One of those two moves after all, in this same protocol step: the error-budget ' + + 'burn-rate window is renamed to durationSeconds by ' + + 'system-metrics-jsdoc-durations-unit-in-key, the remediation of the JSDoc-channel gap ' + + '#15939 named just above. Read that entry with this one; the exporter batch size is ' + + 'still a COUNT of records and still does not move.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c2c1e621a6..2376368498 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10671,7 +10671,15 @@ const step18: MigrationStep = { + '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.', + + 'to carry. Both keep their names. ' + // Pointer, not a rewrite (Prime Directive #13): the sentence above is #15679's, left + // word for word. The burn-rate window it names is renamed in this same, still-unreleased + // protocol-18 step, so a reader arriving here through `migrate meta` needs the successor. + + 'One of those two moves after all, in this same protocol step: the error-budget ' + + 'burn-rate window is renamed to durationSeconds by ' + + 'system-metrics-jsdoc-durations-unit-in-key, the remediation of the JSDoc-channel gap ' + + '#15939 named just above. Read that entry with this one; the exporter batch size is ' + + 'still a COUNT of records and still does not move.', }, { id: 'system-object-storage-durations-unit-in-key',