From e22957d19d0f161c3df7476f74bfaaa4ed5bd1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:08:49 +0000 Subject: [PATCH 01/33] feat(spec): declare the two duration-rule exemptions on the schema (#15676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling B on #14478 exempts two structural classes from the duration-unit rule, and is explicit that both are declared ON THE SCHEMA, never in a gate ledger. This commit lands the declaration channels themselves: - `EpochMs` (`packages/spec/src/shared/epoch.zod.ts`) — the shared epoch-milliseconds instant. A key whose value IS this schema is an instant, not a duration, and `check:duration-unit-keys` recognises that structurally. - `.meta({ externalVocabulary: '' })` — the marker a key carries when it mirrors a name fixed outside this repo. It rides `z.toJSONSchema` verbatim, the same channel `xRef` / `xExpression` already use. Neither exemption is a pass on lying: a marked key still fails `name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit other than milliseconds fails the new `instant-unit-contradicts-schema`. Both classes stay visible in the census — `--list` marks them and the verdict line counts them. The gate also now reads `description` out of `.meta()`. Without it, moving a describe into `.meta({ description })` would take a key out of the population silently — an exemption by blindness. Measured: one numeric key declares its description that way today (`data/Field.precision`), naming no time unit, so the reading adds no offender. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../spec/scripts/check-duration-unit-keys.ts | 289 +++++++++++++++++- packages/spec/src/shared/epoch.zod.ts | 51 ++++ packages/spec/src/shared/index.ts | 5 + 3 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 packages/spec/src/shared/epoch.zod.ts diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index 5d7306265c..fe98c8fae5 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -66,6 +66,43 @@ * talking about time. `--list` still prints the unit-nowhere keys so the * population stays visible; closing it is a describe-by-describe decision. * + * ## The two exemptions, DECLARED ON THE SCHEMA (#15676, ruling B) + * + * The rule governs every authored and every runtime-emitted duration MINUS two + * structural classes, and the ruling is explicit about the mechanism: they are + * "declared ON THE SCHEMA, never in a gate ledger". So neither of them appears + * in this file as a key, a path or a name. What appears here is the ability to + * READ a declaration the schema itself carries. + * + * 1. **Epoch instants** — a key whose value IS the shared {@link INSTANT_ROOT} + * schema (`EpochMs`, `src/shared/epoch.zod.ts`) is an INSTANT, not a + * duration. An instant is numerically the same shape and its describe names + * the same unit, but it is a different confusion: renaming `startTime` to + * `startTimeMs` would move it into the `*Ms` DURATION family (measured on + * this package's authorable surface: all 51 distinct `*Ms` keys are + * durations, all 51 distinct `*At` keys are instants), which is the opposite + * of what the rule is for. The instant is spelled `*At` and typed `EpochMs`. + * + * 2. **External-standard mirrors** — a key that carries + * `.meta({ externalVocabulary: '' })` mirrors a name fixed + * outside this repo (`max-age` from HTTP Cache-Control, `statement_timeout` + * from PostgreSQL, better-auth's option names). Renaming it would break the + * correspondence that makes it readable. The marker rides `z.toJSONSchema` + * verbatim — the same channel `xRef` / `xExpression` / `xEnumDeprecated` use + * — so the reference page prints the unit as "per the named standard" + * (`scripts/lib/schema-section.ts`) instead of the reader having to guess. + * + * ⛔ Neither exemption is a pass on lying. A marked key still fails + * `name-unit-contradicts-prose` (a marker waives the RENAME, never a + * contradiction), and an `EpochMs` key whose describe names a unit other than + * milliseconds fails `instant-unit-contradicts-schema` — the schema says + * milliseconds, so prose that says seconds is one of the two being wrong. A + * declaration that could never be refused is an allowlist wearing a `.meta()`. + * + * Both classes stay VISIBLE in the census: `--list` marks them and the verdict + * line counts them. An exemption nobody can see is the ledger this ruling + * refused. + * * ## No baseline, by ruling * * Triage proposed a ratchet from the day's count with the existing keys @@ -179,6 +216,29 @@ const DURATION_SHAPED_TOKENS = new Set([ const NUMERIC_ROOTS = new Set(['z.number', 'z.int', 'z.coerce.number']); +/** + * The shared epoch-instant schema — exemption class (i), read from the SOURCE + * TEXT as the identifier a property's value chain is rooted at. + * + * Recognised by NAME rather than by resolving the import, for the same reason + * the whole file is a syntactic scan: a detector with no module resolution + * cannot fail to resolve in CI. The coupling that keeps the name honest is a + * self-test case which reads `src/shared/epoch.zod.ts` and asserts it really + * exports this symbol — so renaming the schema without renaming it here is RED, + * not a silently-empty exemption. + */ +const INSTANT_ROOT = 'EpochMs'; +/** Where {@link INSTANT_ROOT} is declared — read by the self-test, not by the scan. */ +const INSTANT_ROOT_MODULE = 'src/shared/epoch.zod.ts'; + +/** + * The `.meta()` key that declares exemption class (ii). A key carrying it + * mirrors a name fixed by an external standard, so the RENAME is waived — never + * the contradiction check, and never the requirement that the describe still + * state the unit. + */ +const EXTERNAL_VOCABULARY_META_KEY = 'externalVocabulary'; + export interface DurationKey { file: string; line: number; @@ -191,11 +251,18 @@ export interface DurationKey { /** true when a sibling `unit` key sits on the same object literal */ valueUnitPair: boolean; durationShaped: boolean; + /** true when the value chain is rooted at the shared `EpochMs` schema — exemption (i) */ + instant: boolean; + /** the standard named by `.meta({ externalVocabulary })`, when one is declared — exemption (ii) */ + externalVocabulary: string | undefined; } export interface Finding { site: DurationKey; - rule: 'unit-in-prose-not-in-name' | 'name-unit-contradicts-prose'; + rule: + | 'unit-in-prose-not-in-name' + | 'name-unit-contradicts-prose' + | 'instant-unit-contradicts-schema'; message: string; } @@ -237,19 +304,57 @@ export function isDurationShaped(key: string): boolean { // ── AST ──────────────────────────────────────────────────────────────────── -/** Walk a `z.x().y().z()` chain to its root; return the root's dotted name and every `.describe()` string. */ -function chainInfo(expr: ts.Expression): { root: string | undefined; describes: string[] } { +/** + * Walk a `z.x().y().z()` chain to its root. + * + * Returns the root's dotted name (`z.number`, `z.coerce.number`) OR, when the + * chain bottoms out at a plain identifier, that identifier — which is how a key + * declared as `EpochMs` / `EpochMs.optional().describe(…)` is recognised as + * exemption class (i) rather than vanishing from the population as an + * unresolvable root. Every OTHER identifier root (`PositiveInt.describe(…)`) + * stays outside the population exactly as before: `collectDurationKeys` admits + * only the roots it knows. + * + * Also collects, from the same single pass: + * - every `.describe()` string; + * - `description` and `externalVocabulary` from `.meta({ … })` — `.meta()` is + * the repo's established annotation channel (`xRef`, `xExpression`, + * `xEnumDeprecated`) and it MERGES with a `.describe()` earlier in the + * chain rather than replacing it (measured against zod 4.4.3), so the two + * spellings coexist on one key. + * + * Reading `description` out of `.meta()` closes a hole rather than adding a + * feature: without it, moving a describe into `.meta({ description })` would + * take a key out of this gate's population SILENTLY — an exemption by + * blindness, which is precisely what ruling B refuses. (Measured on this tree: + * exactly one numeric key declares its description that way — `data/Field`'s + * `precision`, "Decimal precision (default: 2)" — so the reading adds no + * offender today. It stops the next one.) + */ +function chainInfo(expr: ts.Expression): { + root: string | undefined; + describes: string[]; + metaDescription: string | undefined; + externalVocabulary: string | undefined; +} { const describes: string[] = []; + let metaDescription: string | undefined; + let externalVocabulary: string | undefined; let cur: ts.Expression = expr; for (;;) { if (ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur)) { cur = cur.expression; continue; } - if (!ts.isCallExpression(cur)) return { root: undefined, describes }; + if (ts.isIdentifier(cur)) { + // A bare schema constant, or the receiver a chain bottomed out at: + // `createdAt: EpochMs` / `createdAt: EpochMs.optional()`. + return { root: cur.text, describes, metaDescription, externalVocabulary }; + } + if (!ts.isCallExpression(cur)) return { root: undefined, describes, metaDescription, externalVocabulary }; if (!ts.isPropertyAccessExpression(cur.expression)) { // `someHelper(...)` — a call whose callee is not `a.b`; not a `z.` root - return { root: undefined, describes }; + return { root: undefined, describes, metaDescription, externalVocabulary }; } const method = cur.expression.name.text; if (method === 'describe' && cur.arguments.length > 0) { @@ -257,13 +362,35 @@ function chainInfo(expr: ts.Expression): { root: string | undefined; describes: const text = concatLiteral(a); if (text !== undefined) describes.push(text); } + if (method === 'meta' && cur.arguments.length > 0) { + const a = cur.arguments[0]; + if (ts.isObjectLiteralExpression(a)) { + for (const prop of a.properties) { + if (!ts.isPropertyAssignment(prop)) continue; + const name = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name) ? prop.name.text : undefined; + if (name === undefined) continue; + // Only a non-empty STRING LITERAL declares anything. A computed value, + // a template with holes or an empty string is not a standard's name, + // and an unverifiable claim is refused rather than assumed true — so + // the key stays in the population and stays judged. + const value = concatLiteral(prop.initializer); + if (name === 'description' && value !== undefined && metaDescription === undefined) { + metaDescription = value; + } + if (name === EXTERNAL_VOCABULARY_META_KEY && value !== undefined && value.trim() !== '' + && externalVocabulary === undefined) { + externalVocabulary = value; + } + } + } + } // The callee `a.b.c` — collect its dotted parts down to whatever `a` is. const parts: string[] = []; let p: ts.Expression = cur.expression; while (ts.isPropertyAccessExpression(p)) { parts.unshift(p.name.text); p = p.expression; } if (ts.isIdentifier(p) && p.text === 'z') { // reached `z.number(...)` / `z.coerce.number(...)`: this call is the root - return { root: ['z', ...parts].join('.'), describes }; + return { root: ['z', ...parts].join('.'), describes, metaDescription, externalVocabulary }; } // otherwise `p` is the receiver of this method call — keep walking down it cur = p; @@ -290,13 +417,17 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.parent)) { const name = ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name) ? node.name.text : undefined; if (name) { - const { root, describes } = chainInfo(node.initializer); - if (root && NUMERIC_ROOTS.has(root)) { + const { root, describes, metaDescription, externalVocabulary } = chainInfo(node.initializer); + const instant = root === INSTANT_ROOT; + if (root && (NUMERIC_ROOTS.has(root) || instant)) { const siblings = node.parent.properties; const valueUnitPair = siblings.some( (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'unit', ); - const describe = describes.length ? describes[describes.length - 1] : undefined; + // An explicit `.describe()` wins over a `.meta({ description })`: it is + // what every site in this tree writes, and where a key carries both, + // the describe is the one an author reads at the declaration. + const describe = describes.length ? describes[describes.length - 1] : metaDescription; out.push({ file: fileName, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, @@ -306,6 +437,8 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey keyUnits: unitsInKey(name), valueUnitPair, durationShaped: isDurationShaped(name), + instant, + externalVocabulary, }); } } @@ -319,8 +452,35 @@ export function collectDurationKeys(fileName: string, code: string): DurationKey export function judge(site: DurationKey): Finding | undefined { if (site.valueUnitPair) return undefined; const where = `${site.file}:${site.line} \`${site.key}\``; + + // Exemption (i): the value IS the shared `EpochMs` schema, so the key is an + // INSTANT and the duration rule does not reach it. The one thing still + // refused is a describe that contradicts the schema: `EpochMs` declares + // milliseconds, so prose naming another unit means the site and the schema + // disagree, and a silent exemption there would let the declaration launder a + // real unit bug. + if (site.instant) { + if (site.proseUnits.length > 0 && !site.proseUnits.includes('ms')) { + return { + site, + rule: 'instant-unit-contradicts-schema', + message: `${where} — typed \`${INSTANT_ROOT}\` (epoch MILLISECONDS) but the describe says ` + + `${site.proseUnits.join('/')}. One of them is lying; either the describe is wrong or this is ` + + `not an epoch-millisecond instant and must not be typed \`${INSTANT_ROOT}\`.`, + }; + } + return undefined; + } + if (site.proseUnits.length > 0) { if (site.keyUnits.length === 0) { + // Exemption (ii): the key mirrors a name fixed outside this repo, declared + // on the schema with `.meta({ externalVocabulary })`. It waives the RENAME + // and nothing else — the describe must still state the unit, which is what + // put this site in `proseUnits.length > 0` in the first place, and the + // contradiction branch below is not reachable past a `return` here because + // a marked key with a unit token in its NAME never takes this branch. + if (site.externalVocabulary !== undefined) return undefined; return { site, rule: 'unit-in-prose-not-in-name', @@ -329,10 +489,16 @@ export function judge(site: DurationKey): Finding | undefined { }; } if (!site.keyUnits.some((u) => site.proseUnits.includes(u))) { + // Reached by MARKED keys too, deliberately: a marker waives the rename, + // never a contradiction. A key spelled `maxAgeMs` whose describe says + // seconds is the 1000x bug whatever standard its name mirrors. return { site, rule: 'name-unit-contradicts-prose', - message: `${where} — the key name says ${site.keyUnits.join('/')} but the describe says ${site.proseUnits.join('/')}. One of them is lying; fix whichever is wrong.`, + message: `${where} — the key name says ${site.keyUnits.join('/')} but the describe says ${site.proseUnits.join('/')}. One of them is lying; fix whichever is wrong.` + + (site.externalVocabulary !== undefined + ? ` The \`${EXTERNAL_VOCABULARY_META_KEY}\` marker waives the RENAME, never this.` + : ''), }; } return undefined; @@ -450,6 +616,79 @@ function selfTest(): number { rulesOf(`const S = z.object({ a: z.number().describe('Wait 1 second'), b: z.number().describe('A 15-minute window'), c: z.number().describe('Poll every 5 min'), d: z.number().describe('Debounce of 30 ms') });`) .join() === 'unit-in-prose-not-in-name,unit-in-prose-not-in-name,unit-in-prose-not-in-name,unit-in-prose-not-in-name'); + // ── the two DECLARED exemptions (#15676, ruling B) ─────────────────────── + // Each class is pinned in both directions: the declaration exempts, and the + // declaration does NOT exempt a contradiction. A marker that could never be + // refused would be an allowlist wearing a `.meta()`. + + expect('exempt (i): a key whose value IS `EpochMs` is an instant, not a duration', + rulesOf(`const S = z.object({ createdAt: EpochMs.describe('Unix timestamp in milliseconds when the scope was created') });`) + .join() === ''); + expect('exempt (i): a BARE `EpochMs` key (no chain at all) is an instant', + rulesOf(`const S = z.object({ createdAt: EpochMs });`) + .join() === ''); + expect('exempt (i): `EpochMs.optional()` — the exemption survives the chain', + rulesOf(`const S = z.object({ registeredAt: EpochMs.optional().describe('Unix timestamp in milliseconds when registered') });`) + .join() === ''); + expect('REFUSED (i): an `EpochMs` key whose describe names a unit other than ms → instant-unit-contradicts-schema', + rulesOf(`const S = z.object({ startedAt: EpochMs.describe('Boot timestamp in seconds') });`) + .join() === 'instant-unit-contradicts-schema'); + expect('the instant exemption is `EpochMs` ALONE — another identifier root stays outside the population', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ startedAt: SomeOtherSchema.describe('Boot timestamp in seconds') });`); + return sites.length === 0; + })()); + expect('an `EpochMs` site is COUNTED in the census, not vanished from it', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ createdAt: EpochMs.describe('Unix timestamp in milliseconds') });`); + return sites.length === 1 && sites[0].instant && sites[0].proseUnits.join() === 'ms'; + })()); + + expect('exempt (ii): `.meta({ externalVocabulary })` waives the rename on a bare-named mirror', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) + .join() === ''); + expect('exempt (ii): the marker rides in a `.meta()` that also carries description/title', + rulesOf(`const S = z.object({ statementTimeout: z.number().int().positive().optional().describe('Abort statements running longer than this (ms)').meta({ title: 'Statement timeout (ms)', externalVocabulary: 'PostgreSQL statement_timeout' }) });`) + .join() === ''); + expect('REFUSED (ii): a MARKED key whose name-unit contradicts its describe is still an offender', + rulesOf(`const S = z.object({ maxAgeMs: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'HTTP Cache-Control max-age (RFC 9111)' }) });`) + .join() === 'name-unit-contradicts-prose'); + expect('REFUSED (ii): an EMPTY marker declares no standard and exempts nothing', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: '' }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('REFUSED (ii): a non-literal marker value is unverifiable and exempts nothing', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: SOME_CONST }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('REFUSED (ii): a marker is not a licence to drop the unit from the describe — an unmarked sibling still fails', + rulesOf(`const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'RFC 9111' }), ttl: z.number().describe('TTL in seconds') });`) + .join() === ',unit-in-prose-not-in-name'); + expect('a marked site is COUNTED in the census with its standard, not vanished from it', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ maxAge: z.number().describe('Maximum cache age in seconds').meta({ externalVocabulary: 'RFC 9111' }) });`); + return sites.length === 1 && sites[0].externalVocabulary === 'RFC 9111' && sites[0].proseUnits.join() === 'seconds'; + })()); + + expect('a describe declared through `.meta({ description })` is READ — no exemption by blindness', + rulesOf(`const S = z.object({ timeout: z.number().meta({ description: 'Timeout in milliseconds' }) });`) + .join() === 'unit-in-prose-not-in-name'); + expect('an explicit `.describe()` wins over a `.meta({ description })` on the same key', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ ttl: z.number().describe('Cache TTL in seconds').meta({ description: 'Cache TTL in milliseconds' }) });`); + return sites.length === 1 && sites[0].describe === 'Cache TTL in seconds' && judge(sites[0])?.rule === 'unit-in-prose-not-in-name'; + })()); + + // The instant exemption names a schema by IDENTIFIER, because this file is a + // syntactic scan with no module resolution. That is only honest while the + // identifier really is exported from where it says — otherwise the exemption + // would be silently empty and every instant would read as an offender (or, + // after a rename in the other direction, an unrelated local could inherit the + // exemption). Held from this side, the same coupling ROOT_DIR_WATCH_HINTS has. + expect(`\`${INSTANT_ROOT}\` is exported from \`${INSTANT_ROOT_MODULE}\``, + (() => { + const src = readFileSync(join(pkgRoot, INSTANT_ROOT_MODULE), 'utf8'); + return new RegExp(`export const ${INSTANT_ROOT}\\b`).test(src); + })()); + // The declared population must be the population the scan reads (the // ROOT_DIR_WATCH_HINTS idiom's coupling, held from this side). const repoRoot = join(pkgRoot, '..', '..'); @@ -474,24 +713,44 @@ function main(argv: string[]): number { const { sites, findings, files } = scanTree(root ? resolve(root) : undefined); const durationSites = sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0); + // The two DECLARED exemptions, counted rather than hidden. A key exempted by + // a declaration stays in the census and stays countable — that is what makes + // the exemption reviewable at a glance and keeps it from becoming the ledger + // ruling B refused. Counted over the same `durationSites` population the + // verdict line reports, so the three numbers add up on the page. + const instants = durationSites.filter((s) => s.instant); + const mirrors = durationSites.filter((s) => !s.instant && s.externalVocabulary !== undefined); + const exemptions = `${instants.length} declared \`${INSTANT_ROOT}\` instant(s), ` + + `${mirrors.length} declared \`${EXTERNAL_VOCABULARY_META_KEY}\` mirror(s)`; + if (argv.includes('--list')) { for (const s of durationSites) { - console.log(`${s.file}:${s.line} ${s.key} [name: ${s.keyUnits.join('/') || '-'}] [prose: ${s.proseUnits.join('/') || '-'}]${s.valueUnitPair ? ' [value/unit pair]' : ''} ${JSON.stringify(s.describe ?? null)}`); + const marks = [ + s.valueUnitPair ? ' [value/unit pair]' : '', + s.instant ? ` [instant: ${INSTANT_ROOT}]` : '', + s.externalVocabulary !== undefined ? ` [${EXTERNAL_VOCABULARY_META_KEY}: ${s.externalVocabulary}]` : '', + ].join(''); + console.log(`${s.file}:${s.line} ${s.key} [name: ${s.keyUnits.join('/') || '-'}] [prose: ${s.proseUnits.join('/') || '-'}]${marks} ${JSON.stringify(s.describe ?? null)}`); } - console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all.`); + console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all; ${exemptions}.`); } if (findings.length === 0) { - console.log(`✓ check:duration-unit-keys — ${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`); zero offenders, no baseline.`); + console.log(`✓ check:duration-unit-keys — ${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s) all carry their unit in the key name (or in a sibling \`unit\`, or under a declared exemption: ${exemptions}); zero offenders, no baseline.`); return 0; } - console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} duration-shaped numeric key(s) in ${files} source file(s):\n`); + console.error(`✗ check:duration-unit-keys — ${findings.length} offender(s) among ${durationSites.length} duration-shaped numeric key(s) in ${files} source file(s) (${exemptions}):\n`); for (const f of findings) console.error(` [${f.rule}] ${f.message}`); console.error( '\nThe unit of a duration-shaped number lives in the KEY NAME (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`)' + ' or in a unit-carrying VALUE (a duration literal, or a `{ value, unit }` pair) — never only in the describe prose,' + ' and never nowhere. There is no baseline: a published key is renamed under an ADR-0087 conversion (registry entry +' - + ' a loud refusal of the old spelling naming the new key); see the header of this script.', + + ' a loud refusal of the old spelling naming the new key); see the header of this script.' + + '\n\nTwo structural classes are exempt, and both are DECLARED ON THE SCHEMA — there is no list to add a key to:' + + `\n - an epoch INSTANT is typed \`${INSTANT_ROOT}\` (\`${INSTANT_ROOT_MODULE}\`) and named \`*At\`;` + + `\n - a key mirroring a name fixed outside this repo carries \`.meta({ ${EXTERNAL_VOCABULARY_META_KEY}: '' })\`,` + + ' which the reference page prints as "unit per ".' + + '\nIf the offender above is neither, it is a rename.', ); return 1; } diff --git a/packages/spec/src/shared/epoch.zod.ts b/packages/spec/src/shared/epoch.zod.ts new file mode 100644 index 0000000000..c721297ebf --- /dev/null +++ b/packages/spec/src/shared/epoch.zod.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +/** + * An INSTANT: milliseconds since the Unix epoch (`Date.now()`). + * + * ## Why this exists as a shared schema and not as a naming rule + * + * `check:duration-unit-keys` (#14478, maintainer ruling B) makes a + * duration-shaped `z.number()` carry its unit in its KEY NAME, because two + * sibling keys both spelled `ttl` in different units are indistinguishable at + * the authoring site. An epoch instant is numerically the same shape and reads + * the same way to that rule — `startTime: z.number().describe('Boot timestamp + * (ms)')` names a unit in prose and carries none in the name — but it is a + * DIFFERENT confusion, and renaming it to `startTimeMs` would resolve the wrong + * one: measured on this package's own authorable surface, all 51 distinct `*Ms` + * keys are durations (`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`, …) and + * all 51 distinct `*At` keys are instants (`createdAt`, `expiresAt`, + * `lastUsedAt`, …). Spelling an instant `*Ms` would move it INTO the duration + * family, which is the opposite of the ruling's purpose. + * + * So the exemption is a DECLARATION ON THE CONTRACT, never a gate ledger: + * a key whose value IS this schema is an instant, the gate recognises that + * structurally, and no allowlist anywhere names the key. The ruling's words: + * "epoch instants move to a shared `EpochMs` schema". + * + * ## What it declares + * + * `z.number().int()` — an integer, because `Date.now()` is one and a + * fractional epoch is a bug at the producer, not a value to carry. Sites that + * previously declared a bare `z.number()` are tightened by adopting this; the + * four that already declared `.int()` keep exactly what they had. + * + * No `.min()`: a pre-1970 instant is negative and legitimate, and inventing a + * floor here would refuse data this schema has no business judging. + * + * ## How to use it + * + * Compose it and describe the instant at the site — the site's `.describe()` + * wins over this one, and the reference page prints the site's prose: + * + * ```ts + * createdAt: EpochMs.describe('Unix timestamp in milliseconds when the scope was created'), + * registeredAt: EpochMs.optional().describe('Unix timestamp in milliseconds when the service was registered'), + * ``` + * + * Name the key `*At`. That is this package's measured convention for an + * instant, and it is what keeps an instant out of the `*Ms` duration family. + */ +export const EpochMs = z.number().int().describe('Unix timestamp in milliseconds (epoch)'); diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index 24bd628950..b939acaaff 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -33,3 +33,8 @@ export * from './resilient-fetch'; // specifiers and object fields (maintainer ruling 2026-09-02). Declared here so // `system/` and `data/` both reference one schema instead of carrying a copy. export * from './value-domain.zod'; +// [#15676] The shared epoch-milliseconds INSTANT (`EpochMs`), and the first of +// the two structural exemptions ruling B of #14478 declares ON THE SCHEMA +// rather than in a gate ledger: a key whose value is this schema is an instant, +// not a duration, and `check:duration-unit-keys` recognises it structurally. +export * from './epoch.zod'; From 414e5151c9d843c29f311c322a0a04bd9688f9f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:18:32 +0000 Subject: [PATCH 02/33] feat(spec)!: move the six epoch instants onto EpochMs and mark the external-vocabulary keys (#15676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two exemption classes ruling B declares, applied to the keys the gate lists. Instants (exemption i) — all six now typed `EpochMs`; the four whose name was bare are renamed to the `*At` instant convention, tombstoned with `retiredKey()` and registered in `RETIRED_KEYS_BY_MAJOR[18]` plus one D3 semantic entry: api/WebSocketEvent.timestamp -> occurredAt api/SimplePresenceState.lastSeen -> lastSeenAt kernel/KernelContext.startTime -> startedAt (+ TenantRuntimeContext) kernel/HealthStatus.timestamp -> checkedAt kernel/ServiceMetadata.registeredAt (already `*At`, schema only) kernel/ScopeInfo.createdAt (already `*At`, schema only) `*At` and not `*Ms`, measured rather than chosen: on this package's own authorable surface all 51 distinct `*Ms` keys are durations and all 51 distinct `*At` keys are instants, so spelling an instant `*Ms` would move it into the family the rule exists to separate it from. Semantic entries rather than D2 conversions because all four are runtime-emitted — wire payloads, a host-constructed kernel context, an emitted health report — so no conversion seam ever sees one. That is the disposition `kernel/KernelContext:previewMode` already carries on one of these defs, and what ruling B prescribes for a runtime-emitted key. External-standard mirrors (exemption ii) — eleven keys marked, not thirteen. Two of the thirteen the card attributed do not survive verification against their own schema and are left for their directory cards; the PR body records the evidence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/authorable-surface/api.json | 6 +- packages/spec/authorable-surface/kernel.json | 9 +- .../spec/json-schema.manifest/shared.json | 1 + packages/spec/scripts/lib/schema-section.ts | 34 ++++- packages/spec/src/api/http-cache.zod.ts | 20 ++- packages/spec/src/api/storage.zod.ts | 9 +- packages/spec/src/api/websocket.zod.ts | 32 ++++- packages/spec/src/data/driver/postgres.zod.ts | 4 +- packages/spec/src/kernel/context.test.ts | 8 +- packages/spec/src/kernel/context.zod.ts | 21 +++- .../kernel/preview-mode-retirement.test.ts | 4 +- .../spec/src/kernel/service-registry.zod.ts | 9 +- .../src/kernel/startup-orchestrator.test.ts | 6 +- .../src/kernel/startup-orchestrator.zod.ts | 15 ++- .../18.api__SimplePresenceState__lastSeen.ts | 15 +++ .../18.api__WebSocketEvent__timestamp.ts | 20 +++ .../18.kernel__HealthStatus__timestamp.ts | 11 ++ .../18.kernel__KernelContext__startTime.ts | 14 +++ ...kernel__TenantRuntimeContext__startTime.ts | 8 ++ .../semantic/18.epoch-instant-keys-renamed.ts | 65 ++++++++++ packages/spec/src/migrations/registry.ts | 119 ++++++++++++++++++ packages/spec/src/shared/http.zod.ts | 7 +- packages/spec/src/system/auth-config.zod.ts | 18 ++- .../spec/src/system/disaster-recovery.zod.ts | 6 +- .../spec/src/system/object-storage.zod.ts | 5 +- 25 files changed, 433 insertions(+), 33 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index f141354046..e03db2020e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1647,7 +1647,8 @@ "api/SimpleCursorPosition:recordId", "api/SimpleCursorPosition:selection", "api/SimpleCursorPosition:userId", - "api/SimplePresenceState:lastSeen", + "api/SimplePresenceState:lastSeen [RETIRED]", + "api/SimplePresenceState:lastSeenAt", "api/SimplePresenceState:metadata", "api/SimplePresenceState:status", "api/SimplePresenceState:userId", @@ -1808,8 +1809,9 @@ "api/WebSocketConfig:timeout", "api/WebSocketConfig:url", "api/WebSocketEvent:channel", + "api/WebSocketEvent:occurredAt", "api/WebSocketEvent:payload", - "api/WebSocketEvent:timestamp", + "api/WebSocketEvent:timestamp [RETIRED]", "api/WebSocketEvent:type", "api/WebSocketServerConfig:cursorSharing", "api/WebSocketServerConfig:enabled", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 7883c8928b..016597856f 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -203,10 +203,11 @@ "kernel/ExtensionPoint:type", "kernel/GetPackageRequest:id", "kernel/GetPackageResponse:package", + "kernel/HealthStatus:checkedAt", "kernel/HealthStatus:details", "kernel/HealthStatus:healthy", "kernel/HealthStatus:message", - "kernel/HealthStatus:timestamp", + "kernel/HealthStatus:timestamp [RETIRED]", "kernel/HotReloadConfig:afterReload", "kernel/HotReloadConfig:beforeReload", "kernel/HotReloadConfig:debounceDelay", @@ -240,7 +241,8 @@ "kernel/KernelContext:instanceId", "kernel/KernelContext:mode", "kernel/KernelContext:previewMode [RETIRED]", - "kernel/KernelContext:startTime", + "kernel/KernelContext:startTime [RETIRED]", + "kernel/KernelContext:startedAt", "kernel/KernelContext:version", "kernel/KernelContext:workspaceRoot", "kernel/KernelSecurityPolicy:auditLog", @@ -755,7 +757,8 @@ "kernel/TenantRuntimeContext:instanceId", "kernel/TenantRuntimeContext:mode", "kernel/TenantRuntimeContext:previewMode [RETIRED]", - "kernel/TenantRuntimeContext:startTime", + "kernel/TenantRuntimeContext:startTime [RETIRED]", + "kernel/TenantRuntimeContext:startedAt", "kernel/TenantRuntimeContext:tenantDbUrl", "kernel/TenantRuntimeContext:tenantId", "kernel/TenantRuntimeContext:tenantPlan", diff --git a/packages/spec/json-schema.manifest/shared.json b/packages/spec/json-schema.manifest/shared.json index a5350b4de9..c0a045561b 100644 --- a/packages/spec/json-schema.manifest/shared.json +++ b/packages/spec/json-schema.manifest/shared.json @@ -5,6 +5,7 @@ "shared/BaseMetadataRecord", "shared/CorsConfig", "shared/CronExpressionInput", + "shared/EpochMs", "shared/Expression", "shared/ExpressionDialect", "shared/ExpressionInput", diff --git a/packages/spec/scripts/lib/schema-section.ts b/packages/spec/scripts/lib/schema-section.ts index 4117ff4421..37f2f31bb8 100644 --- a/packages/spec/scripts/lib/schema-section.ts +++ b/packages/spec/scripts/lib/schema-section.ts @@ -229,6 +229,36 @@ function carriesDescription(shape: NestedShape): boolean { ); } +/** + * The published half of the `externalVocabulary` exemption (#15676, ruling B on + * #14478). + * + * A key that mirrors a name fixed outside this repo — `max-age` from HTTP + * Cache-Control, `statement_timeout` from PostgreSQL, better-auth's option + * names — keeps its bare name instead of gaining a `Seconds` / `Ms` suffix, and + * declares WHY on the schema with `.meta({ externalVocabulary: '' })`. + * That marker rides `z.toJSONSchema` verbatim, the same channel `xRef` / + * `xExpression` / `xEnumDeprecated` use, so it arrives here as a property of + * the JSON-Schema node. + * + * Printing it is what makes the exemption honest for the ONE reader who cannot + * see the source. `check:duration-unit-keys` exists because a bare `maxAge` + * publishes a naked number to the reference page and the reader has to guess + * whether it is seconds or milliseconds; exempting the key without publishing + * its reason would leave that reader exactly where the gate found them. With + * the standard named, the unit IS stated — by reference rather than by suffix, + * which is the whole claim the exemption rests on. + * + * Appended to the description cell rather than given a column of its own: it + * qualifies the prose already in that cell (which still states the unit), and + * eleven keys do not earn a fifth column on every table in the reference. + */ +function externalVocabularyNote(prop: any): string { + const standard = prop?.externalVocabulary; + if (typeof standard !== 'string' || standard.trim() === '') return ''; + return ` (unit per ${standard.trim()})`; +} + /** * Render one schema's section, heading included. * @@ -434,7 +464,9 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio // `\|` in a description can't decay into an escaped backslash + live // pipe), then pipes — an unescaped `|` (even inside a code span) // splits the cell. - const desc = escapeMdxDescription((prop.description || '').replace(/\n/g, ' ')) + const desc = escapeMdxDescription( + ((prop.description || '') + externalVocabularyNote(prop)).replace(/\n/g, ' '), + ) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|'); t += `| **${key}** | \`${typeStr}\` | ${isReq} | ${desc} |\n`; diff --git a/packages/spec/src/api/http-cache.zod.ts b/packages/spec/src/api/http-cache.zod.ts index eff738b9df..9590ba0155 100644 --- a/packages/spec/src/api/http-cache.zod.ts +++ b/packages/spec/src/api/http-cache.zod.ts @@ -68,9 +68,23 @@ export type CacheDirective = z.input; */ export const CacheControlSchema = lazySchema(() => z.object({ directives: z.array(CacheDirective).describe('Cache control directives'), - maxAge: z.number().optional().describe('Maximum cache age in seconds'), - staleWhileRevalidate: z.number().optional().describe('Allow serving stale content while revalidating (seconds)'), - staleIfError: z.number().optional().describe('Allow serving stale content on error (seconds)'), + // The three keys below are the camelCase of the HTTP response directives they + // carry, and the `directives` enum above spells the same names on the wire + // (`max-age`). They are `externalVocabulary` mirrors under #14478 ruling B: + // renaming them to `maxAgeSeconds` would break the correspondence that lets a + // reader match this object to the `Cache-Control` header it becomes. The + // describe still states the unit, and the reference page prints it as "unit + // per the named standard". + // + // `stale-while-revalidate` and `stale-if-error` are RFC 5861, NOT RFC 9111 — + // RFC 9111 defines neither. Attribution corrected against the directives + // themselves rather than inherited (#15676). + maxAge: z.number().optional().describe('Maximum cache age in seconds') + .meta({ externalVocabulary: 'HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)' }), + staleWhileRevalidate: z.number().optional().describe('Allow serving stale content while revalidating (seconds)') + .meta({ externalVocabulary: 'HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)' }), + staleIfError: z.number().optional().describe('Allow serving stale content on error (seconds)') + .meta({ externalVocabulary: 'HTTP Cache-Control `stale-if-error` (RFC 5861 §4)' }), })); export type CacheControl = z.input; diff --git a/packages/spec/src/api/storage.zod.ts b/packages/spec/src/api/storage.zod.ts index 3ab04f1b95..beefdbd081 100644 --- a/packages/spec/src/api/storage.zod.ts +++ b/packages/spec/src/api/storage.zod.ts @@ -41,7 +41,14 @@ export const PresignedUrlResponseSchema = lazySchema(() => BaseResponseSchema.ex fileId: z.string().describe('Temporary File ID'), method: z.enum(['PUT', 'POST']).describe('HTTP Method to use'), headers: z.record(z.string(), z.string()).optional().describe('Required headers for upload'), - expiresIn: z.number().describe('URL expiry in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): this is the AWS SDK + // presigner's own option name, carried end to end — `storage-routes.ts` + // holds it as `expiresIn`, the adapter interface takes it as + // `getSignedUrl(key, expiresIn, …)`, and `s3-storage-adapter.ts` hands it + // to `getSignedUrl(client, cmd, { expiresIn })`. Renaming only where the + // value surfaces to the client would leave one name for one number. + expiresIn: z.number().describe('URL expiry in seconds') + .meta({ externalVocabulary: 'AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)' }), }), })); diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index d8f3f2cbab..6043b75e2c 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -5,6 +5,8 @@ import { PresenceStatus } from './realtime-shared.zod'; // Re-export shared PresenceStatus for backward compatibility import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; +import { retiredKey } from '../shared/retired-key'; export { PresenceStatus } from './realtime-shared.zod'; /** @@ -470,7 +472,19 @@ export const WebSocketEventSchema = lazySchema(() => z.object({ ]).describe('Event type'), channel: z.string().describe('Channel identifier (e.g., "record.account.123", "user.456")'), payload: z.unknown().describe('Event payload data'), - timestamp: z.number().describe('Unix timestamp in milliseconds'), + // Renamed from `timestamp` and typed `EpochMs` (#15676, #14478 ruling B): an + // epoch INSTANT, not a duration. `*At` is this package's measured convention + // for an instant and `EpochMs` is where the millisecond unit is declared, so + // the unit no longer lives only in the describe prose. + occurredAt: EpochMs.describe('Unix timestamp in milliseconds when the event occurred'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + timestamp: retiredKey( + '`WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, ' + + 'which declares the epoch-millisecond unit the bare key name left to the describe ' + + 'prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`).', + ), })); export type WebSocketEvent = z.input; @@ -490,7 +504,7 @@ export type WebSocketEvent = z.input; * userId: 'user123', * userName: 'John Doe', * status: 'online', - * lastSeen: Date.now(), + * lastSeenAt: Date.now(), * metadata: { currentPage: '/dashboard' } * } * ``` @@ -499,7 +513,19 @@ export const SimplePresenceStateSchema = lazySchema(() => z.object({ userId: z.string().describe('User identifier'), userName: z.string().describe('User display name'), status: z.enum(['online', 'away', 'offline']).describe('User presence status'), - lastSeen: z.number().describe('Unix timestamp of last activity in milliseconds'), + // Renamed from `lastSeen` and typed `EpochMs` (#15676, #14478 ruling B) — an + // epoch instant, joining the `lastAccessedAt` / `lastUsedAt` family. + lastSeenAt: EpochMs.describe('Unix timestamp of last activity in milliseconds'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + lastSeen: retiredKey( + '`SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` ' + + 'schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; ' + + 'the value is unchanged (`Date.now()`). Note the neighbouring ' + + '`PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a ' + + 'different type — an ISO-8601 datetime STRING — and is untouched.', + ), metadata: z.record(z.string(), z.unknown()).optional().describe('Additional presence metadata (e.g., current page, custom status)'), })); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index cf8920b04e..ef8c798eab 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -262,9 +262,11 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( .meta({ title: 'Application name' }), /** `statement_timeout` in milliseconds — aborts any statement that runs longer. */ + // `externalVocabulary` mirror (#14478 ruling B): the camelCase of PostgreSQL's + // own `statement_timeout` parameter, which the JSDoc above names directly. statementTimeout: z.number().int().positive().optional() .describe('Abort statements running longer than this (ms)') - .meta({ title: 'Statement timeout (ms)' }), + .meta({ title: 'Statement timeout (ms)', externalVocabulary: 'PostgreSQL `statement_timeout`' }), /** Dev-only, loosen-only schema self-heal (#2186). */ autoMigrate: SqlAutoMigrateSchema.optional(), diff --git a/packages/spec/src/kernel/context.test.ts b/packages/spec/src/kernel/context.test.ts index 723fabe960..baa0c48e1a 100644 --- a/packages/spec/src/kernel/context.test.ts +++ b/packages/spec/src/kernel/context.test.ts @@ -31,7 +31,7 @@ describe('KernelContextSchema', () => { mode: 'production', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), features: {}, }; @@ -84,10 +84,10 @@ describe('KernelContextSchema', () => { expect(() => KernelContextSchema.parse({ instanceId: '550e8400-e29b-41d4-a716-446655440000' })).toThrow(); }); - it('should reject non-integer startTime', () => { + it('should reject non-integer startedAt', () => { expect(() => KernelContextSchema.parse({ ...validContext, - startTime: 1.5, + startedAt: 1.5, })).toThrow(); }); @@ -114,7 +114,7 @@ describe('TenantRuntimeContextSchema', () => { mode: 'production' as const, version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), features: {}, }; diff --git a/packages/spec/src/kernel/context.zod.ts b/packages/spec/src/kernel/context.zod.ts index a3b0731f18..f589132f50 100644 --- a/packages/spec/src/kernel/context.zod.ts +++ b/packages/spec/src/kernel/context.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { TenantQuotaSchema } from '../system/tenant.zod.js'; import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; +import { EpochMs } from '../shared/epoch.zod'; // Retirement prescriptions (#11846, ADR-0049 enforce-or-remove; maintainer // ruling 2026-08-27). Declared with `//` (never `/** */`) and ABOVE the enum's @@ -27,6 +28,14 @@ const RUNTIME_MODE_PREVIEW_RETIRED = + 'job (`OS_PREVIEW_MODE` is routing-only and never touched identity). If a preview ' + 'experience becomes a product capability it re-declares fresh, with the ' + 'production-posture hard-refusal as the first-landed half.'; +const START_TIME_RENAMED = + '`context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which ' + + 'declares the epoch-millisecond unit the key name used to leave to the describe prose. ' + + 'Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather ' + + 'than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so ' + + 'spelling an instant that way would move it into the family the rule exists to ' + + 'separate it from.'; const PREVIEW_MODE_RETIRED = '`context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, ' @@ -103,7 +112,10 @@ export const KernelContextSchema = lazySchema(() => z.object({ /** * Telemetry */ - startTime: z.number().int().describe('Boot timestamp (ms)'), + // Renamed from `startTime` and typed `EpochMs` (#15676, #14478 ruling B): the + // boot INSTANT. Spelling it `startTimeMs` would have moved it into the `*Ms` + // duration family, which is the confusion the ruling separates. + startedAt: EpochMs.describe('Boot timestamp — Unix milliseconds'), /** * Feature Flags (Global) @@ -120,6 +132,13 @@ export const KernelContextSchema = lazySchema(() => z.object({ * inherits the tombstone. */ previewMode: retiredKey(PREVIEW_MODE_RETIRED), + + /** + * Tombstone for the epoch-instant rename (#15676, ruling B on #14478). + * `TenantRuntimeContextSchema` extends this shape and inherits it, which is + * why the retirement is registered under BOTH def keys. + */ + startTime: retiredKey(START_TIME_RENAMED), })); export type KernelContext = z.input; diff --git a/packages/spec/src/kernel/preview-mode-retirement.test.ts b/packages/spec/src/kernel/preview-mode-retirement.test.ts index 683dfe71b0..a2781e69fb 100644 --- a/packages/spec/src/kernel/preview-mode-retirement.test.ts +++ b/packages/spec/src/kernel/preview-mode-retirement.test.ts @@ -87,7 +87,7 @@ describe("[#11846] RuntimeMode 'preview' retirement", () => { mode: 'preview', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), }); expect(result.success).toBe(false); if (result.success) return; @@ -113,7 +113,7 @@ describe('[#11846] KernelContext.previewMode retirement', () => { mode: 'production', version: '1.0.0', cwd: '/app', - startTime: Date.now(), + startedAt: Date.now(), } as const; /** The block exactly as the retired docs taught authors to write it. */ diff --git a/packages/spec/src/kernel/service-registry.zod.ts b/packages/spec/src/kernel/service-registry.zod.ts index 56dff4cab5..32e8bcffe2 100644 --- a/packages/spec/src/kernel/service-registry.zod.ts +++ b/packages/spec/src/kernel/service-registry.zod.ts @@ -26,6 +26,7 @@ import { ServiceClusterAnnotationsSchema } from './cluster.zod'; * Different service scoping strategies */ import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; export const ServiceScopeType = z.enum([ 'singleton', // Single instance shared across the application 'transient', // New instance created each time @@ -66,7 +67,9 @@ export const ServiceMetadataSchema = lazySchema(() => z.object({ /** * Registration timestamp (Unix milliseconds) */ - registeredAt: z.number().int().optional() + // Typed `EpochMs` (#15676, #14478 ruling B) — an epoch instant, already + // correctly named `*At`, so the declaration is the whole change here. + registeredAt: EpochMs.optional() .describe('Unix timestamp in milliseconds when service was registered'), /** @@ -263,7 +266,9 @@ export const ScopeInfoSchema = lazySchema(() => z.object({ /** * Creation timestamp (Unix milliseconds) */ - createdAt: z.number().int().describe('Unix timestamp in milliseconds when scope was created'), + // Typed `EpochMs` (#15676, #14478 ruling B) — an epoch instant; no rename, + // the name already carries the `*At` instant convention. + createdAt: EpochMs.describe('Unix timestamp in milliseconds when scope was created'), /** * Number of services in this scope diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index e9b596c7a7..461a113c3d 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -49,7 +49,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate healthy status', () => { const healthyStatus = { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), details: { databaseConnected: true, memoryUsage: 45.2, @@ -63,7 +63,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate unhealthy status with message', () => { const unhealthyStatus = { healthy: false, - timestamp: Date.now(), + checkedAt: Date.now(), message: 'Database connection failed', }; @@ -111,7 +111,7 @@ describe('Startup Orchestrator Protocol', () => { duration: 1250, health: { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }, }; diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 0de4aa3118..5bf90abcca 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -29,6 +29,8 @@ import { z } from 'zod'; * } */ import { lazySchema } from '../shared/lazy-schema'; +import { EpochMs } from '../shared/epoch.zod'; +import { retiredKey } from '../shared/retired-key'; export const StartupOptionsSchema = lazySchema(() => z.object({ /** * Maximum time (ms) to wait for each plugin to start @@ -95,7 +97,18 @@ export const HealthStatusSchema = lazySchema(() => z.object({ /** * Health check timestamp (Unix milliseconds) */ - timestamp: z.number().int().describe('Unix timestamp in milliseconds when health check was performed'), + // Renamed from `timestamp` and typed `EpochMs` (#15676, #14478 ruling B): the + // instant the health check ran, named for what it marks. + checkedAt: EpochMs.describe('Unix timestamp in milliseconds when health check was performed'), + + /** Tombstone for the rename above (#15676, ruling B on #14478). */ + timestamp: retiredKey( + '`HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` ' + + 'schema, which declares the epoch-millisecond unit the bare key name left to the ' + + 'describe prose. Rename the key to `checkedAt`; the value is unchanged ' + + '(`Date.now()`).', + ), /** * Optional health details (plugin-specific) diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts new file mode 100644 index 0000000000..2b1d3654f8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__SimplePresenceState__lastSeen.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. +// `SimplePresenceState.lastSeen` is an epoch INSTANT: it moved onto the shared +// `EpochMs` schema and was renamed `lastSeenAt`, joining this package's +// `lastAccessedAt` / `lastUsedAt` family. +// +// ⚠️ Not to be confused with `api/PresenceState:lastSeen` +// (`api/realtime-shared.zod.ts`), a DIFFERENT key of a different type — an +// ISO-8601 datetime string — which is untouched and stays live. +// +// Semantic entry rather than a D2 conversion, and registered under 18 rather +// than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry +// records: a presence payload is runtime-emitted, never a stored metadata row. +export const entry = 'api/SimplePresenceState:lastSeen'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts new file mode 100644 index 0000000000..94fa184891 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketEvent__timestamp.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. `WebSocketEvent.timestamp` +// is an epoch INSTANT, not a duration: it moved onto the shared `EpochMs` schema +// (which declares the millisecond unit) and was renamed `occurredAt`, because +// every `*Ms` key in this package is a duration and spelling an instant that way +// would put it in the family the rule exists to separate it from. +// +// Registered here but NOT in `src/conversions/registry.ts`, the +// `kernel/KernelContext:previewMode` reasoning: a WebSocket event is a RUNTIME +// wire payload emitted by the transport, never a stack collection member and +// never stored as a `sys_metadata` row, so a MetadataConversion would be a +// transform with no seam that ever runs. The prescription reaches consumers +// through the tombstone plus the D3 semantic entry `epoch-instant-keys-renamed` +// — which is exactly what ruling B prescribes for a runtime-emitted key. +// +// Registered under 18, not 17, for the reason the previewMode entry records: +// v17.0.0 was cut before this landed, so the change ships on the 17.x line and +// the prescription lives at the major boundary `migrate meta` users look at. +export const entry = 'api/WebSocketEvent:timestamp'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts new file mode 100644 index 0000000000..390d683ddd --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__HealthStatus__timestamp.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` +// is the instant the health check RAN: it moved onto the shared `EpochMs` schema +// and was renamed `checkedAt`, which also states what the instant marks. +// +// Semantic entry rather than a D2 conversion, and registered under 18 rather +// than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry +// records: a health report is emitted by the startup orchestrator at runtime, +// never authored into a metadata document. +export const entry = 'kernel/HealthStatus:timestamp'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts new file mode 100644 index 0000000000..cdda98d9b8 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelContext__startTime.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the epoch-instant half of #14478 ruling B. `KernelContext.startTime` +// is the boot INSTANT: it moved onto the shared `EpochMs` schema and was renamed +// `startedAt`. +// +// Semantic entry rather than a D2 conversion, the same disposition +// `kernel/KernelContext:previewMode` already carries on this very def: a kernel +// context is constructed by HOST CODE at boot — not a stack collection member +// (`PLURAL_TO_SINGULAR` has no entry for it), never stored as a `sys_metadata` +// row — so the conversion chain has no seam that would ever see one. +// +// Registered under 18, not 17, for the reason that sibling entry records. +export const entry = 'kernel/KernelContext:startTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts new file mode 100644 index 0000000000..87aea050a2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__TenantRuntimeContext__startTime.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15676 — the walked-shape copy of `kernel/KernelContext:startTime`. +// `TenantRuntimeContextSchema` extends `KernelContextSchema`, so it inherits +// both the renamed `startedAt` key and the tombstone; the authorable-surface +// ratchet records the two copies separately, so both are declared here. The +// `previewMode` retirement registered its two copies the same way. +export const entry = 'kernel/TenantRuntimeContext:startTime'; diff --git a/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts new file mode 100644 index 0000000000..6548bd6e6e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.epoch-instant-keys-renamed.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'epoch-instant-keys-renamed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'four epoch-instant keys whose name carried no unit: ' + + 'WebSocketEvent.timestamp, SimplePresenceState.lastSeen, ' + + 'KernelContext.startTime (inherited by TenantRuntimeContext) and ' + + 'HealthStatus.timestamp', + replacement: + 'the same instants named for what they mark and typed with the new shared ' + + 'EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt ' + + 'and checkedAt. The VALUE is unchanged in every case — still ' + + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + + 'and the declared schema move', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + + 'Epoch instants are the first class. They read to the rule exactly like ' + + 'an offending duration — a bare name plus a describe that says ' + + '"milliseconds" — but renaming them the way the rule prescribes would ' + + 'resolve the wrong confusion: measured on this package own authorable ' + + 'surface, all 51 distinct keys ending in Ms are durations (timeoutMs, ' + + 'backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At ' + + 'are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant ' + + 'with the Ms suffix would move it INTO the duration family. So the ' + + 'exemption is a declaration on the contract: the value becomes EpochMs, ' + + 'which states the epoch-millisecond unit once, and the key takes this ' + + 'package established At convention. Two of the six instants ruling B ' + + 'names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were ' + + 'already correctly named and only changed schema, so they are not ' + + 'retirements and appear in no table. A SEMANTIC entry rather than a D2 ' + + 'conversion because all four keys are RUNTIME-EMITTED — a WebSocket ' + + 'event and a presence payload are wire messages, a kernel context is ' + + 'constructed by host code at boot, a health report is emitted by the ' + + 'startup orchestrator — so none is ever stored as a sys_metadata row and ' + + 'the conversion chain has no seam that would see one. That is the same ' + + 'disposition kernel/KernelContext:previewMode already carries on one of ' + + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + + 'conversion where the key is authorable, a semantic entry where it is ' + + 'runtime-emitted. #15676, #14478, ADR-0087.', + acceptanceCriteria: + 'No producer emits the old key and no consumer reads it. All four are ' + + 'tombstoned with retiredKey(), so each fails tsc at the construction ' + + 'site (the key types never) and fails the parse with the rename ' + + 'prescription. Concretely, check four places. (1) Code building a ' + + 'WebSocketEvent: rename timestamp to occurredAt. (2) Code building a ' + + 'SimplePresenceState: rename lastSeen to lastSeenAt — and note that the ' + + 'neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a ' + + 'DIFFERENT key holding an ISO-8601 datetime string, which is untouched ' + + 'and must not be renamed with it. (3) Host boot code composing a ' + + 'KernelContext or a TenantRuntimeContext: rename startTime to startedAt. ' + + '(4) Code building a kernel HealthStatus: rename timestamp to checkedAt. ' + + 'In every case the value is carried across unchanged. One behavioural ' + + 'note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were ' + + 'declared z.number() with no integer constraint and EpochMs is ' + + 'z.number().int(), so a fractional epoch that used to parse is now ' + + 'refused at those two sites — a tightening, and Date.now() has always ' + + 'satisfied it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c998856791..cd898ad512 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6618,6 +6618,67 @@ const step18: MigrationStep = { + '(`{"address.city": …}`) needs NO action — it is deliberately not judged. Reads complete ' + 'with no `INVALID_FIELD` naming a dotted filter key, at either door.', }, + { + id: 'epoch-instant-keys-renamed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'four epoch-instant keys whose name carried no unit: ' + + 'WebSocketEvent.timestamp, SimplePresenceState.lastSeen, ' + + 'KernelContext.startTime (inherited by TenantRuntimeContext) and ' + + 'HealthStatus.timestamp', + replacement: + 'the same instants named for what they mark and typed with the new shared ' + + 'EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt ' + + 'and checkedAt. The VALUE is unchanged in every case — still ' + + 'milliseconds since the Unix epoch, still Date.now(). Only the key name ' + + 'and the declared schema move', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): a ' + + 'duration-shaped z.number() carries its unit in the key NAME, minus two ' + + 'structural classes declared ON THE SCHEMA rather than in a gate ledger. ' + + 'Epoch instants are the first class. They read to the rule exactly like ' + + 'an offending duration — a bare name plus a describe that says ' + + '"milliseconds" — but renaming them the way the rule prescribes would ' + + 'resolve the wrong confusion: measured on this package own authorable ' + + 'surface, all 51 distinct keys ending in Ms are durations (timeoutMs, ' + + 'backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At ' + + 'are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant ' + + 'with the Ms suffix would move it INTO the duration family. So the ' + + 'exemption is a declaration on the contract: the value becomes EpochMs, ' + + 'which states the epoch-millisecond unit once, and the key takes this ' + + 'package established At convention. Two of the six instants ruling B ' + + 'names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were ' + + 'already correctly named and only changed schema, so they are not ' + + 'retirements and appear in no table. A SEMANTIC entry rather than a D2 ' + + 'conversion because all four keys are RUNTIME-EMITTED — a WebSocket ' + + 'event and a presence payload are wire messages, a kernel context is ' + + 'constructed by host code at boot, a health report is emitted by the ' + + 'startup orchestrator — so none is ever stored as a sys_metadata row and ' + + 'the conversion chain has no seam that would see one. That is the same ' + + 'disposition kernel/KernelContext:previewMode already carries on one of ' + + 'these very defs, and ruling B prescribes it explicitly: an ADR-0087 ' + + 'conversion where the key is authorable, a semantic entry where it is ' + + 'runtime-emitted. #15676, #14478, ADR-0087.', + acceptanceCriteria: + 'No producer emits the old key and no consumer reads it. All four are ' + + 'tombstoned with retiredKey(), so each fails tsc at the construction ' + + 'site (the key types never) and fails the parse with the rename ' + + 'prescription. Concretely, check four places. (1) Code building a ' + + 'WebSocketEvent: rename timestamp to occurredAt. (2) Code building a ' + + 'SimplePresenceState: rename lastSeen to lastSeenAt — and note that the ' + + 'neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a ' + + 'DIFFERENT key holding an ISO-8601 datetime string, which is untouched ' + + 'and must not be renamed with it. (3) Host boot code composing a ' + + 'KernelContext or a TenantRuntimeContext: rename startTime to startedAt. ' + + '(4) Code building a kernel HealthStatus: rename timestamp to checkedAt. ' + + 'In every case the value is carried across unchanged. One behavioural ' + + 'note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were ' + + 'declared z.number() with no integer constraint and EpochMs is ' + + 'z.number().int(), so a fractional epoch that used to parse is now ' + + 'refused at those two sites — a tightening, and Date.now() has always ' + + 'satisfied it.', + }, { id: 'event-name-schema-retired', surface: @@ -9270,6 +9331,37 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // consumers through this tombstone plus the D3 semantic entry // `session-user-language-retired`. 'api/SessionUser:language', + // #15676 — the epoch-instant half of #14478 ruling B. + // `SimplePresenceState.lastSeen` is an epoch INSTANT: it moved onto the shared + // `EpochMs` schema and was renamed `lastSeenAt`, joining this package's + // `lastAccessedAt` / `lastUsedAt` family. + // + // ⚠️ Not to be confused with `api/PresenceState:lastSeen` + // (`api/realtime-shared.zod.ts`), a DIFFERENT key of a different type — an + // ISO-8601 datetime string — which is untouched and stays live. + // + // Semantic entry rather than a D2 conversion, and registered under 18 rather + // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry + // records: a presence payload is runtime-emitted, never a stored metadata row. + 'api/SimplePresenceState:lastSeen', + // #15676 — the epoch-instant half of #14478 ruling B. `WebSocketEvent.timestamp` + // is an epoch INSTANT, not a duration: it moved onto the shared `EpochMs` schema + // (which declares the millisecond unit) and was renamed `occurredAt`, because + // every `*Ms` key in this package is a duration and spelling an instant that way + // would put it in the family the rule exists to separate it from. + // + // Registered here but NOT in `src/conversions/registry.ts`, the + // `kernel/KernelContext:previewMode` reasoning: a WebSocket event is a RUNTIME + // wire payload emitted by the transport, never a stack collection member and + // never stored as a `sys_metadata` row, so a MetadataConversion would be a + // transform with no seam that ever runs. The prescription reaches consumers + // through the tombstone plus the D3 semantic entry `epoch-instant-keys-renamed` + // — which is exactly what ruling B prescribes for a runtime-emitted key. + // + // Registered under 18, not 17, for the reason the previewMode entry records: + // v17.0.0 was cut before this landed, so the change ships on the 17.x line and + // the prescription lives at the major boundary `migrate meta` users look at. + 'api/WebSocketEvent:timestamp', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in @@ -9346,6 +9438,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `${defKey}:${name}` membership per def, never by radiating from a neighbour. // See `18.integration__Connector__errorMapping.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:errorMapping', + // #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` + // is the instant the health check RAN: it moved onto the shared `EpochMs` schema + // and was renamed `checkedAt`, which also states what the instant marks. + // + // Semantic entry rather than a D2 conversion, and registered under 18 rather + // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry + // records: a health report is emitted by the startup orchestrator at runtime, + // never authored into a metadata document. + 'kernel/HealthStatus:timestamp', // #12428 — ADR-0049 enforce-or-remove, one symbol over from #12340 (PR #12425) // in the same file and on the same per-key test. `HotReloadManager.startWatching` // contained NO watcher: a guard plus `logger.info('File watching started', @@ -9409,6 +9510,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). 'kernel/KernelContext:previewMode', + // #15676 — the epoch-instant half of #14478 ruling B. `KernelContext.startTime` + // is the boot INSTANT: it moved onto the shared `EpochMs` schema and was renamed + // `startedAt`. + // + // Semantic entry rather than a D2 conversion, the same disposition + // `kernel/KernelContext:previewMode` already carries on this very def: a kernel + // context is constructed by HOST CODE at boot — not a stack collection member + // (`PLURAL_TO_SINGULAR` has no entry for it), never stored as a `sys_metadata` + // row — so the conversion chain has no seam that would ever see one. + // + // Registered under 18, not 17, for the reason that sibling entry records. + 'kernel/KernelContext:startTime', // #11332 — ADR-0049 enforce-or-remove on the plugin manifest's three dead // top-level containers (triage graded 2026-08-23; cloud leg measured clean // 2026-08-29 on #12400 with positive controls). The census found ZERO reads @@ -9905,6 +10018,12 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // precedent). See the base entry for the full record and the // no-D2-conversion reasoning. 'kernel/TenantRuntimeContext:previewMode', + // #15676 — the walked-shape copy of `kernel/KernelContext:startTime`. + // `TenantRuntimeContextSchema` extends `KernelContextSchema`, so it inherits + // both the renamed `startedAt` key and the tombstone; the authorable-surface + // ratchet records the two copies separately, so both are declared here. The + // `previewMode` retirement registered its two copies the same way. + 'kernel/TenantRuntimeContext:startTime', // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key diff --git a/packages/spec/src/shared/http.zod.ts b/packages/spec/src/shared/http.zod.ts index eea7dd0ba2..fe3b000edd 100644 --- a/packages/spec/src/shared/http.zod.ts +++ b/packages/spec/src/shared/http.zod.ts @@ -136,7 +136,12 @@ export const CorsConfigSchema = lazySchema(() => z.object({ /** * Preflight cache duration in seconds */ - maxAge: z.number().int().optional().describe('Preflight cache duration in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): this key IS the CORS + // `Access-Control-Max-Age` response header, whose value is defined in seconds + // by the standard. Renaming it to `maxAgeSeconds` would break the one-to-one + // reading between this config and the header it emits. + maxAge: z.number().int().optional().describe('Preflight cache duration in seconds') + .meta({ externalVocabulary: 'CORS `Access-Control-Max-Age` (WHATWG Fetch)' }), })); export type CorsConfig = z.input; diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index a6c71b1939..29e5b726b2 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -305,9 +305,15 @@ export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ ), minPasswordLength: z.number().optional().describe('Minimum password length (default 8)'), maxPasswordLength: z.number().optional().describe('Maximum password length (default 128)'), + // `externalVocabulary` mirror (#14478 ruling B): this object's own describe + // says its options are "forwarded to better-auth", and every sibling here is + // a better-auth option name verbatim (`disableSignUp`, + // `requireEmailVerification`, `minPasswordLength`, `autoSignIn`, + // `revokeSessionsOnPasswordReset`). A key that is forwarded by name cannot be + // renamed without breaking the forwarding. resetPasswordTokenExpiresIn: z.number().optional().describe( 'Reset-password token TTL in seconds (default 3600)' - ), + ).meta({ externalVocabulary: 'better-auth `emailAndPassword.resetPasswordTokenExpiresIn`' }), autoSignIn: z.boolean().optional().describe('Auto sign-in after sign-up (default true)'), revokeSessionsOnPasswordReset: z.boolean().optional().describe( 'Revoke all other sessions on password reset' @@ -327,9 +333,11 @@ export const EmailVerificationConfigSchema = lazySchema(() => z.object({ autoSignInAfterVerification: z.boolean().optional().describe( 'Auto sign-in the user after email verification' ), + // `externalVocabulary` mirror (#14478 ruling B) — forwarded to better-auth by + // name, as this object's own describe states. expiresIn: z.number().optional().describe( 'Verification token TTL in seconds (default 3600)' - ), + ).meta({ externalVocabulary: 'better-auth `emailVerification.expiresIn`' }), }).optional().describe('Email verification options forwarded to better-auth')); /** @@ -547,7 +555,11 @@ export const AuthConfigSchema = lazySchema(() => z.object({ providers: z.array(AuthProviderConfigSchema).optional(), plugins: AuthPluginConfigSchema.optional(), session: z.object({ - expiresIn: z.number().default(60 * 60 * 24 * 7).describe('Session duration in seconds'), + // `externalVocabulary` mirror (#14478 ruling B): better-auth's own + // `session.expiresIn` / `session.updateAge` pair, forwarded by name — the + // defaults above are that library's defaults (7 days / 1 day). + expiresIn: z.number().default(60 * 60 * 24 * 7).describe('Session duration in seconds') + .meta({ externalVocabulary: 'better-auth `session.expiresIn`' }), updateAge: z.number().default(60 * 60 * 24).describe('Session update frequency'), }).optional(), trustedOrigins: z.array(z.string()).optional().describe( diff --git a/packages/spec/src/system/disaster-recovery.zod.ts b/packages/spec/src/system/disaster-recovery.zod.ts index 6701f7b98a..0732edaea1 100644 --- a/packages/spec/src/system/disaster-recovery.zod.ts +++ b/packages/spec/src/system/disaster-recovery.zod.ts @@ -124,7 +124,11 @@ export const FailoverConfigSchema = lazySchema(() => z.object({ })).min(2).describe('Multi-region configuration (minimum 2 regions)'), /** DNS failover configuration */ dns: z.object({ - ttl: z.number().default(60).describe('DNS TTL in seconds for failover'), + // `externalVocabulary` mirror (#14478 ruling B): the DNS resource-record TTL + // field, whose unit is fixed at seconds by the standard and spelled `ttl` + // by every provider API this key is forwarded to (Route 53, Cloudflare). + ttl: z.number().default(60).describe('DNS TTL in seconds for failover') + .meta({ externalVocabulary: 'DNS resource-record TTL (RFC 1035 §4.1.3)' }), provider: z.enum(['route53', 'cloudflare', 'azure_dns', 'custom']).optional() .describe('DNS provider for automatic failover'), }).optional().describe('DNS failover settings'), diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index 0854886637..d35b46744d 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -194,7 +194,10 @@ export type ObjectMetadata = z.input; */ export const PresignedUrlConfigSchema = lazySchema(() => z.object({ operation: z.enum(['get', 'put', 'delete', 'head']).describe('Allowed operation'), - expiresIn: z.number().min(1).max(604800).describe('Expiration time in seconds (max 7 days)'), + // `externalVocabulary` mirror (#14478 ruling B): the AWS SDK presigner option + // name, and the `.max(604800)` above is that standard's own 7-day ceiling. + expiresIn: z.number().min(1).max(604800).describe('Expiration time in seconds (max 7 days)') + .meta({ externalVocabulary: 'AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)' }), contentType: z.string().optional().describe('Required content type for PUT operations'), maxSize: z.number().min(0).optional().describe('Maximum file size in bytes for PUT operations'), responseContentType: z.string().optional().describe('Override content-type for GET operations'), From 3f9544447930f1d540511a2e02312fa57bd7a313 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:24:40 +0000 Subject: [PATCH 03/33] feat(spec): publish the externalVocabulary standard on the reference page (#15676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published half of exemption (ii). A marked key keeps its bare name BECAUSE an external standard fixes it, and that argument only reaches the reference-page reader if the page names the standard — so the description cell now carries "(unit per )". Without it the exemption would leave exactly the reader `check:duration-unit-keys` was filed for where the gate found them. Also: `EpochMs` gains its type alias (the docs import-surface ratchet demands one for every documented schema) and its ADR-0122 isomorphism pin. Regenerated: json-schema.manifest/, authorable-surface/, api-surface/, export-origins/, declaration-map/, content/docs/references/**. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/api/http-cache.mdx | 18 ++-- content/docs/references/api/protocol.mdx | 6 +- content/docs/references/api/router.mdx | 2 +- content/docs/references/api/storage.mdx | 2 +- content/docs/references/api/websocket.mdx | 6 +- .../docs/references/data/driver-postgres.mdx | 2 +- content/docs/references/index.mdx | 9 +- content/docs/references/kernel/context.mdx | 6 +- .../kernel/startup-orchestrator.mdx | 10 ++- content/docs/references/shared/epoch.mdx | 32 +++++++ content/docs/references/shared/http.mdx | 2 +- content/docs/references/shared/index.mdx | 1 + content/docs/references/shared/meta.json | 1 + .../docs/references/system/auth-config.mdx | 10 +-- .../references/system/disaster-recovery.mdx | 2 +- .../docs/references/system/object-storage.mdx | 2 +- packages/spec/api-surface/shared.json | 1 + packages/spec/scripts/schema-section.test.ts | 88 +++++++++++++++++++ packages/spec/src/shared/epoch.zod.ts | 10 +++ .../src/type-alias-convention.pin.test.ts | 17 +++- 20 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 content/docs/references/shared/epoch.mdx diff --git a/content/docs/references/api/http-cache.mdx b/content/docs/references/api/http-cache.mdx index 288e6e51bc..82f081dbe3 100644 --- a/content/docs/references/api/http-cache.mdx +++ b/content/docs/references/api/http-cache.mdx @@ -59,9 +59,9 @@ const result = CacheControlSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives | -| **maxAge** | `number` | optional | Maximum cache age in seconds | -| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) | -| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) | +| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) | +| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | +| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | --- @@ -148,9 +148,9 @@ const result = CacheControlSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives | -| **maxAge** | `number` | optional | Maximum cache age in seconds | -| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) | -| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) | +| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) | +| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | +| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | --- @@ -180,9 +180,9 @@ const result = CacheControlSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives | -| **maxAge** | `number` | optional | Maximum cache age in seconds | -| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) | -| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) | +| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) | +| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | +| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5435ac2bce..665006b7d4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1207,9 +1207,9 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **directives** | `Enum<'public' \| 'private' \| 'no-cache' \| 'no-store' \| 'must-revalidate' \| 'max-age'>[]` | ✅ | Cache control directives | -| **maxAge** | `number` | optional | Maximum cache age in seconds | -| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) | -| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) | +| **maxAge** | `number` | optional | Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)) | +| **staleWhileRevalidate** | `number` | optional | Allow serving stale content while revalidating (seconds) (unit per HTTP Cache-Control `stale-while-revalidate` (RFC 5861 §3)) | +| **staleIfError** | `number` | optional | Allow serving stale content on error (seconds) (unit per HTTP Cache-Control `stale-if-error` (RFC 5861 §4)) | --- diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index 5b0e1e6219..bfe175a5e8 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -120,7 +120,7 @@ HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST- | **origins** | `string \| string[]` | optional (default: `"*"`) | Allowed origins (* for all) | | **methods** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>[]` | optional | Allowed HTTP methods | | **credentials** | `boolean` | optional (default: `false`) | Allow credentials (cookies, authorization headers) | -| **maxAge** | `integer` | optional | Preflight cache duration in seconds | +| **maxAge** | `integer` | optional | Preflight cache duration in seconds (unit per CORS `Access-Control-Max-Age` (WHATWG Fetch)) | ### Nested Shape: `RouterConfig.staticMounts[number]` diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx index 8405609097..8e9bdc5f47 100644 --- a/content/docs/references/api/storage.mdx +++ b/content/docs/references/api/storage.mdx @@ -287,7 +287,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data); | **fileId** | `string` | ✅ | Temporary File ID | | **method** | `Enum<'PUT' \| 'POST'>` | ✅ | HTTP Method to use | | **headers** | `Record` | optional | Required headers for upload | -| **expiresIn** | `number` | ✅ | URL expiry in seconds | +| **expiresIn** | `number` | ✅ | URL expiry in seconds (unit per AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)) | --- diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index d28cc178e4..5838dcb034 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -362,7 +362,8 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **userId** | `string` | ✅ | User identifier | | **userName** | `string` | ✅ | User display name | | **status** | `Enum<'online' \| 'away' \| 'offline'>` | ✅ | User presence status | -| **lastSeen** | `number` | ✅ | Unix timestamp of last activity in milliseconds | +| **lastSeenAt** | `integer` | ✅ | Unix timestamp of last activity in milliseconds | +| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 (#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. | | **metadata** | `Record` | optional | Additional presence metadata (e.g., current page, custom status) | @@ -450,7 +451,8 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **type** | `Enum<'subscribe' \| 'unsubscribe' \| 'data-change' \| 'presence-update' \| 'cursor-update' \| 'error'>` | ✅ | Event type | | **channel** | `string` | ✅ | Channel identifier (e.g., "record.account.123", "user.456") | | **payload** | `any` | ✅ | Event payload data | -| **timestamp** | `number` | ✅ | Unix timestamp in milliseconds | +| **occurredAt** | `integer` | ✅ | Unix timestamp in milliseconds when the event occurred | +| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 (#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). | --- diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index 806fd72b91..73996bc98c 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -49,7 +49,7 @@ PostgreSQL connection configuration | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | | **schema** | `string` | optional (default: `"public"`) | Default schema (knex searchPath) | | **applicationName** | `string` | optional | Postgres application_name | -| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) | +| **statementTimeout** | `integer` | optional | Abort statements running longer than this (ms) (unit per PostgreSQL `statement_timeout`) | | **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 153d797bc5..f40714bdf0 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1590 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 29 | Permission sets, row-level security, sharing rules, tenancy posture. | -| [Shared Protocol](/docs/references/shared) | 8 | 26 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | +| [Shared Protocol](/docs/references/shared) | 9 | 27 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1589** | 14 protocol modules | +| **Total** | **201** | **1590** | 14 protocol modules | --- @@ -286,13 +286,14 @@ Permission sets, row-level security, sharing rules, tenancy posture. ## Shared Protocol -**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 26 schemas** +**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **9 pages, 27 schemas** Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | File | Schemas | | :--- | :--- | | [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` | +| [`epoch.zod.ts`](/docs/references/shared/epoch) | `EpochMs` | | [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` | | [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` | | [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `MetadataItemName`, `SnakeCaseIdentifier`, `SystemIdentifier` | diff --git a/content/docs/references/kernel/context.mdx b/content/docs/references/kernel/context.mdx index bebb71cc85..96898c8f09 100644 --- a/content/docs/references/kernel/context.mdx +++ b/content/docs/references/kernel/context.mdx @@ -33,9 +33,10 @@ const result = KernelContextSchema.parse(data); | **appName** | `string` | optional | Host application name | | **cwd** | `string` | ✅ | Current working directory | | **workspaceRoot** | `string` | optional | Workspace root if different from cwd | -| **startTime** | `integer` | ✅ | Boot timestamp (ms) | +| **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | | **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | +| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 (#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | --- @@ -68,9 +69,10 @@ Tenant-aware kernel runtime context | **appName** | `string` | optional | Host application name | | **cwd** | `string` | ✅ | Current working directory | | **workspaceRoot** | `string` | optional | Workspace root if different from cwd | -| **startTime** | `integer` | ✅ | Boot timestamp (ms) | +| **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | | **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | +| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 (#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | | **tenantId** | `string` | ✅ | Resolved tenant identifier | | **tenantPlan** | `Enum<'free' \| 'pro' \| 'enterprise'>` | ✅ | Tenant subscription plan | | **tenantRegion** | `string` | optional | Tenant deployment region | diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index 356a354f61..3656c54112 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -36,7 +36,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 (#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | @@ -53,7 +54,7 @@ const result = HealthStatusSchema.parse(data); | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; timestamp: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | +| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | ### Nested Shape: `PluginStartupResult.error` @@ -69,7 +70,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | -| **timestamp** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 (#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | @@ -110,7 +112,7 @@ const result = HealthStatusSchema.parse(data); | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | -| **health** | `{ healthy: boolean; timestamp: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | +| **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | --- diff --git a/content/docs/references/shared/epoch.mdx b/content/docs/references/shared/epoch.mdx new file mode 100644 index 0000000000..0e43aebfb6 --- /dev/null +++ b/content/docs/references/shared/epoch.mdx @@ -0,0 +1,32 @@ +--- +title: Epoch +description: Epoch protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + + +**Source:** `packages/spec/src/shared/epoch.zod.ts` + + +## TypeScript Usage + +```typescript +import { EpochMs } from '@objectstack/spec/shared'; +import type { EpochMs } from '@objectstack/spec/shared'; + +// Validate data +const result = EpochMs.parse(data); +``` + +--- + +## EpochMs + +Unix timestamp in milliseconds (epoch) + +**Type:** `integer` + + +--- + diff --git a/content/docs/references/shared/http.mdx b/content/docs/references/shared/http.mdx index b7a8912f60..cb5c556062 100644 --- a/content/docs/references/shared/http.mdx +++ b/content/docs/references/shared/http.mdx @@ -36,7 +36,7 @@ const result = CorsConfigSchema.parse(data); | **origins** | `string \| string[]` | optional (default: `"*"`) | Allowed origins (* for all) | | **methods** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>[]` | optional | Allowed HTTP methods | | **credentials** | `boolean` | optional (default: `false`) | Allow credentials (cookies, authorization headers) | -| **maxAge** | `integer` | optional | Preflight cache duration in seconds | +| **maxAge** | `integer` | optional | Preflight cache duration in seconds (unit per CORS `Access-Control-Max-Age` (WHATWG Fetch)) | --- diff --git a/content/docs/references/shared/index.mdx b/content/docs/references/shared/index.mdx index 02268ead5a..446b5a5a88 100644 --- a/content/docs/references/shared/index.mdx +++ b/content/docs/references/shared/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the shared layer of ObjectStack. + diff --git a/content/docs/references/shared/meta.json b/content/docs/references/shared/meta.json index c06c4191b8..d4cbcb73ed 100644 --- a/content/docs/references/shared/meta.json +++ b/content/docs/references/shared/meta.json @@ -2,6 +2,7 @@ "title": "Shared Protocol", "pages": [ "enums", + "epoch", "expression", "http", "identifiers", diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index f8c248c004..e01734b686 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -112,7 +112,7 @@ Advanced / low-level Better-Auth options | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **expiresIn** | `number` | optional (default: `604800`) | Session duration in seconds | +| **expiresIn** | `number` | optional (default: `604800`) | Session duration in seconds (unit per better-auth `session.expiresIn`) | | **updateAge** | `number` | optional (default: `86400`) | Session update frequency | ### Nested Shape: `AuthConfig.socialProviders[string]` @@ -151,7 +151,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **requireEmailVerification** | `boolean` | optional | Require email verification before creating a session | | **minPasswordLength** | `number` | optional | Minimum password length (default 8) | | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | -| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) | +| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | | **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | @@ -162,7 +162,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **sendOnSignUp** | `boolean` | optional | Automatically send verification email after sign-up | | **sendOnSignIn** | `boolean` | optional | Send verification email on sign-in when not yet verified | | **autoSignInAfterVerification** | `boolean` | optional | Auto sign-in the user after email verification | -| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) | +| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) (unit per better-auth `emailVerification.expiresIn`) | ### Nested Shape: `AuthConfig.audience` @@ -248,7 +248,7 @@ Email and password authentication options forwarded to better-auth | **requireEmailVerification** | `boolean` | optional | Require email verification before creating a session | | **minPasswordLength** | `number` | optional | Minimum password length (default 8) | | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | -| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) | +| **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | | **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | @@ -266,7 +266,7 @@ Email verification options forwarded to better-auth | **sendOnSignUp** | `boolean` | optional | Automatically send verification email after sign-up | | **sendOnSignIn** | `boolean` | optional | Send verification email on sign-in when not yet verified | | **autoSignInAfterVerification** | `boolean` | optional | Auto sign-in the user after email verification | -| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) | +| **expiresIn** | `number` | optional | Verification token TTL in seconds (default 3600) (unit per better-auth `emailVerification.expiresIn`) | --- diff --git a/content/docs/references/system/disaster-recovery.mdx b/content/docs/references/system/disaster-recovery.mdx index b47f173d65..c3799794d0 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -212,7 +212,7 @@ Failover configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **ttl** | `number` | optional (default: `60`) | DNS TTL in seconds for failover | +| **ttl** | `number` | optional (default: `60`) | DNS TTL in seconds for failover (unit per DNS resource-record TTL (RFC 1035 §4.1.3)) | | **provider** | `Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'>` | optional | DNS provider for automatic failover | diff --git a/content/docs/references/system/object-storage.mdx b/content/docs/references/system/object-storage.mdx index f7ae80cbae..9e67cf3e5d 100644 --- a/content/docs/references/system/object-storage.mdx +++ b/content/docs/references/system/object-storage.mdx @@ -312,7 +312,7 @@ Lifecycle policy action type | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **operation** | `Enum<'get' \| 'put' \| 'delete' \| 'head'>` | ✅ | Allowed operation | -| **expiresIn** | `number` | ✅ | Expiration time in seconds (max 7 days) | +| **expiresIn** | `number` | ✅ | Expiration time in seconds (max 7 days) (unit per AWS S3 presigned URL `expiresIn` (@aws-sdk/s3-request-presigner)) | | **contentType** | `string` | optional | Required content type for PUT operations | | **maxSize** | `number` | optional | Maximum file size in bytes for PUT operations | | **responseContentType** | `string` | optional | Override content-type for GET operations | diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index d3d56924f4..90d6e66672 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -12,6 +12,7 @@ "CronExpressionInputSchema (const)", "EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS (const)", + "EpochMs (type)", "Expression (type)", "ExpressionDialect (type)", "ExpressionInput (type)", diff --git a/packages/spec/scripts/schema-section.test.ts b/packages/spec/scripts/schema-section.test.ts index 1c875edfd3..6b58e0c3f8 100644 --- a/packages/spec/scripts/schema-section.test.ts +++ b/packages/spec/scripts/schema-section.test.ts @@ -569,3 +569,91 @@ describe('renderSchemaSection — the same treatment for relocated vocabularies expect(qualifiedHeadings(md)).toEqual(['### Allowed Values: `Widget.mode`']); }); }); + +/** + * [#15676] The PUBLISHED half of the `externalVocabulary` exemption — ruling B + * on #14478. + * + * `check:duration-unit-keys` exists because a bare `maxAge` publishes a naked + * number to the reference page and the reader has to guess seconds from + * milliseconds. The exemption lets eleven keys keep their bare name BECAUSE the + * name is fixed by an external standard — and that argument only holds for the + * reference-page reader if the page says which standard. Exempting the key + * without publishing its reason would leave exactly that reader where the gate + * found them, so the note is part of the exemption rather than a nicety. + * + * The marker reaches this renderer as a property of the JSON-Schema node, + * riding `z.toJSONSchema` verbatim — the same channel `xRef` / `xExpression` / + * `xEnumDeprecated` use. + * + * MEASURED (reverse verification): deleting the `externalVocabularyNote(prop)` + * term from the description cell turns the first three cases below red and + * leaves the last two green — the last two assert the note's ABSENCE, which is + * what keeps it from decorating every row in the reference. + */ +describe('externalVocabulary — the published half of the duration-rule exemption', () => { + const withMarker = (marker: unknown) => ({ + type: 'object', + properties: { + maxAge: { + type: 'number', + description: 'Maximum cache age in seconds', + ...(marker === undefined ? {} : { externalVocabulary: marker }), + }, + }, + }); + + it('prints the unit as per the named standard, beside the prose that states it', () => { + const md = renderSchemaSection('CacheControl', withMarker('HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1)')); + + expect(md).toContain( + 'Maximum cache age in seconds (unit per HTTP Cache-Control `max-age` (RFC 9111 §5.2.2.1))', + ); + }); + + it('keeps the describe prose — the note QUALIFIES the unit, it does not replace it', () => { + const md = renderSchemaSection('CacheControl', withMarker('PostgreSQL `statement_timeout`')); + + expect(md).toContain('Maximum cache age in seconds'); + expect(md).toContain('(unit per PostgreSQL `statement_timeout`)'); + }); + + it('renders inside a nested shape table too — one grammar, not two', () => { + const md = renderSchemaSection('AuthConfig', { + type: 'object', + properties: { + session: { + type: 'object', + description: 'Session options', + properties: { + expiresIn: { + type: 'number', + description: 'Session duration in seconds', + externalVocabulary: 'better-auth `session.expiresIn`', + }, + }, + }, + }, + }); + + expect(md).toContain('Session duration in seconds (unit per better-auth `session.expiresIn`)'); + }); + + it('prints nothing for a key that declares no marker — the note is not decoration', () => { + const md = renderSchemaSection('CacheControl', withMarker(undefined)); + + expect(md).toContain('Maximum cache age in seconds'); + expect(md).not.toContain('unit per'); + }); + + it('prints nothing for an empty or non-string marker — an unverifiable claim publishes nothing', () => { + // The gate refuses these too (they exempt no key), so the page must not + // print a standard the contract never named. Held on the SAME inputs from + // both sides so the two halves cannot drift into disagreeing about what + // counts as a declaration. + for (const marker of ['', ' ', 42, null, { name: 'RFC 9111' }]) { + const md = renderSchemaSection('CacheControl', withMarker(marker)); + expect(md, `marker ${JSON.stringify(marker)}`).not.toContain('unit per'); + } + }); +}); diff --git a/packages/spec/src/shared/epoch.zod.ts b/packages/spec/src/shared/epoch.zod.ts index c721297ebf..45ee075c4e 100644 --- a/packages/spec/src/shared/epoch.zod.ts +++ b/packages/spec/src/shared/epoch.zod.ts @@ -49,3 +49,13 @@ import { z } from 'zod'; * instant, and it is what keeps an instant out of the `*Ms` duration family. */ export const EpochMs = z.number().int().describe('Unix timestamp in milliseconds (epoch)'); +/** + * The value an `EpochMs` key carries: milliseconds since the Unix epoch. + * + * Author state and parsed state coincide (`z.number().int()` has no default and + * no transform), so there is deliberately no `EpochMsParsed` — a permanent + * synonym is a name an author can only pick wrongly. The isomorphism is pinned + * in `type-alias-convention.pin.test.ts` (ADR-0122), so the day this schema + * gains a default or a transform the pin goes red with the alias named. + */ +export type EpochMs = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f8c8df2da3..7157b7b05d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -268,9 +268,11 @@ import type * as M170 from './ui/component.zod.js'; // [#10235] The served sortability projection — new module, next free index. import type * as M183 from './api/sortability.zod.js'; import type * as M184 from './shared/value-domain.zod.js'; +// [#15676] The shared epoch-millisecond instant — new module, next free index. +import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 826 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1044,6 +1046,11 @@ export type Iso501 = Assert, // shared/protection.zod.ts export type Iso502 = Assert, z.infer< typeof M115.ProtectionSchema > >>; +// shared/epoch.zod.ts — the shared epoch-millisecond INSTANT (#15676), the +// first of the two exemptions ruling B on #14478 declares on the schema. +// `z.number().int()`: no default, no transform, the (RISE) case. +export type Iso868 = Assert, z.infer< typeof M185.EpochMs > >>; + // shared/value-domain.zod.ts — the ONE standard-domain vocabulary (#14168); // `SpecifierValueDomainSchema` (Iso758) is an alias of it, so both pins hold // or fall together. A `z.enum` has no default or transform, the (RISE) case. @@ -1685,7 +1692,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 825 isomorphic pins', () => { + it('still declares all 826 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2116,6 +2123,12 @@ describe('ADR-0122 type-alias convention', () => { // `SpecifierValueDomainSchema` became an alias of it, so its own pin // (`Iso758`) stays and the two hold or fall together. +1 added. // + // 825 -> 826 is #15676's `EpochMs` (shared/epoch.zod.ts) — the shared + // epoch-millisecond instant that ruling B on #14478 declares as the first + // of the duration rule's two structural exemptions. A bare + // `z.number().int()` with no default and no transform: the (RISE) case, + // one new pin (`Iso868`). +1 added. + // // 830 -> 828 is #14180's ADR-0049 retirement of the `metadata:changed` // event payload (kernel/cluster.zod.ts): `MetadataChangedEventPayloadSchema` // — a MUST-emit contract nothing ever produced or consumed, whose From 884646a49092bea7cd46c9ed6e838b79922d230c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:26:48 +0000 Subject: [PATCH 04/33] docs(changeset): the two duration-rule exemptions (#15676) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- ...tant-and-external-vocabulary-exemptions.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .changeset/epoch-instant-and-external-vocabulary-exemptions.md diff --git a/.changeset/epoch-instant-and-external-vocabulary-exemptions.md b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md new file mode 100644 index 0000000000..33ae5af20c --- /dev/null +++ b/.changeset/epoch-instant-and-external-vocabulary-exemptions.md @@ -0,0 +1,86 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: declare the duration rule's two structural exemptions on the schema — a shared `EpochMs` instant and a `.meta({ externalVocabulary })` marker (#15676, ruling B on #14478) + + + +**BREAKING** — four published epoch-instant keys are renamed and tombstoned. +Shipped as `minor` under the repo's launch-window convention for breaking +changes; the hand-migration prescription is registered under protocol major 18. +Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, because two sibling keys both spelled `ttl` in different units +are indistinguishable at the authoring site. Ruling B exempts two structural +classes from it, and is explicit about the mechanism: both are **declared on the +schema, never in a gate ledger**. This change lands both declarations and +applies them. + +## 1. Epoch instants — the shared `EpochMs` schema + +`EpochMs` (`@objectstack/spec/shared`) is a `z.number().int()` describing +milliseconds since the Unix epoch. A key whose value IS that schema is an +INSTANT, and the gate recognises it structurally — nothing anywhere names the +exempt keys. + +An instant reads to the rule exactly like an offending duration (a bare name +plus a describe that says "milliseconds"), but renaming it the way the rule +prescribes would resolve the wrong confusion. Measured on this package's own +authorable surface: all 51 distinct keys ending in `Ms` are durations +(`timeoutMs`, `backoffMs`, `latencyMs`, `uptimeMs`) and all 51 distinct keys +ending in `At` are instants (`createdAt`, `expiresAt`, `lastUsedAt`). Spelling +an instant `*Ms` would move it INTO the family the rule exists to separate it +from. So the six instants take `EpochMs`, and the four whose name was bare take +the `*At` convention. + +### FROM → TO + +| Schema | Wrote | Write instead | +| :-- | :-- | :-- | +| `api/WebSocketEvent` | `timestamp` | `occurredAt` | +| `api/SimplePresenceState` | `lastSeen` | `lastSeenAt` | +| `kernel/KernelContext` (and `TenantRuntimeContext`) | `startTime` | `startedAt` | +| `kernel/HealthStatus` | `timestamp` | `checkedAt` | + +```ts +// before +const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startTime: Date.now(), features: {} }; +// after — the value is unchanged; only the key name and the declared schema move +const ctx: KernelContext = { instanceId, mode: 'production', version, cwd, startedAt: Date.now(), features: {} }; +``` + +Each old key is tombstoned with `retiredKey()`, so it fails `tsc` at the +construction site and fails the parse with the rename prescription rather than +being silently stripped. `kernel/ServiceMetadata.registeredAt` and +`kernel/ScopeInfo.createdAt` were already correctly named and only change +schema — they are not retirements and need no edit. + +⚠️ `api/PresenceState.lastSeen` (`api/realtime-shared.zod.ts`) is a **different** +key holding an ISO-8601 datetime string. It is untouched; do not rename it with +its neighbour. + +**One tightening.** `WebSocketEvent.timestamp` and `SimplePresenceState.lastSeen` +were declared bare `z.number()`, and `EpochMs` is `z.number().int()`, so a +fractional epoch that used to parse at those two sites is now refused. +`Date.now()` has always satisfied it. The other four already declared `.int()`. + +## 2. External-standard mirrors — `.meta({ externalVocabulary })` + +A key whose name is fixed outside this repo carries +`.meta({ externalVocabulary: '' })`. The marker rides +`z.toJSONSchema` verbatim (the channel `xRef` / `xExpression` already use), the +gate honours it, and **the reference page publishes it**: the description cell +now reads `… in seconds (unit per HTTP Cache-Control \`max-age\` (RFC 9111 §5.2.2.1))`. +Publishing it is what makes the exemption honest — the gate exists because a +bare `maxAge` publishes a naked number to a reader who cannot see the source. + +Eleven keys are marked: the three HTTP `Cache-Control` directives, the two CORS +`Access-Control-Max-Age` config keys, the two S3 presigned-URL `expiresIn` keys, +the three better-auth forwarded options, PostgreSQL's `statement_timeout` and +the DNS record `ttl`. No authorable key is renamed or re-typed by this half. + +⛔ Neither exemption is a pass on lying: a marked key still fails +`name-unit-contradicts-prose`, and an `EpochMs` key whose describe names a unit +other than milliseconds fails the new `instant-unit-contradicts-schema`. From c1949b9ac0286c211100d836ea6082e29542a3de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:47:54 +0000 Subject: [PATCH 05/33] chore(spec): regenerate the derived artifacts and fix the consumers the tombstones caught (#15676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `src/contracts/startup-orchestrator.test.ts` built a `HealthStatus` with the old `timestamp` key. The `retiredKey()` tombstone refused it at compile time (`Type 'number' is not assignable to type 'undefined'`, 7 errors) — the audible-removal property the tombstone exists for, working on the first consumer it met. - `type-alias-convention.pin.test.ts`: the pin count assertion follows the new `Iso868`. - Regenerated: export-origins/, declaration-map/, api-surface/ and the `objectstack-api` skill reference index (one generated line, naming the new `shared/epoch.zod.ts` module). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/declaration-map/shared.json | 1 + packages/spec/export-origins/shared.json | 1 + .../src/contracts/startup-orchestrator.test.ts | 16 ++++++++-------- .../spec/src/type-alias-convention.pin.test.ts | 2 +- skills/objectstack-api/references/_index.md | 1 + 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/spec/declaration-map/shared.json b/packages/spec/declaration-map/shared.json index 6b2c3b7f71..328d4fa2f7 100644 --- a/packages/spec/declaration-map/shared.json +++ b/packages/spec/declaration-map/shared.json @@ -8,6 +8,7 @@ "CorsConfigSchema": "shared/CorsConfig", "CronExpressionInput": "shared/CronExpressionInput", "CronExpressionInputSchema": "shared/CronExpressionInput", + "EpochMs": "shared/EpochMs", "Expression": "shared/Expression", "ExpressionDialect": "shared/ExpressionDialect", "ExpressionInput": "shared/ExpressionInput", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 1991e1234d..1a58541778 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -12,6 +12,7 @@ "CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)", "EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)", + "EpochMs": "src/shared/epoch.zod.ts#EpochMs (type)", "Expression": "src/shared/expression.zod.ts#Expression (type)", "ExpressionDialect": "src/shared/expression.zod.ts#ExpressionDialect (type)", "ExpressionInput": "src/shared/expression.zod.ts#ExpressionInput (type)", diff --git a/packages/spec/src/contracts/startup-orchestrator.test.ts b/packages/spec/src/contracts/startup-orchestrator.test.ts index 6ed362c031..9888f309b4 100644 --- a/packages/spec/src/contracts/startup-orchestrator.test.ts +++ b/packages/spec/src/contracts/startup-orchestrator.test.ts @@ -51,17 +51,17 @@ describe('Startup Orchestrator Contract', () => { it('should allow a minimal health status', () => { const status: HealthStatus = { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }; expect(status.healthy).toBe(true); - expect(status.timestamp).toBeGreaterThan(0); + expect(status.checkedAt).toBeGreaterThan(0); }); it('should allow a full health status with details', () => { const status: HealthStatus = { healthy: false, - timestamp: Date.now(), + checkedAt: Date.now(), details: { connections: 0, maxConnections: 10 }, message: 'No database connections available', }; @@ -109,7 +109,7 @@ describe('Startup Orchestrator Contract', () => { duration: 50, health: { healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), details: { uptime: 1000 }, }, }; @@ -131,7 +131,7 @@ describe('Startup Orchestrator Contract', () => { rollback: async (_startedPlugins) => {}, checkHealth: async (_plugin) => ({ healthy: true, - timestamp: Date.now(), + checkedAt: Date.now(), }), }; @@ -155,7 +155,7 @@ describe('Startup Orchestrator Contract', () => { })); }, rollback: async () => {}, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; const results = await orchestrator.orchestrateStartup(plugins, { timeout: 5000 }); @@ -168,7 +168,7 @@ describe('Startup Orchestrator Contract', () => { const orchestrator: IStartupOrchestrator = { orchestrateStartup: async () => [], rollback: async () => {}, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), startWithTimeout: async (_plugin, _context, _timeoutMs) => {}, }; @@ -188,7 +188,7 @@ describe('Startup Orchestrator Contract', () => { rolledBack.push(p.name); } }, - checkHealth: async () => ({ healthy: true, timestamp: Date.now() }), + checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; await orchestrator.rollback([ diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 7157b7b05d..136b387e47 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -2159,7 +2159,7 @@ describe('ADR-0122 type-alias convention', () => { // earlier: `ElementRecordPickerPropsParsed` declared, the Iso819 pin // deleted. -1 converted to an `XParsed` pair; the Iso number stays vacant // (ids are claims about pins, not positions). - expect(pins).toHaveLength(825); + expect(pins).toHaveLength(826); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index a3e50816ce..b37beedfc2 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -30,6 +30,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, +- `node_modules/@objectstack/spec/src/shared/epoch.zod.ts` — Exports: EpochMs - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — Exports: SystemIdentifierSchema, SnakeCaseIdentifierSchema, MetadataItemNameSchema From 796f24fef237e0d805b1b9538475902fadeccd5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:25:21 +0000 Subject: [PATCH 06/33] wip(spec): rename the 12 api/ duration keys, tombstones on the old spellings (#15677) The schema half of stack card 2/6. Gate reads 48 -> 36 with src/api/ at 0. Readers, registry entries and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/api/auth-endpoints.zod.ts | 18 +++++- packages/spec/src/api/contract.zod.ts | 12 +++- packages/spec/src/api/endpoint.zod.ts | 17 +++++- packages/spec/src/api/errors.zod.ts | 26 ++++++++- packages/spec/src/api/plugin-rest-api.zod.ts | 61 ++++++++++++++------ packages/spec/src/api/router.zod.ts | 12 +++- packages/spec/src/api/websocket.zod.ts | 44 ++++++++++++-- 7 files changed, 162 insertions(+), 28 deletions(-) diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 6458512e35..5e2325ca69 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -284,7 +284,23 @@ export const DeviceRequestResponseSchema = lazySchema(() => z.object({ code: z.string().describe('Short-lived device code used for polling'), verificationUrl: z.string().url().describe('URL the user should open in a browser'), expiresAt: z.string().datetime().describe('ISO timestamp when the code expires'), - interval: z.number().default(2).describe('Recommended polling interval in seconds'), + // Renamed from `interval` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. Verified NOT an RFC 8628 mirror before renaming — + // this schema already renames every RFC field it carries (`code` is not + // `device_code`, `verificationUrl` is not `verification_uri`, `expiresAt` is + // not `expires_in`, and carries an ISO-8601 string where the RFC has a + // relative lifetime), so it mirrors no standard as a set and cannot claim + // the standard fixes this one name. + intervalSeconds: z.number().default(2).describe('Recommended polling interval in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + interval: retiredKey( + '`DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the polling cadence is a duration and its unit lived only in the ' + + 'describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + + 'This response is not an RFC 8628 device-authorization payload — it renames every RFC ' + + 'field it carries — so the standard does not fix the bare spelling here.', + ), })); /** diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index e2706d2a38..82d4d52279 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -10,6 +10,7 @@ import { StandardErrorCode } from './errors.zod'; // ========================================== import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const ApiErrorSchema = lazySchema(() => z.object({ /** * Machine-readable semantic code (ADR-0112): a `StandardErrorCode` member or @@ -400,7 +401,16 @@ export const DataLoaderConfigSchema = lazySchema(() => z.object({ .describe('Scheduling strategy for collecting batch keys'), cacheEnabled: z.boolean().default(true).describe('Enable per-request result caching'), cacheKeyFn: z.string().optional().describe('Name or identifier of the cache key function'), - cacheTtl: z.number().min(0).optional().describe('Cache time-to-live in seconds (0 = no expiration)'), + // Renamed from `cacheTtl` (#15677, #14478 ruling B): the unit lived only in + // the describe prose, on a surface whose neighbouring TTLs are milliseconds. + cacheTtlSeconds: z.number().min(0).optional().describe('Cache time-to-live in seconds (0 = no expiration)'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + cacheTtl: retiredKey( + '`DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', + ), coalesceRequests: z.boolean().default(true).describe('Deduplicate identical requests within a batch window'), maxConcurrency: z.number().int().optional().describe('Maximum parallel batch requests'), })); diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index 6e373025f3..cfbe848c6d 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -10,6 +10,7 @@ import { strictObject } from '../shared/strict-object'; * Transform input/output data. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const ApiMappingSchema = lazySchema(() => z.object({ source: z.string().describe('Source field/path'), target: z.string().describe('Target field/path'), @@ -183,7 +184,21 @@ export const ApiEndpointSchema = strictObject({ /** Policies */ authRequired: z.boolean().default(true).describe('Require authentication'), rateLimit: RateLimitConfigSchema.optional().describe('Rate limiting policy'), - cacheTtl: z.number().optional().describe('Response cache TTL in seconds'), + // Renamed from `cacheTtl` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. `apis:` is a stack collection, so the rename is + // replayable — the protocol-18 D2 conversion `api-endpoint-cache-ttl-to- + // cache-ttl-seconds` rewrites stored sources and the tombstone below carries + // the prescription for anyone who jumps majors past it. + cacheTtlSeconds: z.number().optional().describe('Response cache TTL in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + cacheTtl: retiredKey( + '`ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is ' + + 'unchanged, and it stays GET-only. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), // ADR-0010 — runtime protection envelope (internal — set by the loader). // `api` is a registered metadata kind as of #5271, so the artifact loader diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index dd9432190d..dabf299d28 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -361,7 +361,7 @@ export type FieldError = z.input; * "httpStatus": 429, * "retryable": true, * "retryStrategy": "retry_after", - * "retryAfter": 60, + * "retryAfterSeconds": 60, * "details": { * "limit": 1000, * "remaining": 0, @@ -391,7 +391,29 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ httpStatus: z.number().optional().describe('HTTP status code'), retryable: z.boolean().default(false).describe('Whether the request can be retried'), retryStrategy: RetryStrategy.optional().describe('Recommended retry strategy'), - retryAfter: z.number().optional().describe('Seconds to wait before retrying'), + /** + * Renamed from `retryAfter` (#15677, #14478 ruling B). + * + * ⚠️ BREAKING on the ADR-0112 wire envelope. Ruling B put this key + * explicitly IN scope: it is read by humans and agents off the wire even + * though nobody authors it, and `retryAfter` bare next to a `Retry-After` + * header that may carry either a delta-seconds OR an HTTP-date is the exact + * ambiguity the rule exists to remove. + * + * The HTTP `Retry-After` RESPONSE HEADER is a separate, UNCHANGED surface + * (RFC 9110 §10.2.3) — its name is fixed outside this repo and no part of + * this rename touches it. Do not "fix" the header to match. + */ + retryAfterSeconds: z.number().optional().describe('Seconds to wait before retrying'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + retryAfter: retiredKey( + '`EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is ' + + 'unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response ' + + 'header — that header keeps its RFC 9110 name and is untouched.', + ), details: z.unknown().optional().describe('Additional error context'), /** * One entry per offending value. diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index b7f5c5e018..47354e8dd3 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -199,10 +199,28 @@ export const RestApiEndpointSchema = lazySchema(() => z.object({ /** * Performance and reliability settings */ - timeout: z.number().int().optional().describe('Request timeout in milliseconds'), + // Renamed from `timeout` / `cacheTtl` (#15677, #14478 ruling B): both units + // lived only in the describe prose, and the two sat two lines apart in + // DIFFERENT units — milliseconds and seconds — which is the confusion the + // rule exists to remove. + timeoutMs: z.number().int().optional().describe('Request timeout in milliseconds'), rateLimit: z.string().optional().describe('Rate limit policy name'), cacheable: z.boolean().default(false).describe('Whether response can be cached'), - cacheTtl: z.number().int().optional().describe('Cache TTL in seconds'), + cacheTtlSeconds: z.number().int().optional().describe('Cache TTL in seconds'), + + /** Tombstones for the two renames above (#15677, ruling B on #14478). */ + timeout: retiredKey( + '`RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. ' + + 'Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), + cacheTtl: retiredKey( + '`RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose, and the neighbouring request timeout two lines above is in ' + + 'MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', + ), /** * RETIRED (#13823, ADR-0049): `handlerStatus` (`implemented` / `stub` / @@ -714,7 +732,16 @@ export const RestApiPluginConfigSchema = z.object({ enableCompression: z.boolean().default(true).describe('Enable response compression'), enableETag: z.boolean().default(true).describe('Enable ETag generation'), enableCaching: z.boolean().default(true).describe('Enable HTTP caching'), - defaultCacheTtl: z.number().int().default(300).describe('Default cache TTL in seconds'), + // Renamed from `defaultCacheTtl` (#15677, #14478 ruling B). + defaultCacheTtlSeconds: z.number().int().default(300).describe('Default cache TTL in seconds'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + defaultCacheTtl: retiredKey( + '`RestApiPluginConfig.performance.defaultCacheTtl` was renamed to ' + + '`defaultCacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename ' + + 'the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged.', + ), }).optional().describe('Performance optimization settings'), }); @@ -747,7 +774,7 @@ export const DEFAULT_DISCOVERY_ROUTES: RestApiRouteRegistration = { tags: ['Discovery'], responseSchema: 'GetDiscoveryResponseSchema', cacheable: true, - cacheTtl: 3600, // Cache for 1 hour as discovery info rarely changes + cacheTtlSeconds: 3600, // Cache for 1 hour as discovery info rarely changes }], middleware: [ { name: 'response_envelope', type: 'transformation', enabled: true, order: 100 }, @@ -782,7 +809,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { tags: ['Metadata'], responseSchema: 'GetMetaTypesResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -795,7 +822,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { tags: ['Metadata'], responseSchema: 'GetMetaItemsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -813,7 +840,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { // performs or could perform. responseSchema: 'GetMetaItemResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -1037,7 +1064,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'BatchUpdateRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.batch'], - timeout: 60000, // 60 seconds for batch operations + timeoutMs: 60000, // 60 seconds for batch operations cacheable: false, }, { @@ -1059,7 +1086,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'CreateManyDataRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.create', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, { @@ -1074,7 +1101,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'UpdateManyRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.update', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, { @@ -1089,7 +1116,7 @@ export const DEFAULT_BATCH_ROUTES: RestApiRouteRegistration = { requestSchema: 'DeleteManyRequestSchema', responseSchema: 'BatchUpdateResponseSchema', permissions: ['data.delete', 'data.batch'], - timeout: 60000, + timeoutMs: 60000, cacheable: false, }, ], @@ -1233,7 +1260,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetLocalesResponseSchema', cacheable: true, - cacheTtl: 86400, // 24 hours — locales change very rarely + cacheTtlSeconds: 86400, // 24 hours — locales change very rarely }, { method: 'GET', @@ -1246,7 +1273,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetTranslationsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, { method: 'GET', @@ -1259,7 +1286,7 @@ export const DEFAULT_I18N_ROUTES: RestApiRouteRegistration = { tags: ['i18n'], responseSchema: 'GetFieldLabelsResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, ], middleware: [ @@ -1295,7 +1322,7 @@ export const DEFAULT_ANALYTICS_ROUTES: RestApiRouteRegistration = { requestSchema: 'AnalyticsQueryRequestSchema', responseSchema: 'AnalyticsResultResponseSchema', permissions: ['analytics.query'], - timeout: 120000, // 2 minutes for analytics queries + timeoutMs: 120000, // 2 minutes for analytics queries cacheable: false, }, { @@ -1309,7 +1336,7 @@ export const DEFAULT_ANALYTICS_ROUTES: RestApiRouteRegistration = { tags: ['Analytics'], responseSchema: 'AnalyticsMetadataResponseSchema', cacheable: true, - cacheTtl: 3600, + cacheTtlSeconds: 3600, }, ], middleware: [ @@ -1359,7 +1386,7 @@ export const DEFAULT_AUTOMATION_ROUTES: RestApiRouteRegistration = { // protocol-method contract only. responseSchema: 'AutomationTriggerResponseSchema', permissions: ['automation.trigger'], - timeout: 120000, // 2 minutes for long-running automations + timeoutMs: 120000, // 2 minutes for long-running automations cacheable: false, }, { diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 757bd96bd5..126f0f00c6 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -5,6 +5,7 @@ import { CorsConfigSchema, StaticMountSchema, HttpMethod } from '../shared/http. // Re-export HttpMethod for convenience import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export { HttpMethod }; /** @@ -95,7 +96,16 @@ export const RouteDefinitionSchema = lazySchema(() => z.object({ /** * Performance hints */ - timeout: z.number().int().optional().describe('Execution timeout in ms'), + // Renamed from `timeout` (#15677, #14478 ruling B): the unit lived only in + // the describe prose. + timeoutMs: z.number().int().optional().describe('Execution timeout in ms'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + timeout: retiredKey( + '`RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), rateLimit: z.string().optional().describe('Rate limit policy name'), })); diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index 6043b75e2c..c428ab37b0 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -420,10 +420,34 @@ export const WebSocketConfigSchema = lazySchema(() => z.object({ url: z.string().url().describe('WebSocket server URL'), protocols: z.array(z.string()).optional().describe('WebSocket sub-protocols'), reconnect: z.boolean().optional().default(true).describe('Enable automatic reconnection'), - reconnectInterval: z.number().int().positive().optional().default(1000).describe('Reconnection interval in milliseconds'), + // Renamed from `reconnectInterval` / `pingInterval` / `timeout` (#15677, + // #14478 ruling B): three durations on one shape whose unit lived only in + // the describe prose, beside a `maxReconnectAttempts` that is a COUNT — the + // adjacency the rule exists to disambiguate. + reconnectIntervalMs: z.number().int().positive().optional().default(1000).describe('Reconnection interval in milliseconds'), maxReconnectAttempts: z.number().int().positive().optional().default(5).describe('Maximum reconnection attempts'), - pingInterval: z.number().int().positive().optional().default(30000).describe('Ping interval in milliseconds'), - timeout: z.number().int().positive().optional().default(5000).describe('Message timeout in milliseconds'), + pingIntervalMs: z.number().int().positive().optional().default(30000).describe('Ping interval in milliseconds'), + timeoutMs: z.number().int().positive().optional().default(5000).describe('Message timeout in milliseconds'), + + /** Tombstones for the three renames above (#15677, ruling B on #14478). */ + reconnectInterval: retiredKey( + '`WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in ' + + '@objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; ' + + 'the value (milliseconds) is unchanged.', + ), + pingInterval: retiredKey( + '`WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is ' + + 'unchanged.', + ), + timeout: retiredKey( + '`WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' + + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is ' + + 'unchanged.', + ), headers: z.record(z.string(), z.string()).optional().describe('Custom headers for WebSocket handshake'), })); @@ -575,7 +599,7 @@ export type SimpleCursorPosition = z.input; * { * enabled: true, * path: '/ws', - * heartbeatInterval: 30000, + * heartbeatIntervalMs: 30000, * reconnectAttempts: 5, * presence: true, * cursorSharing: true @@ -585,10 +609,20 @@ export type SimpleCursorPosition = z.input; export const WebSocketServerConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable WebSocket server'), path: z.string().default('/ws').describe('WebSocket endpoint path'), - heartbeatInterval: z.number().default(30000).describe('Heartbeat interval in milliseconds'), + // Renamed from `heartbeatInterval` (#15677, #14478 ruling B): its unit lived + // only in the describe prose, beside a `reconnectAttempts` that is a COUNT. + heartbeatIntervalMs: z.number().default(30000).describe('Heartbeat interval in milliseconds'), reconnectAttempts: z.number().default(5).describe('Maximum reconnection attempts for clients'), presence: z.boolean().default(false).describe('Enable presence tracking'), cursorSharing: z.boolean().default(false).describe('Enable collaborative cursor sharing'), + + /** Tombstone for the rename above (#15677, ruling B on #14478). */ + heartbeatInterval: retiredKey( + '`WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in ' + + '@objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in ' + + 'the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; ' + + 'the value (milliseconds) is unchanged.', + ), })); export type WebSocketServerConfig = z.input; From 7e870b1677acadf05c8f46408ef20ee079207c0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:30:17 +0000 Subject: [PATCH 07/33] wip(spec): readers, ADR-0087 registrations, ledger row for the api/ renames (#15677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 retired-key entries, one D2 conversion (api-endpoint-cache-ttl-to-cache-ttl-seconds — apis: is a stack collection) and five semantic entries for the eleven runtime-emitted / construction-argument keys. Readers moved in runtime, metadata, rest-adjacent tests, dogfood fixtures and the showcase example; liveness/api.json carries the renamed row plus the dead tombstone row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../platform-checklist/areas/api-backend.json | 4 +- examples/app-showcase/src/coverage.ts | 2 +- .../app-showcase/src/system/apis/index.ts | 8 +- .../metadata/src/endpoint-matcher.test.ts | 4 +- ...eclarative-endpoint-policy.dogfood.test.ts | 2 +- .../test/fixtures/endpoint-policy-fixture.ts | 4 +- ...case-declarative-endpoints.dogfood.test.ts | 6 +- .../runtime/src/api-endpoint-step.test.ts | 20 +- packages/runtime/src/api-endpoint-step.ts | 12 +- ...ugin.endpoint-fallback.integration.test.ts | 12 +- packages/runtime/src/endpoint-executor.ts | 2 +- packages/runtime/src/endpoint-policy.test.ts | 22 +- packages/runtime/src/endpoint-policy.ts | 28 +- packages/runtime/src/route-ledger.ts | 2 +- packages/spec/REST_API_PLUGIN.md | 2 +- packages/spec/liveness/api.json | 11 +- .../spec/src/api/apis-publish-gates.test.ts | 34 +-- packages/spec/src/api/contract.test.ts | 8 +- .../spec/src/api/endpoint-publish-gate.ts | 26 +- packages/spec/src/api/errors.test.ts | 4 +- packages/spec/src/api/plugin-rest-api.test.ts | 14 +- packages/spec/src/api/websocket.test.ts | 18 +- packages/spec/src/conversions/registry.ts | 62 ++++ .../18.api__ApiEndpoint__cacheTtl.ts | 18 ++ .../18.api__DataLoaderConfig__cacheTtl.ts | 11 + ...18.api__DeviceRequestResponse__interval.ts | 16 ++ .../18.api__EnhancedApiError__retryAfter.ts | 15 + .../18.api__RestApiEndpoint__cacheTtl.ts | 8 + .../18.api__RestApiEndpoint__timeout.ts | 13 + ...uginConfig__performance.defaultCacheTtl.ts | 11 + .../18.api__RouteDefinition__timeout.ts | 12 + .../18.api__WebSocketConfig__pingInterval.ts | 7 + ...api__WebSocketConfig__reconnectInterval.ts | 11 + .../18.api__WebSocketConfig__timeout.ts | 6 + ...ebSocketServerConfig__heartbeatInterval.ts | 9 + .../18.api-error-retry-after-unit-in-key.ts | 35 +++ ...pi-runtime-config-durations-unit-in-key.ts | 36 +++ ...e-request-response-interval-unit-in-key.ts | 34 +++ ...8.rest-api-plugin-durations-unit-in-key.ts | 39 +++ .../18.websocket-durations-unit-in-key.ts | 34 +++ packages/spec/src/migrations/registry.ts | 272 ++++++++++++++++++ 41 files changed, 774 insertions(+), 120 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__DataLoaderConfig__cacheTtl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__DeviceRequestResponse__interval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__EnhancedApiError__retryAfter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__RestApiEndpoint__cacheTtl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__RestApiEndpoint__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__RestApiPluginConfig__performance.defaultCacheTtl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__RouteDefinition__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketConfig__pingInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketConfig__reconnectInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketConfig__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.api__WebSocketServerConfig__heartbeatInterval.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.api-error-retry-after-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.api-runtime-config-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.device-request-response-interval-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.rest-api-plugin-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.websocket-durations-unit-in-key.ts diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index a326196156..3212d62015 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -842,7 +842,7 @@ "object_operation target — the task feed (authed read)", "flow target — delegates to a flow", "policy: authRequired (default true)", - "policy: cacheTtl → Cache-Control", + "policy: cacheTtlSeconds → Cache-Control", "script/proxy target — answer 501 in the open framework" ], "steps": [ @@ -854,7 +854,7 @@ ], "acceptance": [ { - "clause": "the object_operation endpoint answers 200 authed with the delegated data, and carries the declared cache policy (Cache-Control: private, max-age=30 when cacheTtl is set)", + "clause": "the object_operation endpoint answers 200 authed with the delegated data, and carries the declared cache policy (Cache-Control: private, max-age=30 when cacheTtlSeconds is set)", "oracle": "api", "verify": "authed GET status 200 + body + Cache-Control header vs the declared policy", "evidence": "response + headers" diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 11d36f5040..57fa637984 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -124,7 +124,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/apis/index.ts'], notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtl: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', + 'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.', }, translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] }, email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] }, diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 71b4e88feb..afb47a89d9 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -30,7 +30,7 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; * target delegation, mapping keys, OpenAPI enrichment) and E7 narrowed the * blanket refusal to per-endpoint publish gates. So the premise of the comment * is gone, and these come back **unchanged in intent** — same names, same - * targets, same `authRequired`, same `cacheTtl` — with the ONE edit ADR-0121 + * targets, same `authRequired`, same `cacheTtlSeconds` — with the ONE edit ADR-0121 * D1 requires: the paths move under this app's namespace carve-out. * * ## The namespace carve-out (ADR-0121 D1/D2) @@ -88,11 +88,11 @@ export const TaskFeedEndpoint: ApiEndpoint = { // RLS-trimmed for its caller and a shared cache must never store one and // hand it to somebody else. `computeCacheControl` in // `packages/runtime/src/endpoint-policy.ts` states that rule and the rest of - // the ladder with it — including `cacheTtl: 0`, which is `no-store` rather + // the ladder with it — including `cacheTtlSeconds: 0`, which is `no-store` rather // than "no header": writing 0 says something, and saying nothing is spelled - // by omitting the key. GET-only by rule: publish rejects `cacheTtl` on any + // by omitting the key. GET-only by rule: publish rejects `cacheTtlSeconds` on any // other method rather than parsing it and ignoring it. - cacheTtl: 30, + cacheTtlSeconds: 30, }; /** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */ diff --git a/packages/metadata/src/endpoint-matcher.test.ts b/packages/metadata/src/endpoint-matcher.test.ts index 7f57ff4e7f..c5872e9734 100644 --- a/packages/metadata/src/endpoint-matcher.test.ts +++ b/packages/metadata/src/endpoint-matcher.test.ts @@ -343,8 +343,8 @@ describe('#5189 — publish gates re-applied at load (identity-free subset)', () endpoint({ name: 'proxied', type: 'proxy', target: 'https://x.test' }), endpoint({ name: 'no_params', objectParams: undefined }), endpoint({ name: 'mapped', outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }), - endpoint({ name: 'neg_cache', cacheTtl: -1 }), - endpoint({ name: 'post_cache', method: 'POST', cacheTtl: 30 }), + endpoint({ name: 'neg_cache', cacheTtlSeconds: -1 }), + endpoint({ name: 'post_cache', method: 'POST', cacheTtlSeconds: 30 }), ]) { const logger = makeLogger(); expect(buildEndpointIndex([bad], logger).size).toBe(0); diff --git a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts index 42e593deeb..268ca519ad 100644 --- a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts +++ b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts @@ -91,7 +91,7 @@ describe('[#5112] ADR-0121 D6 — anonymous is served, and metered', () => { expect(body.success).toBe(true); }); - it('carries the declared cacheTtl on the anonymous success', async () => { + it('carries the declared cacheTtlSeconds on the anonymous success', async () => { const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); expect(res.headers.get('cache-control')).toMatch(/max-age=15/); }); diff --git a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts index 080e26d82d..8e7f61fb7d 100644 --- a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts @@ -22,7 +22,7 @@ // anonymous-without-armed-budget combination for that reason. The budget is // deliberately tiny (2 requests per minute) so a test can exhaust it without // sleeping. -// • `cacheTtl` on the GET — proves the success-only `Cache-Control` on a +// • `cacheTtlSeconds` on the GET — proves the success-only `Cache-Control` on a // second, independent stack. // // The paths sit under `/api/v1/apps/e8policy/…` because ADR-0121 D1 confines a @@ -59,7 +59,7 @@ export const AnonymousMeteredEndpoint: ApiEndpoint = { objectParams: { object: 'e8policy_note', operation: 'find' }, authRequired: false, rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 2 }, - cacheTtl: 15, + cacheTtlSeconds: 15, }; /** The control: same object, same operation, session-gated, unmetered. */ diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index 7b0c108957..4d21af3f6b 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -22,7 +22,7 @@ // built-in route gives for the same operation (#5040 §4's red line); // 2. does `authRequired` actually gate (401 for anonymous), rather than // parsing green and gating nothing as it did before #4936; -// 3. does `cacheTtl` reach the wire as `Cache-Control`; +// 3. does `cacheTtlSeconds` reach the wire as `Cache-Control`; // 4. does an UNDECLARED path under the mount still answer the transport's // own bare 404, byte for byte — the seam must cost non-endpoint traffic // nothing (#5090); @@ -272,7 +272,7 @@ describe('[#5112] object_operation endpoint: same pipeline, same answer', () => expect(a.data).toEqual(b); }); - it('carries the declared cacheTtl as a Cache-Control header — `private` included', async () => { + it('carries the declared cacheTtlSeconds as a Cache-Control header — `private` included', async () => { // Both halves of this header are pinned, and the FIRST one is the reason // this assertion exists at all (#5396). // @@ -290,7 +290,7 @@ describe('[#5112] object_operation endpoint: same pipeline, same answer', () => const res = await stack.apiAs(adminToken, 'GET', TASKS); expect( res.headers.get('cache-control'), - 'cacheTtl: 30 must reach the wire, and reach it as `private`', + 'cacheTtlSeconds: 30 must reach the wire, and reach it as `private`', ).toMatch(/^private, max-age=30$/); }, 60_000); diff --git a/packages/runtime/src/api-endpoint-step.test.ts b/packages/runtime/src/api-endpoint-step.test.ts index 4b3b171952..8c725643c7 100644 --- a/packages/runtime/src/api-endpoint-step.test.ts +++ b/packages/runtime/src/api-endpoint-step.test.ts @@ -232,13 +232,13 @@ describe('the policy chain runs between the match and the answer', () => { expect(hint).toContain('#5040'); }); - it('never puts the cacheTtl header on the 501 — but the verdict still carries it', async () => { + it('never puts the cacheTtlSeconds header on the 501 — but the verdict still carries it', async () => { // Exposure, not application: `Cache-Control` describes a successful body // that does not exist yet (execution is E5), and telling a client to // cache a 501 for 30s would be worse than saying nothing. The header // lives on the policy verdict, which is what the executor will read — // asserted directly in `endpoint-policy.test.ts`. - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); const answer = await policedStep([cached], policyContext()); expect(answer?.status).toBe(501); expect(answer?.headers).toBeUndefined(); @@ -276,7 +276,7 @@ describe('the policy chain runs between the match and the answer', () => { * The delegation itself is `endpoint-executor.test.ts`'s subject; what is * asserted here is the JOIN — that a passing request reaches the executor with * the request's own coordinates and identity, that a denial never does, and - * that `cacheTtl`'s header lands on a success and on nothing else. + * that `cacheTtlSeconds`'s header lands on a success and on nothing else. */ describe('execution runs on the far side of the policy chain', () => { const OPEN: ApiEndpoint = ApiEndpointSchema.parse({ ...TASKS, name: 'showcase_open', authRequired: false }); @@ -343,8 +343,8 @@ describe('execution runs on the far side of the policy chain', () => { ]]); }); - it('puts the cacheTtl Cache-Control on a SUCCESS answer', async () => { - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + it('puts the cacheTtlSeconds Cache-Control on a SUCCESS answer', async () => { + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); const answer = await wiredStep([cached], { deps: { callData: callDataSpy().fn as never } }); expect(answer?.status).toBe(200); @@ -352,7 +352,7 @@ describe('execution runs on the far side of the policy chain', () => { }); it('never puts it on an ERROR answer, however the failure arose', async () => { - const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtl: 30 }); + const cached = ApiEndpointSchema.parse({ ...OPEN, name: 'showcase_cached', cacheTtlSeconds: 30 }); // A delegated pipeline that throws — the executor maps it to a 4xx/5xx // answer, and a client must not be told to reuse a failure for 30s. const answer = await wiredStep([cached], { @@ -365,7 +365,7 @@ describe('execution runs on the far side of the policy chain', () => { // Same for a declaration this runtime does not execute (501 from the // executor's own `unsupported` arm, not from the no-wiring branch). const proxied = ApiEndpointSchema.parse({ - ...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtl: 30, + ...OPEN, name: 'showcase_proxy', type: 'proxy', target: 'https://example.invalid', cacheTtlSeconds: 30, }); const unsupported = await wiredStep([proxied], { deps: { callData: async () => ({}) } }); expect(unsupported?.status).toBe(501); @@ -505,8 +505,8 @@ describe('the mapping keys apply on the two sides of the delegation', () => { expect(JSON.stringify(answer?.body)).not.toContain('internal_note'); }); - it('keeps the cacheTtl header on a mapped success', async () => { - // `cacheTtl` is GET-only (#5040 §3.3), so this is a read endpoint: the + it('keeps the cacheTtlSeconds header on a mapped success', async () => { + // `cacheTtlSeconds` is GET-only (#5040 §3.3), so this is a read endpoint: the // point is that the two keys compose — the projection replaces the body // and the policy verdict's header still rides with it. const mapped = ApiEndpointSchema.parse({ @@ -514,7 +514,7 @@ describe('the mapping keys apply on the two sides of the delegation', () => { name: 'showcase_cached_map', method: 'GET', objectParams: { object: 'showcase_inquiry', operation: 'find' }, - cacheTtl: 30, + cacheTtlSeconds: 30, outputMapping: [{ source: 'total', target: 'count' }], }); diff --git a/packages/runtime/src/api-endpoint-step.ts b/packages/runtime/src/api-endpoint-step.ts index 36b4156b2b..24950f017d 100644 --- a/packages/runtime/src/api-endpoint-step.ts +++ b/packages/runtime/src/api-endpoint-step.ts @@ -31,7 +31,7 @@ * * ## The chain, in the one order it can run in * - * `authRequired` / `rateLimit` / `cacheTtl` are enforced by + * `authRequired` / `rateLimit` / `cacheTtlSeconds` are enforced by * {@link applyEndpointPolicies}, in the order #5040 §3 fixes, whenever the * caller supplies a {@link EndpointPolicyContext}. A denial (401 / 429) is the * answer. A pass reaches {@link executeEndpointTarget} — and NOTHING else can: @@ -40,7 +40,7 @@ * forgot the policies" is therefore not a mistake a future change can make by * omission; there is nowhere else to put the call. * - * `verdict.responseHeaders` (the `Cache-Control` computed from `cacheTtl`) is + * `verdict.responseHeaders` (the `Cache-Control` computed from `cacheTtlSeconds`) is * merged into SUCCESS answers only. An error answer never carries it: the * header describes a body the caller should be willing to reuse, and telling a * client to cache a 401 / 429 / 500 for a minute is worse than saying nothing. @@ -133,7 +133,7 @@ export interface AppEndpointStepAnswer { * Headers that are part of THIS answer and must be written with it: * `Retry-After` on a rate-limit denial (the one piece of information a * throttled client needs to behave), and `Cache-Control` on a SUCCESSFUL - * execution result (from `cacheTtl`). + * execution result (from `cacheTtlSeconds`). * * The asymmetry is deliberate and enforced below — `Cache-Control` rides * only on a success, never on an error answer. @@ -242,7 +242,7 @@ export async function runAppEndpointStep( // hint says which keys were NOT evaluated — a report that is wrong // about what ran is worse than no report. return notImplemented(match, method, path, - 'This request reached the step without a policy context, so authRequired / rateLimit / cacheTtl ' + 'This request reached the step without a policy context, so authRequired / rateLimit / cacheTtlSeconds ' + 'were not evaluated — and nothing was executed either. The composed runtime always threads one ' + '(#5040 E5b), so reaching this answer means a host mounted the step by hand and omitted it.'); } @@ -261,7 +261,7 @@ export async function runAppEndpointStep( // without a policy context, and a denial short-circuits before it. if (!input.execution) { return notImplemented(match, method, path, - 'Policies (authRequired / rateLimit / cacheTtl) were enforced and this request passed them, but no ' + 'Policies (authRequired / rateLimit / cacheTtlSeconds) were enforced and this request passed them, but no ' + 'execution wiring was supplied, so the target was not run. The composed runtime always supplies ' + 'it (#5040 E5b).'); } @@ -295,7 +295,7 @@ export async function runAppEndpointStep( deps, ); - // `Cache-Control` (from `cacheTtl`) applies to a SUCCESS and nothing else. + // `Cache-Control` (from `cacheTtlSeconds`) applies to a SUCCESS and nothing else. // `executeEndpointTarget` never throws — a delegated failure is already an // error answer here — so the status is the whole test, and an endpoint whose // execution failed cannot hand the client a cache directive for the failure. diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts index 43763ae934..aabcbaea34 100644 --- a/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts +++ b/packages/runtime/src/dispatcher-plugin.endpoint-fallback.integration.test.ts @@ -14,7 +14,7 @@ * Since #5129 the seam serves the whole chain — policies (E4) then target * delegation (E5) — so the cases below drive REAL `callData` and a REAL * automation slot, and pin the two things only a socket can prove: that a 429 - * carries its `Retry-After` ON THE WIRE, and that a `cacheTtl` `Cache-Control` + * carries its `Retry-After` ON THE WIRE, and that a `cacheTtlSeconds` `Cache-Control` * rides a success and never an error. * * The load-bearing assertion in most of these is a NEGATIVE one: that adding @@ -349,10 +349,10 @@ const EXECUTABLE: ApiEndpoint[] = [ target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'find' }, authRequired: false, - cacheTtl: 30, + cacheTtlSeconds: 30, }), ApiEndpointSchema.parse({ - // Same `cacheTtl`, but a shape whose execution FAILS: `get` with no + // Same `cacheTtlSeconds`, but a shape whose execution FAILS: `get` with no // `?id=` is a 400 from the executor. The pair is the whole point — // one key, two outcomes, only one of them cacheable. name: 'showcase_cached_get', @@ -362,7 +362,7 @@ const EXECUTABLE: ApiEndpoint[] = [ target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'get' }, authRequired: false, - cacheTtl: 30, + cacheTtlSeconds: 30, }), ]; @@ -513,12 +513,12 @@ describe('the wired chain — policies, then the real pipeline (#5129)', () => { expect(body.error.details?.retryAfterSeconds).toBe(Number(retryAfter)); }); - it('sends cacheTtl\'s Cache-Control on a success and on nothing else', async () => { + it('sends cacheTtlSeconds\'s Cache-Control on a success and on nothing else', async () => { const ok = await fetch(`${baseUrl}/api/v1/apps/showcase/cached`); expect(ok.status).toBe(200); expect(ok.headers.get('Cache-Control')).toBe('private, max-age=30'); - // Same endpoint family, same `cacheTtl: 30`, but the execution fails + // Same endpoint family, same `cacheTtlSeconds: 30`, but the execution fails // (a `get` with no `?id=`). Telling the client to reuse a 400 for 30 // seconds would make an author's typo sticky. const failed = await fetch(`${baseUrl}/api/v1/apps/showcase/cached-get`); diff --git a/packages/runtime/src/endpoint-executor.ts b/packages/runtime/src/endpoint-executor.ts index 038ecbfa40..937495264b 100644 --- a/packages/runtime/src/endpoint-executor.ts +++ b/packages/runtime/src/endpoint-executor.ts @@ -485,7 +485,7 @@ async function executeObjectOperation( * applied to the `200`-wrapped FAILURE body and could present it as data. The * policy chain is upstream of this function and is untouched: a refusal here * is reached only by a request that already passed `rateLimit` / - * `authRequired`, and `Cache-Control` from `cacheTtl` rides success only, + * `authRequired`, and `Cache-Control` from `cacheTtlSeconds` rides success only, * again on the same `status < 400` test. */ async function executeFlow( diff --git a/packages/runtime/src/endpoint-policy.test.ts b/packages/runtime/src/endpoint-policy.test.ts index 8fe10a071a..55e2d25b51 100644 --- a/packages/runtime/src/endpoint-policy.test.ts +++ b/packages/runtime/src/endpoint-policy.test.ts @@ -5,7 +5,7 @@ * * Three keys, and for each of them the three cases that matter: declared and * hit, declared and not hit, and the boundary (`authRequired: false`, a budget - * that is present but disarmed, `cacheTtl: 0`). A policy key that is only ever + * that is present but disarmed, `cacheTtlSeconds: 0`). A policy key that is only ever * tested in its "allow" direction is indistinguishable from a key nobody read. * * Everything is driven with stubs — a counter store, a principal resolver, a @@ -298,7 +298,7 @@ describe('rateLimit — #5006 primitives, an endpoint-scoped keyspace', () => { }); // ───────────────────────────────────────────────────────────────────────────── -describe('cacheTtl — response-header semantics only', () => { +describe('cacheTtlSeconds — response-header semantics only', () => { it('says nothing when the key is absent', async () => { expect(computeCacheControl(declare(), 'GET')).toBeUndefined(); const verdict = await run(declare({ authRequired: false }), harness({})); @@ -306,38 +306,38 @@ describe('cacheTtl — response-header semantics only', () => { }); it('sets `private, max-age=` for a positive ttl', async () => { - const verdict = await run(declare({ authRequired: false, cacheTtl: 30 }), harness({})); + const verdict = await run(declare({ authRequired: false, cacheTtlSeconds: 30 }), harness({})); expect(verdict.verdict).toBe('pass'); if (verdict.verdict !== 'pass') return; expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'private, max-age=30' }); }); it('is `private` even on an anonymous endpoint — a shared cache must never hold a per-caller answer', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: 60 }, 'GET')).toBe('private, max-age=60'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 60 }, 'GET')).toBe('private, max-age=60'); }); it('reads 0 as "do not cache" rather than as silence', async () => { - // The boundary #5091 asks for. `cacheTtl: 0` is a sentence the author + // The boundary #5091 asks for. `cacheTtlSeconds: 0` is a sentence the author // wrote; answering it identically to an absent key would make writing it // a no-op, which is the failure mode this program exists to remove. - expect(computeCacheControl({ name: 'e', cacheTtl: 0 }, 'GET')).toBe('no-store'); - const verdict = await run(declare({ authRequired: false, cacheTtl: 0 }), harness({})); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 0 }, 'GET')).toBe('no-store'); + const verdict = await run(declare({ authRequired: false, cacheTtlSeconds: 0 }), harness({})); expect(verdict.verdict).toBe('pass'); if (verdict.verdict !== 'pass') return; expect(verdict.responseHeaders).toEqual({ 'Cache-Control': 'no-store' }); }); it('truncates a fractional ttl rather than emitting a fractional max-age', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: 30.7 }, 'GET')).toBe('private, max-age=30'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: 30.7 }, 'GET')).toBe('private, max-age=30'); }); it('refuses to invent a meaning for a negative ttl', () => { - expect(computeCacheControl({ name: 'e', cacheTtl: -5 }, 'GET')).toBe('no-store'); + expect(computeCacheControl({ name: 'e', cacheTtlSeconds: -5 }, 'GET')).toBe('no-store'); }); it('sends no header on a non-GET endpoint, and says so out loud', () => { const warnings: string[] = []; - const header = computeCacheControl({ name: 'purge', cacheTtl: 30 }, 'POST', { + const header = computeCacheControl({ name: 'purge', cacheTtlSeconds: 30 }, 'POST', { warn: (m: string) => { warnings.push(m); }, }); expect(header).toBeUndefined(); @@ -348,7 +348,7 @@ describe('cacheTtl — response-header semantics only', () => { it('is not computed for a denied request', async () => { // Nothing to cache, nothing to say: the denial carries its own headers // (`Retry-After`) and no cache directive at all. - const denied = await run(declare({ cacheTtl: 30 }), harness({})); + const denied = await run(declare({ cacheTtlSeconds: 30 }), harness({})); expect(denied.verdict).toBe('deny'); if (denied.verdict !== 'deny') return; expect(denied.headers).toBeUndefined(); diff --git a/packages/runtime/src/endpoint-policy.ts b/packages/runtime/src/endpoint-policy.ts index edf42522f6..3cafa6221d 100644 --- a/packages/runtime/src/endpoint-policy.ts +++ b/packages/runtime/src/endpoint-policy.ts @@ -2,7 +2,7 @@ /** * The POLICY KEYS of a declarative `apis:` endpoint — `authRequired`, - * `rateLimit`, `cacheTtl` (#5040 E4). + * `rateLimit`, `cacheTtlSeconds` (#5040 E4). * * ## What this is, and what it deliberately is not * @@ -16,9 +16,9 @@ * |---|---|---| * | `authRequired` | `shouldDenyAnonymous` + the `ANONYMOUS_DENY_*` constants | `/meta`, `/ai`, `/security` (#2567, #3963) | * | `rateLimit` | `deriveBucketConfig` / `resolveRateLimitKey` / `SharedTokenBucketLimiter` | the server-level inbound limiter (#5006, #4910 Q3=C / Q4=B) | - * | `cacheTtl` | a `Cache-Control` response header, nothing more | — | + * | `cacheTtlSeconds` | a `Cache-Control` response header, nothing more | — | * - * `cacheTtl` is header semantics ONLY. #5091 narrowed the design's original + * `cacheTtlSeconds` is header semantics ONLY. #5091 narrowed the design's original * server-side cache out of scope on purpose: a cache needs an invalidation * story, and inventing one for a key whose vocabulary says four words * ("Response cache TTL in seconds") is how a runtime dialect is born. A header @@ -33,7 +33,7 @@ * * ## Order: rate limit BEFORE auth. This is not an accident. * - * #5040 §3 fixes the order as `rateLimit → authRequired → cacheTtl`, and the + * #5040 §3 fixes the order as `rateLimit → authRequired → cacheTtlSeconds`, and the * rationale is worth restating where the code is: the traffic that most needs * metering — credential stuffing, token spraying, scraping — is exactly the * traffic that will be answered 401. Gating first and metering second would let @@ -53,7 +53,7 @@ * traffic: `api-endpoint-step.ts` applies this chain on every match, and the * showcase's two declared endpoints exercise it over a socket * (`packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts` - * pins that `authRequired` denies anonymous and `cacheTtl` reaches the wire). + * pins that `authRequired` denies anonymous and `cacheTtlSeconds` reaches the wire). * The tests below still drive this module directly — a pure function over * explicit deps is worth testing as one. */ @@ -209,7 +209,7 @@ export type EndpointPolicyVerdict = principalId?: string; /** * Headers for the endpoint's eventual SUCCESS answer — today only - * `Cache-Control`, from `cacheTtl`. Handed back rather than applied + * `Cache-Control`, from `cacheTtlSeconds`. Handed back rather than applied * because the thing being described (the response body) does not exist * yet: execution lands with #5040 E5, and telling a client to cache a * 501 for a minute would be worse than saying nothing. @@ -225,7 +225,7 @@ export type EndpointPolicyVerdict = }; /** - * `cacheTtl` → the `Cache-Control` header for a successful response. + * `cacheTtlSeconds` → the `Cache-Control` header for a successful response. * * The vocabulary fixes the unit (seconds) and nothing else, so the rest is * stated here, tested, and documented rather than left to a reader's guess: @@ -239,28 +239,28 @@ export type EndpointPolicyVerdict = * shared cache must never store one and hand it to somebody else. (The * design's per-principal cache key, #5040 §3.3, is the same rule one layer * down; with no server-side cache this is where it survives.) - * - **0 or negative** → `no-store`. An author who writes `cacheTtl: 0` said + * - **0 or negative** → `no-store`. An author who writes `cacheTtlSeconds: 0` said * something; making it identical to saying nothing is exactly the silent * no-op this program exists to remove. (E7's publish gate should reject a * NEGATIVE ttl outright — noted on #5111 — but the runtime still has to * answer coherently if one arrives.) - * - **non-GET** → no header, plus a `warn` naming the endpoint. `cacheTtl` is + * - **non-GET** → no header, plus a `warn` naming the endpoint. `cacheTtlSeconds` is * GET-only (#5040 §3.3) and E7 rejects the combination at publish; until * then the runtime refuses to invent a meaning for it, and says so out loud * instead of dropping it silently. */ export function computeCacheControl( - endpoint: Pick, + endpoint: Pick, method: string, logger?: RateLimitLogger, ): string | undefined { - const ttl = endpoint.cacheTtl; + const ttl = endpoint.cacheTtlSeconds; if (ttl === undefined || ttl === null) return undefined; if (method.toUpperCase() !== 'GET') { logger?.warn?.( - `[dispatcher] endpoint '${endpoint.name}' declares \`cacheTtl\` on a ${method.toUpperCase()} endpoint. ` - + '`cacheTtl` is GET-only (#5040 §3.3) and no Cache-Control header will be sent. Remove the key, or ' + `[dispatcher] endpoint '${endpoint.name}' declares \`cacheTtlSeconds\` on a ${method.toUpperCase()} endpoint. ` + + '`cacheTtlSeconds` is GET-only (#5040 §3.3) and no Cache-Control header will be sent. Remove the key, or ' + 'declare the endpoint as GET.', ); return undefined; @@ -378,7 +378,7 @@ export async function applyEndpointPolicies(input: EndpointPolicyInput): Promise } } - // ── ③ cacheTtl ────────────────────────────────────────────────────── + // ── ③ cacheTtlSeconds ────────────────────────────────────────────────────── const cacheControl = computeCacheControl(endpoint, method, logger); return { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 56b7f93fd0..7cd7bbbd03 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -497,7 +497,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ + '`IHttpServer.setFallbackHandler` (Hono `app.notFound`) that runs only after every registered ' + 'route has missed, and for paths under this prefix resolves the request\'s environment + ' + 'identity, probes `metadata.matchEndpoint`, and on a match runs the full chain (#5040 E5b): ' - + 'the policy keys authRequired / rateLimit / cacheTtl (E4), then target delegation (E5) — ' + + 'the policy keys authRequired / rateLimit / cacheTtlSeconds (E4), then target delegation (E5) — ' + '`object_operation` through the same `callData` as /data, `flow` through the automation ' + 'service. `script` / `proxy` targets and the inputMapping / outputMapping keys are NOT ' + 'executed and answer 501. A miss (or an occupant of the metadata slot with no matchEndpoint, ' diff --git a/packages/spec/REST_API_PLUGIN.md b/packages/spec/REST_API_PLUGIN.md index b9eab75873..41fba2c800 100644 --- a/packages/spec/REST_API_PLUGIN.md +++ b/packages/spec/REST_API_PLUGIN.md @@ -315,7 +315,7 @@ const config: RestApiPluginConfig = { enableCompression: true, enableETag: true, enableCaching: true, - defaultCacheTtl: 300, + defaultCacheTtlSeconds: 300, }, }; ``` diff --git a/packages/spec/liveness/api.json b/packages/spec/liveness/api.json index 30dab7b444..cf7fd5129d 100644 --- a/packages/spec/liveness/api.json +++ b/packages/spec/liveness/api.json @@ -132,11 +132,16 @@ } } }, - "cacheTtl": { + "cacheTtlSeconds": { "status": "live", - "evidence": "packages/runtime/src/endpoint-policy.ts#computeCacheControl (`const ttl = endpoint.cacheTtl`; absent ⇒ no header, non-GET ⇒ no header plus a warn naming the endpoint); packages/runtime/src/endpoint-policy.ts#applyEndpointPolicies (step ③ — the returned header is attached to the SUCCESS answer only)", + "evidence": "packages/runtime/src/endpoint-policy.ts#computeCacheControl (`const ttl = endpoint.cacheTtlSeconds`; absent ⇒ no header, non-GET ⇒ no header plus a warn naming the endpoint); packages/runtime/src/endpoint-policy.ts#applyEndpointPolicies (step ③ — the returned header is attached to the SUCCESS answer only)", "verifiedAt": "2026-08-28", - "note": "Seconds, emitted as a Cache-Control response header and nothing more (#5091 narrowed the original design to header semantics only — there is no response store). Applied to SUCCESSFUL answers only: telling a client to reuse a 401/429/5xx for half a minute is worse than saying nothing. GET-only — on any other method publish refuses it (#5040 §3.3) and the runtime warns instead of emitting. 2026-08-28: RE-ANCHORED (#13003) and PROSE CORRECTED — the line `:252` was ACCURATE (it is the function's own declaration line), but the parenthetical named `cacheControlHeader`, which is not a symbol anywhere in `packages/**` — the function is `computeCacheControl`, and the only other occurrence of the old spelling is a docblock table in `endpoint-publish-gate.ts` that names it and `endpointRateLimiterRegistry` (also stale — `createEndpointRateLimiterRegistry`) as this file's exports, i.e. the same rename went unpropagated in two places. Line-accurate and name-wrong is the combination nothing in the ledger could flag, because no check has ever compared a citation's prose against its position — and it is the combination an anchor removes by construction, since the name IS the pointer now. Re-closed by hand against 93ea19bca." + "note": "Seconds, emitted as a Cache-Control response header and nothing more (#5091 narrowed the original design to header semantics only — there is no response store). Applied to SUCCESSFUL answers only: telling a client to reuse a 401/429/5xx for half a minute is worse than saying nothing. GET-only — on any other method publish refuses it (#5040 §3.3) and the runtime warns instead of emitting. 2026-08-28: RE-ANCHORED (#13003) and PROSE CORRECTED — the line `:252` was ACCURATE (it is the function's own declaration line), but the parenthetical named `cacheControlHeader`, which is not a symbol anywhere in `packages/**` — the function is `computeCacheControl`, and the only other occurrence of the old spelling is a docblock table in `endpoint-publish-gate.ts` that names it and `endpointRateLimiterRegistry` (also stale — `createEndpointRateLimiterRegistry`) as this file's exports, i.e. the same rename went unpropagated in two places. Line-accurate and name-wrong is the combination nothing in the ledger could flag, because no check has ever compared a citation's prose against its position — and it is the combination an anchor removes by construction, since the name IS the pointer now. Re-closed by hand against 93ea19bca. RENAMED 2026-09-05 (#15677, #14478 ruling B) from `cacheTtl`: the unit lived only in the describe prose. `computeCacheControl` moved from `endpoint.cacheTtl` to `endpoint.cacheTtlSeconds` in the same PR at the same magnitude, and the publish gate's issue path moved with it (`apis.N.cacheTtlSeconds`). Anchors carried over from the `cacheTtl` row (re-anchored #13003)." + }, + "cacheTtl": { + "status": "dead", + "verifiedAt": "2026-09-05", + "note": "REMOVED 2026-09-05 (#15677, #14478 ruling B) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of stored sources by the protocol-18 conversion `api-endpoint-cache-ttl-to-cache-ttl-seconds`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `cacheTtlSeconds` — rename the key, the value (seconds) is unchanged, it is still GET-only, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/api/endpoint.zod.ts#cacheTtl. Not to be confused with `RestServerConfig.metadata.cacheTtl` (liveness/metadata_endpoints.json), a different key retired for a different reason by #14691." } } } diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts index 3c62354f00..3231b0e07b 100644 --- a/packages/spec/src/api/apis-publish-gates.test.ts +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -55,7 +55,7 @@ const validObjectEndpoint = { target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'find' as const }, authRequired: true, - cacheTtl: 30, + cacheTtlSeconds: 30, }; /** A fully valid `flow` endpoint under the same namespace. */ @@ -141,7 +141,7 @@ describe('[#5111] the flip — a well-formed `apis:` publishes', () => { path: '/api/v1/apps/showcase/tasks', method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' }, - cacheTtl: undefined, + cacheTtlSeconds: undefined, inputMapping: [{ source: 'title', target: 'name' }, { source: 'meta.owner', target: 'owner_id' }], outputMapping: [{ source: 'id', target: 'task_id' }], }, @@ -330,7 +330,7 @@ describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclaration name: 'showcase_task_create', method: 'POST' as const, objectParams: { object: 'showcase_task', operation: 'create' as const }, - cacheTtl: undefined, + cacheTtlSeconds: undefined, }; it('rejects `transform` on either mapping key', () => { @@ -380,7 +380,7 @@ describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclaration { ...validObjectEndpoint, method: operation === 'delete' ? 'DELETE' : 'GET', - cacheTtl: undefined, + cacheTtlSeconds: undefined, objectParams: { object: 'showcase_task', operation }, inputMapping: [{ source: 'a', target: 'b' }], }, @@ -448,19 +448,19 @@ describe('[#5111] gate (e) — policy keys (ADR-0121 D6 + the E4 refusals)', () expect(apis?.[0]?.rateLimit).toEqual({ enabled: true, windowMs: 60_000, maxRequests: 100 }); }); - it('rejects a negative `cacheTtl`, and accepts 0 (an explicit no-store)', () => { - const message = reject({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: -5 }] }); + it('rejects a negative `cacheTtlSeconds`, and accepts 0 (an explicit no-store)', () => { + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, cacheTtlSeconds: -5 }] }); expect(message).toMatch(/cannot be negative/); - accept({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: 0 }] }); + accept({ manifest, apis: [{ ...validObjectEndpoint, cacheTtlSeconds: 0 }] }); }); - it('rejects `cacheTtl` on a non-GET endpoint', () => { + it('rejects `cacheTtlSeconds` on a non-GET endpoint', () => { const message = reject({ manifest, - apis: [{ ...validFlowEndpoint, cacheTtl: 30 }], + apis: [{ ...validFlowEndpoint, cacheTtlSeconds: 30 }], }); expect(message).toMatch(/GET-only/); - expect(message).toMatch(/apis\.0\.cacheTtl/); + expect(message).toMatch(/apis\.0\.cacheTtlSeconds/); }); }); @@ -500,7 +500,7 @@ describe('[#5111] gate (d) — one claim per METHOD + path inside a stack', () = ...validObjectEndpoint, name: 'showcase_task_create', method: 'POST', - cacheTtl: undefined, + cacheTtlSeconds: undefined, objectParams: { object: 'showcase_task', operation: 'create' }, }, ], @@ -515,12 +515,12 @@ describe('[#5111] every rejection is actionable, and reaches every publish seam' apis: [ { ...validObjectEndpoint, name: 'a_bad', path: '/api/v1/nope' }, { ...validFlowEndpoint, name: 'b_bad', target: '' }, - { ...validObjectEndpoint, name: 'c_bad', path: '/api/v1/apps/showcase/other', cacheTtl: -1 }, + { ...validObjectEndpoint, name: 'c_bad', path: '/api/v1/apps/showcase/other', cacheTtlSeconds: -1 }, ], }); const custom = result.success ? [] : result.error.issues.filter((i) => i.code === 'custom'); expect(custom).toHaveLength(3); - expect(custom.map((i) => i.path.join('.'))).toEqual(['apis.0.path', 'apis.1.target', 'apis.2.cacheTtl']); + expect(custom.map((i) => i.path.join('.'))).toEqual(['apis.0.path', 'apis.1.target', 'apis.2.cacheTtlSeconds']); }); it('`defineStack` throws the same prescription an artifact parse reports', () => { @@ -551,7 +551,7 @@ describe('[#5111] the `ApiEndpoint` vocabulary itself is untouched', () => { expect(parsed.type).toBe('object_operation'); expect(parsed.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); expect(parsed.authRequired).toBe(true); - expect(parsed.cacheTtl).toBe(30); + expect(parsed.cacheTtlSeconds).toBe(30); }); it('keeps `authRequired` defaulting to true — omission is the SAFE state', () => { @@ -596,7 +596,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi it('still refuses D6 — the gate with no runtime counterpart, and the reason #5189 exists', () => { const failure = identityFreeEndpointGateFailure( - ApiEndpointSchema.parse({ ...validObjectEndpoint, cacheTtl: undefined, authRequired: false }), + ApiEndpointSchema.parse({ ...validObjectEndpoint, cacheTtlSeconds: undefined, authRequired: false }), ); expect(failure).toBeDefined(); expect(failure!.path).toEqual(['rateLimit']); @@ -609,7 +609,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi identityFreeEndpointGateFailure( ApiEndpointSchema.parse({ ...validObjectEndpoint, - cacheTtl: undefined, + cacheTtlSeconds: undefined, authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, }), @@ -622,7 +622,7 @@ describe('identityFreeEndpointGateFailure — the same judge, minus stack identi [{ type: 'proxy', target: 'https://x.test', objectParams: undefined }, ['type']], [{ objectParams: { object: 'showcase_task' } }, ['objectParams']], [{ outputMapping: [{ source: 'a', target: 'b', transform: 'upper' }] }, ['outputMapping', 0, 'transform']], - [{ cacheTtl: -1 }, ['cacheTtl']], + [{ cacheTtlSeconds: -1 }, ['cacheTtlSeconds']], ]; for (const [over, path] of cases) { const failure = identityFreeEndpointGateFailure( diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts index e95edb9325..c796286656 100644 --- a/packages/spec/src/api/contract.test.ts +++ b/packages/spec/src/api/contract.test.ts @@ -499,7 +499,7 @@ describe('DataLoaderConfigSchema', () => { batchScheduleFn: 'timeout', cacheEnabled: false, cacheKeyFn: 'customKeyFn', - cacheTtl: 60, + cacheTtlSeconds: 60, coalesceRequests: false, maxConcurrency: 4, }); @@ -508,7 +508,7 @@ describe('DataLoaderConfigSchema', () => { expect(config.batchScheduleFn).toBe('timeout'); expect(config.cacheEnabled).toBe(false); expect(config.cacheKeyFn).toBe('customKeyFn'); - expect(config.cacheTtl).toBe(60); + expect(config.cacheTtlSeconds).toBe(60); expect(config.maxConcurrency).toBe(4); }); @@ -520,8 +520,8 @@ describe('DataLoaderConfigSchema', () => { }); }); - it('should reject negative cacheTtl', () => { - expect(() => DataLoaderConfigSchema.parse({ cacheTtl: -1 })).toThrow(); + it('should reject negative cacheTtlSeconds', () => { + expect(() => DataLoaderConfigSchema.parse({ cacheTtlSeconds: -1 })).toThrow(); }); }); diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts index c30e952b52..97abeb2ee1 100644 --- a/packages/spec/src/api/endpoint-publish-gate.ts +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -28,7 +28,7 @@ * |---|---| * | unsupported target (`script` / `proxy` / incomplete `object_operation` / empty flow `target`) | `planEndpointTarget` — `packages/runtime/src/endpoint-executor.ts` | * | mapping (`transform`, unusable path, colliding `target`) | `mappingDeclarationRejection` — `packages/runtime/src/api-mapping.ts` | - * | policy (armed-but-unusable `rateLimit`, negative `cacheTtl`, `cacheTtl` off GET) | `createEndpointRateLimiterRegistry` / `computeCacheControl` — `packages/runtime/src/endpoint-policy.ts` | + * | policy (armed-but-unusable `rateLimit`, negative `cacheTtlSeconds`, `cacheTtlSeconds` off GET) | `createEndpointRateLimiterRegistry` / `computeCacheControl` — `packages/runtime/src/endpoint-policy.ts` | * | namespace + uniqueness | ADR-0121 D1/D2, and `normalizeEndpointPath` (`packages/metadata/src/endpoint-matcher.ts`) for the path form | * * The runtime keeps its refusals: a declaration can still reach the store @@ -103,7 +103,7 @@ type MappingKey = (typeof MAPPING_KEYS)[number]; * root) and what the author must read. */ export interface EndpointGateIssue { - /** Zod issue path — e.g. `['apis', 2, 'cacheTtl']`. */ + /** Zod issue path — e.g. `['apis', 2, 'cacheTtlSeconds']`. */ path: (string | number)[]; message: string; } @@ -461,7 +461,7 @@ function mappingKeyGate( /** * `inputMapping` on an operation that never reads a request body. * - * PM ruling on #5111 (2026-08-04), same category as `cacheTtl` on a non-GET + * PM ruling on #5111 (2026-08-04), same category as `cacheTtlSeconds` on a non-GET * method: the declaration is legal to parse and provably inert, because * `inputMapping` maps the REQUEST BODY (its own `.describe()`) and `find` / * `get` / `delete` are served from `query` alone. "Declared, parsed, does @@ -487,7 +487,7 @@ function inertInputMappingGate( + 'REQUEST BODY to internal params (its own vocabulary text); `find` takes its criteria from ' + 'the query string and `get` / `delete` take the record id from `query.id`. Remove the key, ' + 'or move the endpoint to an operation that carries a body (`create` / `update`). ' - + 'Same rule, same reason as `cacheTtl` on a non-GET endpoint: a declaration that cannot ' + + 'Same rule, same reason as `cacheTtlSeconds` on a non-GET endpoint: a declaration that cannot ' + 'take effect is rejected instead of silently ignored.', }; } @@ -549,28 +549,28 @@ function policyGate( } } - const cacheTtl = endpoint.cacheTtl; - if (typeof cacheTtl === 'number' && cacheTtl < 0) { + const cacheTtlSeconds = endpoint.cacheTtlSeconds; + if (typeof cacheTtlSeconds === 'number' && cacheTtlSeconds < 0) { return { - path: at('cacheTtl'), + path: at('cacheTtlSeconds'), message: - `${named} declares \`cacheTtl: ${cacheTtl}\`. A response cache lifetime cannot be negative — ` + `${named} declares \`cacheTtlSeconds: ${cacheTtlSeconds}\`. A response cache lifetime cannot be negative — ` + 'seconds only, 0 or more. Use a positive number of seconds for a cacheable answer, or ' - + '`cacheTtl: 0` to say explicitly "never store this response" (it emits ' + + '`cacheTtlSeconds: 0` to say explicitly "never store this response" (it emits ' + '`Cache-Control: no-store`); omit the key to send no caching header at all.', }; } - if (cacheTtl !== undefined && endpoint.method !== 'GET') { + if (cacheTtlSeconds !== undefined && endpoint.method !== 'GET') { // Internal anchor for the GET-only rule: #5040 §3.3. Kept out of the // message, which is printed to a customer with no tracker access. return { - path: at('cacheTtl'), + path: at('cacheTtlSeconds'), message: - `${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only: ` + `${named} declares \`cacheTtlSeconds\` on a ${endpoint.method} endpoint. \`cacheTtlSeconds\` is GET-only: ` + 'it becomes a `Cache-Control` header on a successful response, and a ' + 'non-GET answer is not a cacheable representation, so the key would be parsed and never ' - + 'take effect. Remove `cacheTtl`, or declare the endpoint as GET if it really is a read.', + + 'take effect. Remove `cacheTtlSeconds`, or declare the endpoint as GET if it really is a read.', }; } diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts index 0595c58c8c..1e0fdc3def 100644 --- a/packages/spec/src/api/errors.test.ts +++ b/packages/spec/src/api/errors.test.ts @@ -169,7 +169,7 @@ describe('EnhancedApiErrorSchema', () => { httpStatus: 429, retryable: true, retryStrategy: 'retry_after', - retryAfter: 60, + retryAfterSeconds: 60, details: { limit: 1000, remaining: 0, @@ -178,7 +178,7 @@ describe('EnhancedApiErrorSchema', () => { }); expect(error.retryable).toBe(true); - expect(error.retryAfter).toBe(60); + expect(error.retryAfterSeconds).toBe(60); expect(error.details.limit).toBe(1000); }); diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index b882fb6ea9..b394cb99dc 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -64,7 +64,7 @@ describe('plugin-rest-api.zod', () => { tags: ['Data', 'CRUD'], requestSchema: 'CreateRequestSchema', responseSchema: 'SingleRecordResponseSchema', - timeout: 30000, + timeoutMs: 30000, rateLimit: 'standard', cacheable: false, }); @@ -72,7 +72,7 @@ describe('plugin-rest-api.zod', () => { expect(endpoint.permissions).toEqual(['data.create']); expect(endpoint.summary).toBe('Create a record'); expect(endpoint.tags).toEqual(['Data', 'CRUD']); - expect(endpoint.timeout).toBe(30000); + expect(endpoint.timeoutMs).toBe(30000); }); it('should default public to false', () => { @@ -443,7 +443,7 @@ describe('plugin-rest-api.zod', () => { enableCompression: true, enableETag: true, enableCaching: true, - defaultCacheTtl: 600, + defaultCacheTtlSeconds: 600, }, }); @@ -452,7 +452,7 @@ describe('plugin-rest-api.zod', () => { expect(config.validation?.mode).toBe('strict'); expect(config.openApi?.title).toBe('My API'); expect(config.cors?.origins).toContain('http://localhost:3000'); - expect(config.performance?.defaultCacheTtl).toBe(600); + expect(config.performance?.defaultCacheTtlSeconds).toBe(600); }); }); @@ -520,7 +520,7 @@ describe('plugin-rest-api.zod', () => { // Verify batch endpoints have longer timeouts DEFAULT_BATCH_ROUTES.endpoints?.forEach(endpoint => { - expect(endpoint.timeout).toBe(60000); + expect(endpoint.timeoutMs).toBe(60000); }); }); @@ -578,7 +578,7 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_ANALYTICS_ROUTES.endpoints).toHaveLength(2); // Analytics query should have extended timeout const queryEndpoint = DEFAULT_ANALYTICS_ROUTES.endpoints?.find(e => e.handler === 'analyticsQuery'); - expect(queryEndpoint?.timeout).toBe(120000); + expect(queryEndpoint?.timeoutMs).toBe(120000); }); it('should validate DEFAULT_AUTOMATION_ROUTES', () => { @@ -591,7 +591,7 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_AUTOMATION_ROUTES.endpoints).toHaveLength(2); expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].path).toBe('/trigger/:name'); // Automation trigger should have extended timeout - expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].timeout).toBe(120000); + expect(DEFAULT_AUTOMATION_ROUTES.endpoints?.[0].timeoutMs).toBe(120000); // The actions endpoint exposes the live registry and is cacheable. const actionsEndpoint = DEFAULT_AUTOMATION_ROUTES.endpoints?.find(e => e.path === '/actions'); expect(actionsEndpoint?.method).toBe('GET'); diff --git a/packages/spec/src/api/websocket.test.ts b/packages/spec/src/api/websocket.test.ts index b1ce26b9d4..739cd3aef3 100644 --- a/packages/spec/src/api/websocket.test.ts +++ b/packages/spec/src/api/websocket.test.ts @@ -650,10 +650,10 @@ describe('WebSocketConfigSchema', () => { url: 'wss://example.com/ws', protocols: ['objectstack-v1', 'json'], reconnect: true, - reconnectInterval: 2000, + reconnectIntervalMs: 2000, maxReconnectAttempts: 10, - pingInterval: 60000, - timeout: 10000, + pingIntervalMs: 60000, + timeoutMs: 10000, headers: { 'Authorization': 'Bearer token123', 'X-Custom-Header': 'value', @@ -662,7 +662,7 @@ describe('WebSocketConfigSchema', () => { const parsed = WebSocketConfigSchema.parse(config); expect(parsed.reconnect).toBe(true); - expect(parsed.reconnectInterval).toBe(2000); + expect(parsed.reconnectIntervalMs).toBe(2000); expect(parsed.maxReconnectAttempts).toBe(10); }); @@ -673,10 +673,10 @@ describe('WebSocketConfigSchema', () => { const parsed = WebSocketConfigSchema.parse(config); expect(parsed.reconnect).toBe(true); - expect(parsed.reconnectInterval).toBe(1000); + expect(parsed.reconnectIntervalMs).toBe(1000); expect(parsed.maxReconnectAttempts).toBe(5); - expect(parsed.pingInterval).toBe(30000); - expect(parsed.timeout).toBe(5000); + expect(parsed.pingIntervalMs).toBe(30000); + expect(parsed.timeoutMs).toBe(5000); }); it('should validate URL format', () => { @@ -692,12 +692,12 @@ describe('WebSocketConfigSchema', () => { it('should reject negative intervals', () => { expect(() => WebSocketConfigSchema.parse({ url: 'wss://example.com/ws', - reconnectInterval: -1000, + reconnectIntervalMs: -1000, })).toThrow(); expect(() => WebSocketConfigSchema.parse({ url: 'wss://example.com/ws', - pingInterval: 0, + pingIntervalMs: 0, })).toThrow(); }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3984265710..5dad0b918c 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8622,6 +8622,67 @@ const jobTimeoutToTimeoutMs: MetadataConversion = { }, }; +/** + * `apis[].cacheTtl` → `apis[].cacheTtlSeconds` (protocol 18, #15677 for #14478) + * — the `api` half of the same rename `hookTimeoutToTimeoutMs` and + * `jobTimeoutToTimeoutMs` document, and the ONE key of that card's twelve that + * gets a conversion rather than a semantic entry: `apis:` is a stack collection + * (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered metadata kind + * stored as a row, so the chain has a seam that sees it. The other eleven are + * wire payloads and construction arguments the chain never touches. + * + * Same posture as its two siblings: retired from the load path, tombstoned at + * the schema, replayable here. The fixture keeps `rateLimit` out of the + * converted endpoint on purpose — it is the one neighbouring policy key whose + * own shape is still in a live window — and carries a second endpoint that + * never authored `cacheTtl` so copy-on-write identity is pinned too. + */ +const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { + id: 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'apis[].cacheTtl', + summary: "api endpoint key 'cacheTtl' \u2192 'cacheTtlSeconds' (#14478 \u2014 the unit lived only in the description; the value, seconds, is unchanged, and the key stays GET-only)", + apply(stack, emit) { + return mapCollection(stack, 'apis', (endpoint, path) => { + const renamed = renameKey(endpoint, 'cacheTtl', 'cacheTtlSeconds'); + if (!renamed) return endpoint; + emit({ from: 'cacheTtl', to: 'cacheTtlSeconds', path: `${path}.cacheTtlSeconds` }); + return renamed; + }); + }, + fixture: { + before: { + apis: [ + { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'task', operation: 'find' }, + cacheTtl: 30, + }, + // An endpoint that never authored the key keeps its identity (copy-on-write). + { name: 'create_task', path: '/api/v1/apps/showcase/tasks', method: 'POST', type: 'object_operation' }, + ], + }, + after: { + apis: [ + { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'task', operation: 'find' }, + cacheTtlSeconds: 30, + }, + { name: 'create_task', path: '/api/v1/apps/showcase/tasks', method: 'POST', type: 'object_operation' }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8713,6 +8774,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly ], }; @@ -9043,6 +9202,22 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, and no + // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response + // cache TTL in seconds" in prose and nothing else, on the same authorable + // surface where `rateLimit.windowMs` spells its unit. Renamed to + // `cacheTtlSeconds`; the value is unchanged and the key stays GET-only. + // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare + // deletion would strip the old key in silence, and the unknown-key error could + // not carry the rename. This is the ONE key of this card's twelve that gets a + // D2 CONVERSION rather than a semantic entry: `apis:` is a stack collection + // (`stack.zod.ts` — `apis: z.array(ApiEndpointSchema)`) and an `api` is a + // registered metadata kind stored as a row, so the conversion chain has a seam + // that sees it. `api-endpoint-cache-ttl-to-cache-ttl-seconds` rewrites it, + // retired from the load path (no alias window). Registered under 18 for the + // launch-window reason its neighbours state. + 'api/ApiEndpoint:cacheTtl', // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, // executing the #14369 liveness census (15 `dead` rows across the `crud` / // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` @@ -9135,6 +9310,42 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // four ledger child rows collapse into the one `patterns` row. Closes #14365's // question about the record's input type — there is no record left to reshape. 'api/CrudEndpointsConfig:patterns', + // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. + // `DataLoaderConfig.cacheTtl` named seconds only in its describe. Renamed to + // `cacheTtlSeconds`; the value is unchanged. Tombstoned with `retiredKey()` + // because the shape is not `.strict()`. No D2 conversion: a `DataLoaderConfig` + // is a per-request batch-loader construction argument, never a stack collection + // member or a stored row, so the chain has no seam (the `kernel/Manifest:loading` + // precedent); the semantic entry `api-runtime-config-durations-unit-in-key` + // carries the prescription. + 'api/DataLoaderConfig:cacheTtl', + // #15677 (stack card 2/6 of #14478) — ruling B: the unit lives in the key NAME. + // `DeviceRequestResponse.interval` named seconds only in its describe. Renamed + // to `intervalSeconds`; the value is unchanged. Checked against ruling B's + // SECOND exemption before renaming — a key mirroring a name fixed outside this + // repo carries `.meta({ externalVocabulary })` — and it does not qualify: + // `DeviceRequestResponseSchema` does not mirror RFC 8628 as a set (`code` is + // not `device_code`, `verificationUrl` is not `verification_uri`, `expiresAt` + // is not `expires_in` and holds an ISO-8601 string where the RFC has a relative + // lifetime), so a schema that already renames every RFC field it carries cannot + // claim the standard fixes this one. Tombstoned with `retiredKey()`. No D2 + // conversion: this is a RUNTIME-EMITTED device-flow response body, never a + // stored row; the semantic entry `device-request-response-interval-unit-in-key` + // carries the prescription. + 'api/DeviceRequestResponse:interval', + // #15677 (stack card 2/6 of #14478) — ruling B, which put this key explicitly + // IN scope with its own BREAKING note: the ~16 runtime-emitted measurements are + // read by humans and agents even if nobody authors them, `ApiError.retryAfter` + // on the wire envelope included. `retryAfter` bare, beside an HTTP `Retry-After` + // header that may carry EITHER delta-seconds OR an HTTP-date, is precisely the + // ambiguity the rule removes. Renamed to `retryAfterSeconds`; the value is + // unchanged. ⚠️ The HTTP `Retry-After` RESPONSE HEADER is a SEPARATE, UNCHANGED + // surface — its name is fixed by RFC 9110 §10.2.3 and nothing here touches it. + // Tombstoned with `retiredKey()`. No D2 conversion: an ADR-0112 error envelope + // is emitted on the wire, never stored as a metadata row, so the chain has no + // seam; the semantic entry `api-error-retry-after-unit-in-key` carries the + // prescription. + 'api/EnhancedApiError:retryAfter', // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, // executing the #14369 liveness census (15 `dead` rows across the `crud` / // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` @@ -9179,6 +9390,12 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // A nested key of an inline block, so it has no line of its own in // `authorable-surface/` (the `kernel/Manifest:contributes.routes` shape). 'api/MetadataEndpointsConfig:endpoints.schema', + // #15677 (stack card 2/6 of #14478) — ruling B; the seconds half of the pair + // documented on `api/RestApiEndpoint:timeout`. Renamed to `cacheTtlSeconds`; + // the value is unchanged. Tombstoned with `retiredKey()`; disposition and + // reasoning are that entry's, and the prescription travels in the semantic + // entry `rest-api-plugin-durations-unit-in-key`. + 'api/RestApiEndpoint:cacheTtl', // #13823 — ADR-0049 enforce-or-remove on `RestApiEndpointSchema.handlerStatus` // (maintainer ruling 2026-09-01, director decision batch #27, verbatim // 「同意」: remove). The key (`implemented` / `stub` / `planned`) was declared @@ -9215,6 +9432,36 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #11846 / #12428 grading). 'api/RestApiEndpoint:handlerStatus', + // #15677 (stack card 2/6 of #14478) — ruling B. `RestApiEndpoint.timeout` + // (milliseconds) sat THREE LINES above `cacheTtl` (seconds), each unit named + // only in its describe: one shape, two units, no way to tell them apart at the + // authoring site. Renamed to `timeoutMs`; the value is unchanged. Tombstoned + // with `retiredKey()` on this non-strict shape, beside the `handlerStatus` + // tombstone already there. No D2 conversion: a `RestApiEndpoint` is REST-plugin + // route-registration configuration, never a stack collection member (the + // `rest-api-endpoint-handler-status-retired` precedent on this very shape); the + // semantic entry `rest-api-plugin-durations-unit-in-key` carries the + // prescription. + 'api/RestApiEndpoint:timeout', + // #15677 (stack card 2/6 of #14478) — ruling B. The plugin-wide default behind + // the per-endpoint `cacheTtl` this card also renames; leaving it bare would have + // left the DEFAULT spelled one way and the OVERRIDE another. Renamed to + // `defaultCacheTtlSeconds`; the value is unchanged. Tombstoned with + // `retiredKey()` inside the live `performance` block — a tombstone whose + // siblings must keep parsing. No D2 conversion: `RestApiPluginConfig` is the + // REST plugin's construction argument, never a stored row; the semantic entry + // `rest-api-plugin-durations-unit-in-key` carries the prescription. + 'api/RestApiPluginConfig:performance.defaultCacheTtl', + // #15677 (stack card 2/6 of #14478) — ruling B. `RouteDefinition.timeout` said + // "Execution timeout in ms" in prose and nothing else. Renamed to `timeoutMs`; + // the value is unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a + // `RouteDefinition` is a router registration a host or plugin builds in code, + // never a stack collection member or a stored row; the semantic entry + // `api-runtime-config-durations-unit-in-key` carries the prescription. Note for + // anyone grepping: `packages/runtime/src/dispatcher-plugin.ts` declares its OWN + // local `RouteDefinition` interface for the `ai:routes` hook payload — a + // different type, with no duration key at all, and untouched by this rename. + 'api/RouteDefinition:timeout', // #14691 — ADR-0049 enforce-or-remove on the `RestServerConfig` sub-objects, // executing the #14369 liveness census (15 `dead` rows across the `crud` / // `metadata` / `batch` / `routes` sub-schemas; 0 read sites in `packages/rest` @@ -9344,6 +9591,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // than 17, for the reasons the sibling `api/WebSocketEvent:timestamp` entry // records: a presence payload is runtime-emitted, never a stored metadata row. 'api/SimplePresenceState:lastSeen', + // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two + // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to + // `pingIntervalMs`; the value is unchanged. Semantic entry + // `websocket-durations-unit-in-key`. + 'api/WebSocketConfig:pingInterval', + // #15677 (stack card 2/6 of #14478) — ruling B. Three durations on + // `WebSocketConfig` named their unit only in prose, interleaved with a + // `maxReconnectAttempts` that is a COUNT — so `reconnectInterval: 5` beside + // `maxReconnectAttempts: 5` read as one kind of number and was two. Renamed to + // `reconnectIntervalMs`; the value is unchanged. Tombstoned with `retiredKey()`. + // No D2 conversion: a `WebSocketConfig` is a client connection argument, never a + // stored row; the semantic entry `websocket-durations-unit-in-key` carries the + // prescription for all four of this shape's renames. + 'api/WebSocketConfig:reconnectInterval', + // #15677 (stack card 2/6 of #14478) — ruling B; documented with its two + // siblings on `api/WebSocketConfig:reconnectInterval`. Renamed to `timeoutMs`; + // the value is unchanged. Semantic entry `websocket-durations-unit-in-key`. + 'api/WebSocketConfig:timeout', // #15676 — the epoch-instant half of #14478 ruling B. `WebSocketEvent.timestamp` // is an epoch INSTANT, not a duration: it moved onto the shared `EpochMs` schema // (which declares the millisecond unit) and was renamed `occurredAt`, because @@ -9362,6 +9627,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // v17.0.0 was cut before this landed, so the change ships on the 17.x line and // the prescription lives at the major boundary `migrate meta` users look at. 'api/WebSocketEvent:timestamp', + // #15677 (stack card 2/6 of #14478) — ruling B. The server-side counterpart of + // the `WebSocketConfig` trio, with the same COUNT neighbour problem + // (`reconnectAttempts`). Renamed to `heartbeatIntervalMs`; the value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: server + // construction configuration, never a stored row; the semantic entry + // `websocket-durations-unit-in-key` carries the prescription. + 'api/WebSocketServerConfig:heartbeatInterval', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in From 37fc158623300a8c1aad061b5819967405a7fd15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 09:52:48 +0000 Subject: [PATCH 08/33] wip(spec): tombstone refusal tests, alias retarget, regenerated artifacts (#15677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-key refusal tests assert the prescription (code + rename text), not a bare throw. Two readers the key-name grep missed and tsc/the tombstones caught: the ApiEndpoint alias table (cacheTTL/ttl/cache retargeted onto cacheTtlSeconds — an alias must point at a key the schema accepts) and the showcase endpoint fixture in metadata-type-api-registration.test.ts. Regenerated: authorable surface + defaults, reference docs, liveness state-counts. skills/objectstack-api/SKILL.md carries the rename (governed; net 0 lines, file and package both). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../docs/references/api/auth-endpoints.mdx | 3 +- content/docs/references/api/contract.mdx | 6 +- content/docs/references/api/endpoint.mdx | 3 +- content/docs/references/api/errors.mdx | 6 +- .../docs/references/api/plugin-rest-api.mdx | 17 +++-- content/docs/references/api/router.mdx | 3 +- content/docs/references/api/websocket.mdx | 12 ++- packages/spec/authorable-defaults/api.json | 10 +-- packages/spec/authorable-surface/api.json | 33 +++++--- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/api/auth-endpoints.test.ts | 39 ++++++++++ packages/spec/src/api/contract.test.ts | 26 +++++++ packages/spec/src/api/endpoint.test.ts | 54 +++++++++++-- packages/spec/src/api/endpoint.zod.ts | 5 +- packages/spec/src/api/errors.test.ts | 34 +++++++++ packages/spec/src/api/plugin-rest-api.test.ts | 75 +++++++++++++++++++ packages/spec/src/api/router.test.ts | 35 +++++++-- packages/spec/src/api/websocket.test.ts | 60 +++++++++++++++ .../metadata-type-api-registration.test.ts | 2 +- skills/objectstack-api/SKILL.md | 2 +- 20 files changed, 381 insertions(+), 48 deletions(-) diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 14cb4740b0..4919abf597 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -88,7 +88,8 @@ const result = AuthEndpointSchema.parse(data); | **code** | `string` | ✅ | Short-lived device code used for polling | | **verificationUrl** | `string` | ✅ | URL the user should open in a browser | | **expiresAt** | `string` | ✅ | ISO timestamp when the code expires | -| **interval** | `number` | optional (default: `2`) | Recommended polling interval in seconds | +| **intervalSeconds** | `number` | optional (default: `2`) | Recommended polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 (#14478 ruling B) — the polling cadence is a duration and its unit lived only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. This response is not an RFC 8628 device-authorization payload — it renames every RFC field it carries — so the standard does not fix the bare spelling here. | --- diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b5fb06793a..652128a63a 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -452,7 +452,8 @@ const result = ApiErrorSchema.parse(data); | **batchScheduleFn** | `Enum<'microtask' \| 'timeout' \| 'manual'>` | optional (default: `"microtask"`) | Scheduling strategy for collecting batch keys | | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | -| **cacheTtl** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | @@ -665,7 +666,8 @@ const result = ApiErrorSchema.parse(data); | **batchScheduleFn** | `Enum<'microtask' \| 'timeout' \| 'manual'>` | optional (default: `"microtask"`) | Scheduling strategy for collecting batch keys | | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | -| **cacheTtl** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index ca852a8c5c..522bdd5b2b 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -39,7 +39,8 @@ const result = ApiEndpointSchema.parse(data); | **outputMapping** | `{ source: string; target: string; transform?: string }[]` | optional | Map Internal Result to Response Body | | **authRequired** | `boolean` | optional (default: `true`) | Require authentication | | **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Rate limiting policy | -| **cacheTtl** | `number` | optional | Response cache TTL in seconds | +| **cacheTtlSeconds** | `number` | optional | Response cache TTL in seconds | +| **cacheTtl** | `never` | optional | [REMOVED] `ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged, and it stays GET-only. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 914c21b94e..8fe3ad290e 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -47,7 +47,8 @@ const result = EnhancedApiErrorSchema.parse(data); | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | -| **retryAfter** | `number` | optional | Seconds to wait before retrying | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | @@ -162,7 +163,8 @@ const result = EnhancedApiErrorSchema.parse(data); | **httpStatus** | `number` | optional | HTTP status code | | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | -| **retryAfter** | `number` | optional | Seconds to wait before retrying | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx index cb4f5a3f6c..bb81b2d518 100644 --- a/content/docs/references/api/plugin-rest-api.mdx +++ b/content/docs/references/api/plugin-rest-api.mdx @@ -182,10 +182,12 @@ const result = ErrorHandlingConfigSchema.parse(data); | **tags** | `string[]` | optional | OpenAPI tags for grouping | | **requestSchema** | `string` | optional | Request schema name (for validation) | | **responseSchema** | `string` | optional | Response schema name (for documentation) | -| **timeout** | `integer` | optional | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional | Request timeout in milliseconds | | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | -| **cacheTtl** | `integer` | optional | Cache TTL in seconds | +| **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | @@ -207,7 +209,7 @@ const result = ErrorHandlingConfigSchema.parse(data); | **openApi** | `{ enabled: boolean; version: Enum<'3.0.0' \| '3.0.1' \| '3.0.2' \| '3.0.3' \| '3.1.0'>; title: string; description?: string; … }` | optional | OpenAPI documentation configuration | | **globalMiddleware** | `{ name: string; type: Enum<'authentication' \| 'authorization' \| 'logging' \| 'validation' \| 'transformation' \| 'error' \| 'custom'>; enabled: boolean; order: integer; … }[]` | optional | Global middleware stack | | **cors** | `{ enabled: boolean; origins?: string[]; methods?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>[]; credentials: boolean }` | optional | CORS configuration | -| **performance** | `{ enableCompression: boolean; enableETag: boolean; enableCaching: boolean; defaultCacheTtl: integer }` | optional | Performance optimization settings | +| **performance** | `{ enableCompression: boolean; enableETag: boolean; enableCaching: boolean; defaultCacheTtlSeconds: integer }` | optional | Performance optimization settings | ### Nested Shape: `RestApiPluginConfig.routes[number]` @@ -302,7 +304,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **enableCompression** | `boolean` | optional (default: `true`) | Enable response compression | | **enableETag** | `boolean` | optional (default: `true`) | Enable ETag generation | | **enableCaching** | `boolean` | optional (default: `true`) | Enable HTTP caching | -| **defaultCacheTtl** | `integer` | optional (default: `300`) | Default cache TTL in seconds | +| **defaultCacheTtlSeconds** | `integer` | optional (default: `300`) | Default cache TTL in seconds | +| **defaultCacheTtl** | `never` | optional | [REMOVED] `RestApiPluginConfig.performance.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged. | --- @@ -357,10 +360,12 @@ const result = ErrorHandlingConfigSchema.parse(data); | **tags** | `string[]` | optional | OpenAPI tags for grouping | | **requestSchema** | `string` | optional | Request schema name (for validation) | | **responseSchema** | `string` | optional | Response schema name (for documentation) | -| **timeout** | `integer` | optional | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional | Request timeout in milliseconds | | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | -| **cacheTtl** | `integer` | optional | Cache TTL in seconds | +| **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | ### Nested Shape: `RestApiRouteRegistration.middleware[number]` diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index bfe175a5e8..ece7ec4d51 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -78,7 +78,8 @@ HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST- | **description** | `string` | optional | OpenAPI description | | **public** | `boolean` | optional (default: `false`) | Is publicly accessible | | **permissions** | `string[]` | optional | Required permissions | -| **timeout** | `integer` | optional | Execution timeout in ms | +| **timeoutMs** | `integer` | optional | Execution timeout in ms | +| **timeout** | `never` | optional | [REMOVED] `RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **rateLimit** | `string` | optional | Rate limit policy name | diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index 5838dcb034..e4f1009e47 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -433,10 +433,13 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **url** | `string` | ✅ | WebSocket server URL | | **protocols** | `string[]` | optional | WebSocket sub-protocols | | **reconnect** | `boolean` | optional (default: `true`) | Enable automatic reconnection | -| **reconnectInterval** | `integer` | optional (default: `1000`) | Reconnection interval in milliseconds | +| **reconnectIntervalMs** | `integer` | optional (default: `1000`) | Reconnection interval in milliseconds | | **maxReconnectAttempts** | `integer` | optional (default: `5`) | Maximum reconnection attempts | -| **pingInterval** | `integer` | optional (default: `30000`) | Ping interval in milliseconds | -| **timeout** | `integer` | optional (default: `5000`) | Message timeout in milliseconds | +| **pingIntervalMs** | `integer` | optional (default: `30000`) | Ping interval in milliseconds | +| **timeoutMs** | `integer` | optional (default: `5000`) | Message timeout in milliseconds | +| **reconnectInterval** | `never` | optional | [REMOVED] `WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; the value (milliseconds) is unchanged. | +| **pingInterval** | `never` | optional | [REMOVED] `WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **headers** | `Record` | optional | Custom headers for WebSocket handshake | @@ -719,10 +722,11 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable WebSocket server | | **path** | `string` | optional (default: `"/ws"`) | WebSocket endpoint path | -| **heartbeatInterval** | `number` | optional (default: `30000`) | Heartbeat interval in milliseconds | +| **heartbeatIntervalMs** | `number` | optional (default: `30000`) | Heartbeat interval in milliseconds | | **reconnectAttempts** | `number` | optional (default: `5`) | Maximum reconnection attempts for clients | | **presence** | `boolean` | optional (default: `false`) | Enable presence tracking | | **cursorSharing** | `boolean` | optional (default: `false`) | Enable collaborative cursor sharing | +| **heartbeatInterval** | `never` | optional | [REMOVED] `WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; the value (milliseconds) is unchanged. | --- diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index a0956ec1b2..1d2c8c6c73 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -56,7 +56,7 @@ "api/DataLoaderConfig:cacheEnabled = true", "api/DataLoaderConfig:coalesceRequests = true", "api/DataLoaderConfig:maxBatchSize = 100", - "api/DeviceRequestResponse:interval = 2", + "api/DeviceRequestResponse:intervalSeconds = 2", "api/DispatcherConfig:fallback = \"404\"", "api/DispatcherRoute:authRequired = true", "api/DispatcherRoute:criticality = \"optional\"", @@ -185,13 +185,13 @@ "api/VersioningConfig:strategy = \"urlPath\"", "api/VersioningConfig:urlPrefix = \"/api\"", "api/WebSocketConfig:maxReconnectAttempts = 5", - "api/WebSocketConfig:pingInterval = 30000", + "api/WebSocketConfig:pingIntervalMs = 30000", "api/WebSocketConfig:reconnect = true", - "api/WebSocketConfig:reconnectInterval = 1000", - "api/WebSocketConfig:timeout = 5000", + "api/WebSocketConfig:reconnectIntervalMs = 1000", + "api/WebSocketConfig:timeoutMs = 5000", "api/WebSocketServerConfig:cursorSharing = false", "api/WebSocketServerConfig:enabled = false", - "api/WebSocketServerConfig:heartbeatInterval = 30000", + "api/WebSocketServerConfig:heartbeatIntervalMs = 30000", "api/WebSocketServerConfig:path = \"/ws\"", "api/WebSocketServerConfig:presence = false", "api/WebSocketServerConfig:reconnectAttempts = 5" diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index e03db2020e..a032679976 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -117,7 +117,8 @@ "api/ApiEndpoint:_packageVersion", "api/ApiEndpoint:_provenance", "api/ApiEndpoint:authRequired", - "api/ApiEndpoint:cacheTtl", + "api/ApiEndpoint:cacheTtl [RETIRED]", + "api/ApiEndpoint:cacheTtlSeconds", "api/ApiEndpoint:description", "api/ApiEndpoint:inputMapping", "api/ApiEndpoint:method", @@ -447,7 +448,8 @@ "api/DataLoaderConfig:batchScheduleFn", "api/DataLoaderConfig:cacheEnabled", "api/DataLoaderConfig:cacheKeyFn", - "api/DataLoaderConfig:cacheTtl", + "api/DataLoaderConfig:cacheTtl [RETIRED]", + "api/DataLoaderConfig:cacheTtlSeconds", "api/DataLoaderConfig:coalesceRequests", "api/DataLoaderConfig:maxBatchSize", "api/DataLoaderConfig:maxConcurrency", @@ -493,7 +495,8 @@ "api/DeleteResponse:success", "api/DeviceRequestResponse:code", "api/DeviceRequestResponse:expiresAt", - "api/DeviceRequestResponse:interval", + "api/DeviceRequestResponse:interval [RETIRED]", + "api/DeviceRequestResponse:intervalSeconds", "api/DeviceRequestResponse:verificationUrl", "api/DiffMetaItemResponse:added", "api/DiffMetaItemResponse:changed", @@ -579,7 +582,8 @@ "api/EnhancedApiError:httpStatus", "api/EnhancedApiError:message", "api/EnhancedApiError:requestId", - "api/EnhancedApiError:retryAfter", + "api/EnhancedApiError:retryAfter [RETIRED]", + "api/EnhancedApiError:retryAfterSeconds", "api/EnhancedApiError:retryStrategy", "api/EnhancedApiError:retryable", "api/EnhancedApiError:timestamp", @@ -1449,7 +1453,8 @@ "api/RestApiConfig:requireAuth [RETIRED]", "api/RestApiConfig:responseFormat", "api/RestApiConfig:version", - "api/RestApiEndpoint:cacheTtl", + "api/RestApiEndpoint:cacheTtl [RETIRED]", + "api/RestApiEndpoint:cacheTtlSeconds", "api/RestApiEndpoint:cacheable", "api/RestApiEndpoint:category", "api/RestApiEndpoint:description", @@ -1464,7 +1469,8 @@ "api/RestApiEndpoint:responseSchema", "api/RestApiEndpoint:summary", "api/RestApiEndpoint:tags", - "api/RestApiEndpoint:timeout", + "api/RestApiEndpoint:timeout [RETIRED]", + "api/RestApiEndpoint:timeoutMs", "api/RestApiPluginConfig:basePath", "api/RestApiPluginConfig:cors", "api/RestApiPluginConfig:enabled", @@ -1517,7 +1523,8 @@ "api/RouteDefinition:public", "api/RouteDefinition:rateLimit", "api/RouteDefinition:summary", - "api/RouteDefinition:timeout", + "api/RouteDefinition:timeout [RETIRED]", + "api/RouteDefinition:timeoutMs", "api/RouteGenerationConfig:excludeObjects [RETIRED]", "api/RouteGenerationConfig:includeObjects [RETIRED]", "api/RouteGenerationConfig:nameTransform [RETIRED]", @@ -1802,11 +1809,14 @@ "api/VersioningConfig:versions", "api/WebSocketConfig:headers", "api/WebSocketConfig:maxReconnectAttempts", - "api/WebSocketConfig:pingInterval", + "api/WebSocketConfig:pingInterval [RETIRED]", + "api/WebSocketConfig:pingIntervalMs", "api/WebSocketConfig:protocols", "api/WebSocketConfig:reconnect", - "api/WebSocketConfig:reconnectInterval", - "api/WebSocketConfig:timeout", + "api/WebSocketConfig:reconnectInterval [RETIRED]", + "api/WebSocketConfig:reconnectIntervalMs", + "api/WebSocketConfig:timeout [RETIRED]", + "api/WebSocketConfig:timeoutMs", "api/WebSocketConfig:url", "api/WebSocketEvent:channel", "api/WebSocketEvent:occurredAt", @@ -1815,7 +1825,8 @@ "api/WebSocketEvent:type", "api/WebSocketServerConfig:cursorSharing", "api/WebSocketServerConfig:enabled", - "api/WebSocketServerConfig:heartbeatInterval", + "api/WebSocketServerConfig:heartbeatInterval [RETIRED]", + "api/WebSocketServerConfig:heartbeatIntervalMs", "api/WebSocketServerConfig:path", "api/WebSocketServerConfig:presence", "api/WebSocketServerConfig:reconnectAttempts", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 397795fae6..207b7becff 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -54,7 +54,7 @@ for both corollaries. | `seed` | 12 | 0 | 0 | 0 | 0 | 12 | | `translation` | 23 | 0 | 0 | 0 | 2 | 25 | | `validation` | 15 | 0 | 0 | 3 | 0 | 18 | -| `api` | 25 | 0 | 0 | 0 | 2 | 27 | +| `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 0 | 5 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **92** | **12** | **955** | +| **total** | **845** | **5** | **1** | **93** | **12** | **956** | diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index f8eefb9a3e..8c8b4c986c 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { AuthEndpointPaths, + DeviceRequestResponseSchema, AuthEndpointSchema, AuthEndpointAliases, AuthFeaturesConfigSchema, @@ -173,3 +174,41 @@ describe('getAuthEndpointUrl', () => { ); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('DeviceRequestResponse.interval \u2192 intervalSeconds (#15677)', () => { + const base = { + code: 'ABCD-1234', + verificationUrl: 'https://example.com/device', + expiresAt: '2026-09-05T12:00:00.000Z', + }; + + it('REFUSES the retired `interval` spelling with the rename in the message', () => { + const result = DeviceRequestResponseSchema.safeParse({ ...base, interval: 5 }); + 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( + /`DeviceRequestResponse\.interval` was renamed to `intervalSeconds`/, + ); + }); + + it('records in the prescription that this is NOT an RFC 8628 mirror', () => { + const result = DeviceRequestResponseSchema.safeParse({ ...base, interval: 5 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue!.message).toMatch(/not an RFC 8628 device-authorization payload/); + }); + + it('accepts `intervalSeconds` at the same magnitude, and keeps the default', () => { + const parsed = DeviceRequestResponseSchema.parse({ ...base, intervalSeconds: 5 }); + expect(parsed.intervalSeconds).toBe(5); + expect(parsed).not.toHaveProperty('interval'); + expect(DeviceRequestResponseSchema.parse(base).intervalSeconds).toBe(2); + }); +}); diff --git a/packages/spec/src/api/contract.test.ts b/packages/spec/src/api/contract.test.ts index c796286656..5a220e3d18 100644 --- a/packages/spec/src/api/contract.test.ts +++ b/packages/spec/src/api/contract.test.ts @@ -666,3 +666,29 @@ describe('makeApiErrorSchema (federated ledger, #4805)', () => { expect(parsed.requestId).toBe('req_1'); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('DataLoaderConfig.cacheTtl \u2192 cacheTtlSeconds (#15677)', () => { + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = DataLoaderConfigSchema.safeParse({ cacheTtl: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`DataLoaderConfig\.cacheTtl` was renamed to `cacheTtlSeconds`/, + ); + }); + + it('accepts `cacheTtlSeconds` at the same magnitude, and keeps the min(0) bound', () => { + const parsed = DataLoaderConfigSchema.parse({ cacheTtlSeconds: 60 }); + expect(parsed.cacheTtlSeconds).toBe(60); + expect(parsed).not.toHaveProperty('cacheTtl'); + expect(DataLoaderConfigSchema.safeParse({ cacheTtlSeconds: -1 }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/api/endpoint.test.ts b/packages/spec/src/api/endpoint.test.ts index f4d2f28bcd..c954472f9e 100644 --- a/packages/spec/src/api/endpoint.test.ts +++ b/packages/spec/src/api/endpoint.test.ts @@ -201,13 +201,13 @@ describe('ApiEndpointSchema', () => { windowMs: 60000, maxRequests: 10, }, - cacheTtl: 300, + cacheTtlSeconds: 300, }); expect(endpoint.summary).toBe('Create a new order'); expect(endpoint.inputMapping).toHaveLength(2); expect(endpoint.rateLimit?.enabled).toBe(true); - expect(endpoint.cacheTtl).toBe(300); + expect(endpoint.cacheTtlSeconds).toBe(300); }); it('should accept different HTTP methods', () => { @@ -359,10 +359,10 @@ describe('ApiEndpointSchema', () => { method: 'GET', type: 'object_operation', target: 'data', - cacheTtl: 600, + cacheTtlSeconds: 600, }); - expect(endpoint.cacheTtl).toBe(600); + expect(endpoint.cacheTtlSeconds).toBe(600); }); it('should accept public endpoint (no auth required)', () => { @@ -535,7 +535,7 @@ describe('#5384 — ApiEndpointSchema REJECTS undeclared keys', () => { it.each([ // The three typos the file header names as what the strip used to cost. - ['cacheTTL', 'cacheTtl'], + ['cacheTTL', 'cacheTtlSeconds'], ['objectParam', 'objectParams'], ['outputMappings', 'outputMapping'], // The policy block, where a silent strip is worst. @@ -606,3 +606,47 @@ describe('#5227 — the author state is what `ApiEndpoint` denotes', () => { expect(parsed).toBeDefined(); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('ApiEndpoint.cacheTtl \u2192 cacheTtlSeconds (#15677, ADR-0087 `api-endpoint-cache-ttl-to-cache-ttl-seconds`)', () => { + const base = { + name: 'get_customers', + path: '/api/v1/customers', + method: 'GET' as const, + type: 'object_operation' as const, + }; + + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = ApiEndpointSchema.safeParse({ ...base, cacheTtl: 30 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`ApiEndpoint\.cacheTtl` was renamed to `cacheTtlSeconds`/); + }); + + it('closes with the house `os migrate meta` sentence — the surface IS covered by a conversion', () => { + const result = ApiEndpointSchema.safeParse({ ...base, cacheTtl: 30 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue!.message).toMatch(/os migrate meta --from 17/); + }); + + it('accepts `cacheTtlSeconds` at the same magnitude the retired key carried', () => { + const parsed = ApiEndpointSchema.parse({ ...base, cacheTtlSeconds: 30 }); + expect(parsed.cacheTtlSeconds).toBe(30); + expect(parsed).not.toHaveProperty('cacheTtl'); + }); + + it('tsc channel: `cacheTtl` is unwritable on the ApiEndpoint input type', () => { + // @ts-expect-error — `cacheTtl` is a tombstone (input type `never`); the key is `cacheTtlSeconds` + const bad: ApiEndpoint = { ...base, cacheTtl: 30 }; + expect(bad).toBeDefined(); + const good: ApiEndpoint = { ...base, cacheTtlSeconds: 30 }; + expect(good.cacheTtlSeconds).toBe(30); + }); +}); diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index cfbe848c6d..e36de711d1 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -107,7 +107,10 @@ export const ApiEndpointSchema = strictObject({ aliases: { // Policy block — the highest-consequence misses on this surface. auth: 'authRequired', authentication: 'authRequired', requiresAuth: 'authRequired', - cacheTTL: 'cacheTtl', ttl: 'cacheTtl', cache: 'cacheTtl', + // Retargeted onto `cacheTtlSeconds` by #15677: an alias must point at a key + // the schema really accepts, and `cacheTtl` is now a tombstone that accepts + // nothing (`check:alias-integrity` / alias-integrity.test.ts enforce this). + cacheTTL: 'cacheTtlSeconds', ttl: 'cacheTtlSeconds', cache: 'cacheTtlSeconds', rateLimiting: 'rateLimit', throttle: 'rateLimit', // Identity / routing. id: 'name', url: 'path', route: 'path', endpoint: 'path', uri: 'path', diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts index 1e0fdc3def..60b6af343c 100644 --- a/packages/spec/src/api/errors.test.ts +++ b/packages/spec/src/api/errors.test.ts @@ -386,3 +386,37 @@ describe('EnhancedApiErrorSchema.fieldErrors retirement (ADR-0114 D4)', () => { expect(parsed.fields).toHaveLength(1); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('EnhancedApiError.retryAfter \u2192 retryAfterSeconds (#15677 \u2014 BREAKING on the ADR-0112 wire envelope)', () => { + const base = { code: 'RATE_LIMIT_EXCEEDED' as const, message: 'Rate limit exceeded' }; + + it('REFUSES the retired `retryAfter` spelling with the rename in the message', () => { + const result = EnhancedApiErrorSchema.safeParse({ ...base, retryAfter: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retryAfter'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`EnhancedApiError\.retryAfter` was renamed to `retryAfterSeconds`/, + ); + }); + + it('says in the prescription that the HTTP `Retry-After` header is a separate, unchanged surface', () => { + const result = EnhancedApiErrorSchema.safeParse({ ...base, retryAfter: 60 }); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retryAfter'); + // The next reader must not "fix" the RFC 9110 header to match the envelope. + expect(issue!.message).toMatch(/HTTP `Retry-After` response header \u2014 that header keeps its RFC 9110 name/); + }); + + it('accepts `retryAfterSeconds` at the same magnitude the retired key carried', () => { + const parsed = EnhancedApiErrorSchema.parse({ ...base, retryAfterSeconds: 60 }); + expect(parsed.retryAfterSeconds).toBe(60); + expect(parsed).not.toHaveProperty('retryAfter'); + }); +}); diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index b394cb99dc..8ec12281ae 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -684,3 +684,78 @@ describe('plugin-rest-api.zod', () => { }); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('RestApiEndpoint / RestApiPluginConfig durations carry their unit (#15677)', () => { + const endpoint = { + method: 'GET' as const, path: '/api/v1/discovery', + handler: 'getDiscovery', category: 'discovery' as const, + }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = RestApiEndpointSchema.safeParse({ ...endpoint, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RestApiEndpoint\.timeout` was renamed to `timeoutMs`/); + }); + + it('REFUSES the retired `cacheTtl` spelling with the rename in the message', () => { + const result = RestApiEndpointSchema.safeParse({ ...endpoint, cacheTtl: 3600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cacheTtl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RestApiEndpoint\.cacheTtl` was renamed to `cacheTtlSeconds`/); + }); + + it('names the OTHER unit in each prescription — the pair is why this rename exists', () => { + const t = RestApiEndpointSchema.safeParse({ ...endpoint, timeout: 1 }) + .error!.issues.find((i) => i.path.join('.') === 'timeout')!; + const c = RestApiEndpointSchema.safeParse({ ...endpoint, cacheTtl: 1 }) + .error!.issues.find((i) => i.path.join('.') === 'cacheTtl')!; + expect(t.message).toMatch(/cache TTL two lines below is in SECONDS/); + expect(c.message).toMatch(/request timeout two lines above is in MILLISECONDS/); + }); + + it('accepts the suffixed endpoint keys at the same magnitudes', () => { + const parsed = RestApiEndpointSchema.parse({ ...endpoint, timeoutMs: 30000, cacheTtlSeconds: 3600 }); + expect(parsed.timeoutMs).toBe(30000); + expect(parsed.cacheTtlSeconds).toBe(3600); + expect(parsed).not.toHaveProperty('timeout'); + expect(parsed).not.toHaveProperty('cacheTtl'); + }); + + it('REFUSES `performance.defaultCacheTtl` — a tombstone inside a LIVE block', () => { + const result = RestApiPluginConfigSchema.safeParse({ + routes: [], + performance: { enableCompression: true, defaultCacheTtl: 600 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'performance.defaultCacheTtl', + ); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`RestApiPluginConfig\.performance\.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds`/, + ); + }); + + it('parses the live siblings of that tombstone, and keeps the 300 default', () => { + const config = RestApiPluginConfigSchema.parse({ + routes: [], + performance: { enableCompression: false, defaultCacheTtlSeconds: 600 }, + }); + expect(config.performance?.enableCompression).toBe(false); + expect(config.performance?.defaultCacheTtlSeconds).toBe(600); + expect(RestApiPluginConfigSchema.parse({ routes: [], performance: {} }) + .performance?.defaultCacheTtlSeconds).toBe(300); + }); +}); diff --git a/packages/spec/src/api/router.test.ts b/packages/spec/src/api/router.test.ts index 447cc5ddf0..6235a4787e 100644 --- a/packages/spec/src/api/router.test.ts +++ b/packages/spec/src/api/router.test.ts @@ -198,10 +198,10 @@ describe('RouteDefinitionSchema', () => { method: 'POST', path: '/api/batch', handler: 'batch_process', - timeout: 30000, + timeoutMs: 30000, }); - expect(route.timeout).toBe(30000); + expect(route.timeoutMs).toBe(30000); }); it('should accept route with rate limit', () => { @@ -220,11 +220,11 @@ describe('RouteDefinitionSchema', () => { method: 'POST', path: '/api/heavy-operation', handler: 'heavy_handler', - timeout: 60000, + timeoutMs: 60000, rateLimit: 'moderate', }); - expect(route.timeout).toBe(60000); + expect(route.timeoutMs).toBe(60000); expect(route.rateLimit).toBe('moderate'); }); }); @@ -253,7 +253,7 @@ describe('RouteDefinitionSchema', () => { description: 'Creates a new order in the system', public: false, permissions: ['orders.create'], - timeout: 5000, + timeoutMs: 5000, }; expect(() => RouteDefinitionSchema.parse(route)).not.toThrow(); @@ -555,3 +555,28 @@ describe('Integration Tests', () => { }); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('RouteDefinition.timeout \u2192 timeoutMs (#15677)', () => { + const base = { method: 'GET' as const, path: '/api/test', handler: 'test_handler' }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = RouteDefinitionSchema.safeParse({ ...base, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`RouteDefinition\.timeout` was renamed to `timeoutMs`/); + }); + + it('accepts `timeoutMs` at the same magnitude the retired key carried', () => { + const parsed = RouteDefinitionSchema.parse({ ...base, timeoutMs: 30000 }); + expect(parsed.timeoutMs).toBe(30000); + expect(parsed).not.toHaveProperty('timeout'); + }); +}); diff --git a/packages/spec/src/api/websocket.test.ts b/packages/spec/src/api/websocket.test.ts index 739cd3aef3..230275f233 100644 --- a/packages/spec/src/api/websocket.test.ts +++ b/packages/spec/src/api/websocket.test.ts @@ -22,6 +22,7 @@ import { PongMessageSchema, WebSocketMessageSchema, WebSocketConfigSchema, + WebSocketServerConfigSchema, type EventSubscription, type PresenceState, type CursorPosition, @@ -701,3 +702,62 @@ describe('WebSocketConfigSchema', () => { })).toThrow(); }); }); + +// #15677 (stack card 2/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spellings are `retiredKey()` tombstones, +// so the refusal carries the RENAME (the prescription IS the payload) rather +// than a bare unrecognized-key error, and the value survives at the same +// magnitude. Asserting the message, not just `.toThrow()`: a bare throw stays +// green when the schema throws for some unrelated reason. +describe('WebSocket durations carry their unit (#15677)', () => { + const url = 'wss://example.com/ws'; + + it.each([ + ['reconnectInterval', 'reconnectIntervalMs', 2000], + ['pingInterval', 'pingIntervalMs', 60000], + ['timeout', 'timeoutMs', 10000], + ])('REFUSES the retired `%s` with the rename to `%s` in the message', (old, next, value) => { + const result = WebSocketConfigSchema.safeParse({ url, [old]: value }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === old); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`WebSocketConfig.${old}\` was renamed to \`${next}\``); + }); + + it('accepts the three suffixed client keys at the same magnitudes', () => { + const parsed = WebSocketConfigSchema.parse({ + url, reconnectIntervalMs: 2000, pingIntervalMs: 60000, timeoutMs: 10000, + }); + expect(parsed.reconnectIntervalMs).toBe(2000); + expect(parsed.pingIntervalMs).toBe(60000); + expect(parsed.timeoutMs).toBe(10000); + expect(parsed).not.toHaveProperty('reconnectInterval'); + }); + + it('keeps the positive-integer bound on the renamed keys', () => { + expect(WebSocketConfigSchema.safeParse({ url, reconnectIntervalMs: -1000 }).success).toBe(false); + expect(WebSocketConfigSchema.safeParse({ url, pingIntervalMs: 0 }).success).toBe(false); + }); + + it('REFUSES `WebSocketServerConfig.heartbeatInterval` with the rename in the message', () => { + const result = WebSocketServerConfigSchema.safeParse({ heartbeatInterval: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'heartbeatInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch( + /`WebSocketServerConfig\.heartbeatInterval` was renamed to `heartbeatIntervalMs`/, + ); + }); + + it('accepts `heartbeatIntervalMs` and keeps the 30000 default; the COUNT neighbour is untouched', () => { + const parsed = WebSocketServerConfigSchema.parse({ heartbeatIntervalMs: 15000 }); + expect(parsed.heartbeatIntervalMs).toBe(15000); + expect(parsed).not.toHaveProperty('heartbeatInterval'); + const defaults = WebSocketServerConfigSchema.parse({}); + expect(defaults.heartbeatIntervalMs).toBe(30000); + // `reconnectAttempts` is a COUNT, not a duration — it keeps its bare name. + expect(defaults.reconnectAttempts).toBe(5); + }); +}); diff --git a/packages/spec/src/kernel/metadata-type-api-registration.test.ts b/packages/spec/src/kernel/metadata-type-api-registration.test.ts index 57325c4c41..d6836cb849 100644 --- a/packages/spec/src/kernel/metadata-type-api-registration.test.ts +++ b/packages/spec/src/kernel/metadata-type-api-registration.test.ts @@ -59,7 +59,7 @@ const SHOWCASE_TASK_FEED = { target: 'showcase_task', objectParams: { object: 'showcase_task', operation: 'find' }, authRequired: true, - cacheTtl: 30, + cacheTtlSeconds: 30, } as const; /** The flow-typed half of the same stack. */ diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index 99267f6004..c462cdf3f8 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -129,7 +129,7 @@ export const leadFeed: ApiEndpoint = { type: 'object_operation', objectParams: { object: 'acme_lead', operation: 'find' }, // `authRequired` omitted → defaults to `true`. Omission is SAFE. - cacheTtl: 30, // seconds; GET-only; success answers only + cacheTtlSeconds: 30, // GET-only; rides success answers only }; ``` From 8cd4d8cbac040d8b4187181a3e6996e900b9ec97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:16:59 +0000 Subject: [PATCH 09/33] docs(changeset): the twelve api/ duration renames (#15677) @objectstack/spec minor with the BREAKING banner naming every renamed key, the six adr-0087 ids registered, the retryAfter wire note, and the disposition split (one D2 conversion, five semantic entries). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../api-duration-keys-unit-in-key-name.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .changeset/api-duration-keys-unit-in-key-name.md diff --git a/.changeset/api-duration-keys-unit-in-key-name.md b/.changeset/api-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..cbea5238b1 --- /dev/null +++ b/.changeset/api-duration-keys-unit-in-key-name.md @@ -0,0 +1,95 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": patch +--- + +feat(spec)!: the twelve `api/` duration keys carry their unit in the key name (#15677, ruling B on #14478) + + + +**BREAKING** — twelve published `api/` duration keys are renamed and tombstoned. +Shipped as `minor` under the repo's launch-window convention for breaking +changes; the hand-migration prescriptions are registered under protocol major +18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions; this card clears the `api/` directory against it. Measured with the +gate itself: `src/api/**` goes from 12 offenders to **0**, and the whole-tree +count falls **48 → 36**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `ApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | +| `DataLoaderConfig.cacheTtl` | `cacheTtlSeconds` | seconds | +| `DeviceRequestResponse.interval` | `intervalSeconds` | seconds | +| `EnhancedApiError.retryAfter` | `retryAfterSeconds` | seconds | +| `RestApiEndpoint.timeout` | `timeoutMs` | milliseconds | +| `RestApiEndpoint.cacheTtl` | `cacheTtlSeconds` | seconds | +| `RestApiPluginConfig.performance.defaultCacheTtl` | `defaultCacheTtlSeconds` | seconds | +| `RouteDefinition.timeout` | `timeoutMs` | milliseconds | +| `WebSocketConfig.reconnectInterval` | `reconnectIntervalMs` | milliseconds | +| `WebSocketConfig.pingInterval` | `pingIntervalMs` | milliseconds | +| `WebSocketConfig.timeout` | `timeoutMs` | milliseconds | +| `WebSocketServerConfig.heartbeatInterval` | `heartbeatIntervalMs` | milliseconds | + +**Every value is unchanged** — only key names move. Every old spelling is a +`retiredKey()` tombstone, so it fails `tsc` at the authoring site (input type +`never`) and fails the parse with the rename prescription rather than a bare +unrecognized-key error. + +## ⚠️ `ApiError.retryAfter` — the wire envelope, and what it does NOT touch + +Ruling B put this key explicitly in scope with its own BREAKING note: the +runtime-emitted measurements are read by humans and agents even though nobody +authors them. A consumer meets two retry-after values on one 429 — this +ADR-0112 envelope field, always delta-seconds, and the HTTP `Retry-After` +header, which per RFC 9110 §10.2.3 may carry delta-seconds **or** an HTTP-date. +Spelled identically they read as one value in two places. + +**The HTTP `Retry-After` response header is a separate, unchanged surface.** Its +name is fixed outside this repo and nothing here touches it. Do not "fix" the +header to match the envelope, and do not read a surviving `retry-after` in +transport code as leftover work. + +## Dispositions — one D2 conversion, five semantic entries + +Justified per key rather than defaulted. **`ApiEndpoint.cacheTtl` is the only +one of the twelve that gets an ADR-0087 D2 conversion** +(`api-endpoint-cache-ttl-to-cache-ttl-seconds`), because `apis:` is a stack +collection (`apis: z.array(ApiEndpointSchema)`) and `api` is a registered +metadata kind stored as a row, so the conversion chain has a seam that sees it. +`os migrate meta --from 17` lists the mechanical edits. + +The other eleven are wire payloads and construction arguments — a device-flow +response body, an error envelope, REST-plugin route registration, a batch-loader +config, a router registration, WebSocket client/server configuration. None is +ever a stack collection member or a `sys_metadata` row, so no conversion seam +runs on them and each carries a **semantic** entry instead: this is the +disposition `api/RestApiEndpoint:handlerStatus` already holds on one of these +very shapes, and what ruling B prescribes for a runtime-emitted key. + +## `DeviceRequestResponse.interval` is a rename, not an external-vocabulary mirror + +Attributed to RFC 8628 by the campaign card; the attribution fails against the +schema's own evidence. `DeviceRequestResponseSchema` does not mirror RFC 8628 as +a set — `code` is not `device_code`, `verificationUrl` is not +`verification_uri`, `expiresAt` is not `expires_in` (a different name *and* a +different type, an ISO-8601 instant where the RFC carries a relative lifetime). +A schema that already renames every RFC field it carries into house style cannot +claim the standard fixes the one name it left bare. Renamed rather than marked +deliberately: a wrongly marked key is exempted permanently and silently, while a +wrongly renamed one is visible. + +## Readers moved in the same PR, at the same magnitude + +`@objectstack/runtime`'s policy chain (`computeCacheControl` now reads +`endpoint.cacheTtlSeconds`), the publish gate's issue path +(`apis.N.cacheTtlSeconds`), the built-in REST route tables, the showcase +example, dogfood fixtures, `liveness/api.json` (renamed row plus a `dead` +tombstone row) and the `objectstack-api` skill. The `ApiEndpoint` alias table is +retargeted onto the live key — an alias must point at a key the schema really +accepts, and `cacheTtl` now accepts nothing. From d7ebd6c7ad29935918ed039476ea6100c887a543 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:36:19 +0000 Subject: [PATCH 10/33] fix(docs-audit): declare the conversion-replay exclusion kind (b) relied on incidentally (#15677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ADR-0087 conversion fixture copies a routable metadata kind verbatim, so an `apis:` fixture carries `method:` beside `path:` — because that is what an ApiEndpoint IS. Ruling A named conversions/registry.ts as the guard's target but enforced it with requireMethodSignal, a content proxy that held only while no conversion fixture carried a verb. This card's apis: conversion is the first that does, and the live pin red exactly as designed. The fixture is correct and stays. The exclusion moves to CONVERSION_REPLAY_FILE_RE, which states the structural fact instead of testing a symptom, and three cases pin the new guard as load-bearing rather than incidental in its turn. NOT restricting kind (b) to packages/spec/src/api/**: that is the invariant the live pin asserts, and enforcing it in the walk would make that pin true by construction — a check that cannot fail. Measured tail-neutral: the scan census is byte-identical to the base (17 route sources, 12 call sites, 5 contract declarations, 78 tails, 61 reachable). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- scripts/docs-audit/affected-docs.mjs | 62 +++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index baa50df4f7..9285076a38 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -638,6 +638,36 @@ const CALL_SITE_FILE_RE = /(?:^|\/)(?:[\w.-]*route[\w.-]*|[\w.-]*-server)\.ts$/; /** Route LEDGERS — the declared `route` ⟷ `client` tables the `sdk` anchor rides on. */ const LEDGER_FILE_RE = /(?:^|\/)[\w.-]*route-ledger\.ts$/; +/** + * The ADR-0087 CONVERSION CHAIN — replay data, never a route surface (#15677). + * + * Every conversion carries a `fixture: { before, after }` pair: literal stack documents the + * chain replays in `migrations.test.ts`. When the converted collection is a ROUTABLE + * metadata kind those documents are faithful copies of that kind — an `apis:` member is an + * `ApiEndpoint`, which really does declare a `method:` beside a `path:`, because that is + * what the kind IS. So the fixture reads to kind (b) exactly like the contract declaration + * it is a copy of, while serving nothing: the tail is a document's content, not a route + * this repo answers on. + * + * ⛔ THIS EXCLUSION IS NOW DECLARED, AND IT WAS NOT BEFORE. Ruling A (2026-09-04, batch + * #31) named `conversions/registry.ts` as the guard's target and excluded it with a proxy + * — `requireMethodSignal`, "is there an HTTP verb next to the path" — which held only + * because no conversion fixture had yet carried one. #15677's `apis:` conversion is the + * first that does, and the live pin below reds rather than silently minting a phantom + * route source, which is precisely what that pin exists for. The proxy was never wrong + * about the INTENT; it was a content test standing in for a structural fact, and the + * fixture that defeats it is a correct fixture. Naming the directory states the fact + * directly, so the next conversion over a routable kind costs nothing. + * + * Deliberately NOT the other available fix — restricting kind (b) to + * `packages/spec/src/api/**`. That is the invariant the live pin "every contract + * declaration admitted is a packages/spec API declaration" ASSERTS, and enforcing it in + * the walk would make that pin true by construction: a check that cannot fail, over the + * one population this route is most likely to widen by accident. The pin is worth more + * than the tidier rule. + */ +const CONVERSION_REPLAY_FILE_RE = /(?:^|\/)packages\/spec\/src\/conversions\//; + /** * THE selection rule of the `sdk` bridge, defined once: a registrar tail selects a ledger * row when the row's wire path ends with it (the `GET ` prefix is stripped first, which @@ -2157,9 +2187,13 @@ function scanRouteSurface() { // be present in the raw text for any tail to exist. // // ⛔ A LEDGER IS NOT A ROUTE SOURCE and a call site is not counted twice — both are - // skipped here, so `routeSources` partitions cleanly by kind. + // skipped here, so `routeSources` partitions cleanly by kind. ⛔ NEITHER IS REPLAY DATA: + // a conversion fixture copies a routable metadata kind verbatim, verb and all, so it is + // excluded structurally rather than left to the method-signal proxy (#15677 — see + // `CONVERSION_REPLAY_FILE_RE`). for (const rel of sourceFiles) { if (LEDGER_FILE_RE.test(rel) || CALL_SITE_FILE_RE.test(rel)) continue; + if (CONVERSION_REPLAY_FILE_RE.test(rel)) continue; let text; try { text = readFileSync(join(repoRoot, rel), 'utf8'); } catch { continue; } if (!text.includes('path')) continue; @@ -5614,6 +5648,16 @@ function selfTest() { const liveKind = (k) => live.routeSources.filter((r) => r.kind === k).map((r) => r.file); // (1) The guard's target, on the real file rather than a reduced fixture. + // + // ⭐ WHICH guard, updated #15677. This case was written when the exclusion rode on + // `requireMethodSignal` alone — a CONTENT proxy for a STRUCTURAL fact, sound only + // while no conversion fixture carried an HTTP verb. #15677's `apis:` conversion is + // the first that does (an `ApiEndpoint` fixture declares `method:` beside `path:`, + // because that is what the kind is), and this pin RED — doing exactly its job, ahead + // of a phantom route source reaching the census. The fixture is correct and stays; + // the exclusion moved to `CONVERSION_REPLAY_FILE_RE`, which states the fact instead + // of testing a symptom. So this pin now reads: the file is out because replay data is + // declared not to be a route surface, not because its contents happen to lack a verb. check('scanRouteSurface', 'the connector-action input is NOT admitted as a route source', 'packages/spec/src/conversions/registry.ts', false, live.routeSources.some((r) => r.file === 'packages/spec/src/conversions/registry.ts')); @@ -5624,6 +5668,22 @@ function selfTest() { // that would admit it, and the guard is the only thing that does not. check('scanRouteSurface', 'counterfactual: unguarded, that real file WOULD be admitted — the guard is what excludes it', 'registry.ts tails, unguarded', true, registryText === null || parseRouteSource(registryText).size > 0); + // ⭐ AND THE NEW GUARD IS THE LOAD-BEARING ONE, pinned rather than assumed (#15677). + // The counterfactual above survives on the OLD proxy too, so on its own it would keep + // passing if the directory guard were deleted. This case is the one that would not: + // the real file's tails survive `requireMethodSignal`, so the method proxy no longer + // excludes it and `CONVERSION_REPLAY_FILE_RE` is the only thing that does. The day + // someone deletes that guard as "redundant", this reds. + check('scanRouteSurface', 'and the METHOD proxy alone no longer excludes it — the directory guard is load-bearing', + 'registry.ts tails, method-guarded', true, + registryText === null || parseRouteSource(registryText, { requireMethodSignal: true }).size > 0); + check('CONVERSION_REPLAY_FILE_RE', 'which is what the directory guard matches', + 'packages/spec/src/conversions/registry.ts', true, + CONVERSION_REPLAY_FILE_RE.test('packages/spec/src/conversions/registry.ts')); + // …and it is NARROW: a sibling spec directory is untouched by it. + check('CONVERSION_REPLAY_FILE_RE', 'and it does not reach the api declarations kind (b) exists to admit', + 'packages/spec/src/api/storage.zod.ts', false, + CONVERSION_REPLAY_FILE_RE.test('packages/spec/src/api/storage.zod.ts')); // (2) THE CONTRACT KIND ADMITS SOMETHING — the anti-vacuity floor, and the case that // names kind (b) when it stops running. ⚠️ Without it the `every()` below passes on an From dc3b8471d8f4d79f4def7087cf0dbf92c4ad7e08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:13:44 +0000 Subject: [PATCH 11/33] docs: move the hand-written pages onto the renamed keys, and strip the issue ids (#15677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lap 1 regenerated content/docs/references/** but left the HAND-WRITTEN pages teaching the old spellings. Three of them carried `os:check` blocks authoring `cacheTtl`, so check:skill-examples was RED and lap 1 never ran it — it sits in check:generated's "not run here" list and I did not run it separately. cacheTtl -> cacheTtlSeconds: 14 occurrences on 13 lines, all the ApiEndpoint key. retryAfter -> retryAfterSeconds: 14 occurrences, the ADR-0112 envelope field only. Deliberately NOT swept, each verified rather than assumed: - the HTTP `Retry-After` response header (6 locals over 4 sites) — RFC 9110, a separate surface, and the thing the tombstone prose exists to protect; - `retry_after` as a RetryStrategy ENUM VALUE (errors.zod.ts z.enum); - `details.retry_after` on the wire, and the pre-existing `details.retryAfterSeconds` the runtime really emits (endpoint-policy.ts). Also strips `(#14478 ruling B)` from the twelve tombstone prescriptions THIS card wrote: check:doc-authoring forbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12), and the campaign's own earlier tombstones already comply. The version and the FROM -> TO mapping stay — those are the durable references AGENTS.md requires. Measured: the gate read 4 findings on the base and 16 on my head; it now reads the base's 4 again, so this PR adds none. Those 4 are card 1/6's (PR #15814) and are not mine to touch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/api/declarative-endpoints.mdx | 13 +++++++------ content/docs/api/error-catalog.mdx | 12 ++++++------ content/docs/api/error-handling-client.mdx | 14 +++++++------- content/docs/getting-started/quick-reference.mdx | 4 ++-- content/docs/protocol/kernel/http-protocol.mdx | 12 ++++++------ content/docs/references/api/auth-endpoints.mdx | 2 +- content/docs/references/api/contract.mdx | 4 ++-- content/docs/references/api/endpoint.mdx | 2 +- content/docs/references/api/errors.mdx | 4 ++-- content/docs/references/api/plugin-rest-api.mdx | 10 +++++----- content/docs/references/api/router.mdx | 2 +- content/docs/references/api/websocket.mdx | 8 ++++---- packages/spec/src/api/auth-endpoints.zod.ts | 4 ++-- packages/spec/src/api/contract.zod.ts | 4 ++-- packages/spec/src/api/endpoint.zod.ts | 4 ++-- packages/spec/src/api/errors.zod.ts | 4 ++-- packages/spec/src/api/plugin-rest-api.zod.ts | 10 +++++----- packages/spec/src/api/router.zod.ts | 4 ++-- packages/spec/src/api/websocket.zod.ts | 12 ++++++------ 19 files changed, 65 insertions(+), 64 deletions(-) diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index 4d59c14167..e4ae65983a 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -72,7 +72,7 @@ export default defineStack({ // `type: 'flow'`, as `acme_lead_intake` below shows. objectParams: { object: 'acme_lead', operation: 'find' }, // Omitting `authRequired` is the safe spelling — it defaults to `true`. - cacheTtl: 30, + cacheTtlSeconds: 30, }, { name: 'acme_lead_intake', @@ -95,7 +95,7 @@ refused is refused before you deploy. A declared endpoint is **not a registered route**. It is matched in the dispatcher's unmatched-request seam, which is what makes it structurally impossible for your declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `authRequired` → -`cacheTtl`) → delegation to an existing pipeline: +`cacheTtlSeconds`) → delegation to an existing pipeline: | `type` | Delegates to | Request shape | |:---|:---|:---| @@ -188,12 +188,13 @@ mapping refusals out of this family rather than nesting them. - An **armed** budget must be usable: `maxRequests` above `0` and `windowMs` above `0`. A zero-or-negative allowance rejects every request including your own health checks, and the runtime fails closed on it rather than serving unmetered. -- `cacheTtl` is seconds and cannot be negative. -- `cacheTtl` is **GET-only**. It becomes a `Cache-Control` header on a successful answer, +- `cacheTtlSeconds` cannot be negative — the key carries its unit, so there is no + second place for it to disagree with. +- `cacheTtlSeconds` is **GET-only**. It becomes a `Cache-Control` header on a successful answer, and a non-GET answer is not a cacheable representation, so on any other method the key could never take effect and is refused instead of ignored. -`cacheTtl: 0` is not the same as omitting the key: `0` emits `Cache-Control: no-store` +`cacheTtlSeconds: 0` is not the same as omitting the key: `0` emits `Cache-Control: no-store` (saying "never store this"), while omitting it sends no caching header at all. A positive ttl emits `private, max-age=` — `private` is a security rule and not a tuning choice, because any answer can be RLS-trimmed for its caller and a shared cache must never hand @@ -243,7 +244,7 @@ export const partnerWebhook: ApiEndpoint = { What that budget buys you, and what it does not: -- **Metering runs before the auth gate** (`rateLimit` → `authRequired` → `cacheTtl`). That +- **Metering runs before the auth gate** (`rateLimit` → `authRequired` → `cacheTtlSeconds`). That order is deliberate: the traffic that most needs a budget — credential stuffing, scraping — is exactly the traffic that ends in a `401`, so a denied request still spends a token. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index dc208c30e3..c272559f2c 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -44,7 +44,7 @@ ObjectStack uses a structured error system with **9 error categories** and **50 | `no_retry` | Do not retry the request | Validation errors, permission denied | | `retry_immediate` | Retry immediately | Transient network errors | | `retry_backoff` | Retry with exponential backoff | Rate limits, server errors | -| `retry_after` | Wait the specified `retryAfter` seconds | Rate limit with explicit cooldown | +| `retry_after` | Wait the `retryAfterSeconds` the envelope carries | Rate limit with explicit cooldown | --- @@ -387,12 +387,12 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping). ### `RATE_LIMIT_EXCEEDED` **Cause:** Too many requests in the current time window. -**Fix:** Reduce request frequency. Check the `retryAfter` field for the wait time. +**Fix:** Reduce request frequency. Check the `retryAfterSeconds` field for the wait. **Retry:** `retry_after` ### `QUOTA_EXCEEDED` **Cause:** The API usage quota for the current period has been exhausted. -**Fix:** Wait for the quota to reset (check `retryAfter`), or upgrade the plan. +**Fix:** Wait for the quota to reset (check `retryAfterSeconds`), or upgrade the plan. **Retry:** `retry_after` ### `CONCURRENT_LIMIT_EXCEEDED` @@ -648,7 +648,7 @@ interface EnhancedApiError { httpStatus?: number; // HTTP status code retryable: boolean; // Whether retry may succeed retryStrategy?: RetryStrategy; // Recommended retry approach - retryAfter?: number; // Seconds to wait (for rate limits) + retryAfterSeconds?: number; // Wait before retrying (for rate limits) details?: unknown; // Additional error context fields?: FieldError[]; // One entry per offending value timestamp?: string; // ISO 8601 timestamp @@ -714,7 +714,7 @@ reading a field no server sent — move to `error.fields`. The `@objectstack/client` SDK's built-in fetch error handling attaches `code`, `category`, `httpStatus`, `retryable`, `details`, and — for validation -failures — `fields`. It does **not** attach `retryAfter` or `requestId` +failures — `fields`. It does **not** attach `retryAfterSeconds` or `requestId` directly; if your server populates those on the response body, read them from `apiError.details` until the client surfaces them at the top level. @@ -766,7 +766,7 @@ async function handleApiCall() { case 'rate_limit': // Wait and retry - const waitTime = apiError.retryAfter ?? 60; + const waitTime = apiError.retryAfterSeconds ?? 60; await sleep(waitTime * 1000); return handleApiCall(); diff --git a/content/docs/api/error-handling-client.mdx b/content/docs/api/error-handling-client.mdx index 1b726b4bda..8601015e6f 100644 --- a/content/docs/api/error-handling-client.mdx +++ b/content/docs/api/error-handling-client.mdx @@ -28,7 +28,7 @@ interface ErrorResponse { httpStatus?: number; // HTTP status code retryable?: boolean; // Whether the request can be retried retryStrategy?: string; - retryAfter?: number; // Seconds to wait before retrying (rate limits) + retryAfterSeconds?: number; // Wait before retrying (rate limits) // One entry per offending value. Named `fields` on the wire AND in the spec // contract since ADR-0114 D4; the old `fieldErrors` is tombstoned. fields?: Array<{ @@ -69,7 +69,7 @@ import type { ErrorResponse } from '@objectstack/spec/api'; class ObjectStackError extends Error { code: string; status: number; - retryAfter?: number; + retryAfterSeconds?: number; fields: ErrorResponse['error']['fields']; details: ErrorResponse['error']['details']; requestId?: string; @@ -79,7 +79,7 @@ class ObjectStackError extends Error { this.name = 'ObjectStackError'; this.code = response.error.code; this.status = response.error.httpStatus ?? 0; - this.retryAfter = response.error.retryAfter; + this.retryAfterSeconds = response.error.retryAfterSeconds; this.fields = response.error.fields; this.details = response.error.details; this.requestId = response.error.requestId ?? response.meta?.requestId; @@ -241,7 +241,7 @@ Not all errors should be retried. Use this decision matrix: | Authentication errors | 401 | ⚠️ Once | Refresh token, then retry | | Permission errors | 403 | ❌ No | Show access denied message | | Not found | 404 | ❌ No | Show not found message | -| Rate limited | 429 | ✅ Yes | Wait for `retryAfter` seconds, then retry | +| Rate limited | 429 | ✅ Yes | Wait `retryAfterSeconds`, then retry | | Server errors | 500 | ✅ Yes | Exponential backoff | | Network errors | 0 | ✅ Yes | Exponential backoff | @@ -266,12 +266,12 @@ async function withRetry( } // Rate limited: honor the server's retry hint. - // `retryAfter` is in seconds; some responses instead carry an + // `retryAfterSeconds` names its own unit; some responses instead carry an // absolute `details.resetAt` timestamp. if (error.isRateLimit) { const resetAt = (error.details as any)?.resetAt; - const waitMs = error.retryAfter != null - ? error.retryAfter * 1000 + const waitMs = error.retryAfterSeconds != null + ? error.retryAfterSeconds * 1000 : resetAt ? Math.max(new Date(resetAt).getTime() - Date.now(), 1000) : baseDelay; diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 2c6c34f213..f5488e77ef 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -310,7 +310,7 @@ is gated at publish and, once it passes, serves real traffic. Full contract: | **Types that execute** | `object_operation` (needs `objectParams.object` + `.operation`) and `flow` (needs `target`). `script` / `proxy` are rejected at publish | | **`authRequired`** | defaults to `true` — **omitting it is safe**. An explicit `false` is the only thing that opens anonymous access | | **`authRequired: false`** | REQUIRES an armed budget, `rateLimit: { enabled: true, windowMs, maxRequests }` (ADR-0121 D6) — `enabled` itself defaults to `false`, so a budget without it meters nothing | -| **`cacheTtl`** | seconds, GET-only, applied to successful answers only (`Cache-Control: private, max-age=`) | +| **`cacheTtlSeconds`** | GET-only, applied to successful answers only (`Cache-Control: private, max-age=`) | {/* os:check */} ```typescript @@ -326,7 +326,7 @@ export const leadFeed: ApiEndpoint = { // `target` is required (at publish) only for `type: 'flow'`. objectParams: { object: 'acme_lead', operation: 'find' }, // `authRequired` omitted → defaults to true (a session is required). - cacheTtl: 30, + cacheTtlSeconds: 30, }; ``` diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 1cf488689c..1e3a46a755 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1198,8 +1198,8 @@ export default defineStack({ objectParams: { object: 'acme_lead', operation: 'find' }, // Defaults to `true`. Omitting it is safe; see the policy table below. authRequired: true, - // Seconds. GET-only, and only ever on a successful answer. - cacheTtl: 30, + // GET-only, and only ever on a successful answer. The unit is in the key. + cacheTtlSeconds: 30, }, ], }); @@ -1213,7 +1213,7 @@ declaration to shadow a built-in route: 1. **Match** — the request path must be under `/apps/`, and `METHOD` + path (one trailing slash trimmed) must hit exactly one declaration. -2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtl`, in that order. Metering +2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtlSeconds`, in that order. Metering runs *before* the auth gate on purpose: the traffic that most needs a budget (credential stuffing, scraping) is exactly the traffic that ends in a 401, so a denied request still spends a token. @@ -1228,8 +1228,8 @@ declaration to shadow a built-in route: | `type: 'flow'` | delegated to the same automation pipeline as `POST /api/v1/automation/{name}/trigger` — the same execution context builder, the same `execute` call, and **the same response contract**: a refused or failed run is classified into the same real status codes (404 / 409 `FLOW_DISABLED` / 422 `FLOW_NO_START_NODE` / 422 `FLOW_INPUT_SCHEMA_INVALID` / 400 `FLOW_FAILED`), from one shared definition all three flow doors read. Branch on the status and `error.code`, never on an inner success flag | | `authRequired: true` (or omitted) + anonymous caller | `401` `UNAUTHENTICATED`, the same envelope every seam answers | | `rateLimit` armed and exhausted | `429` + `Retry-After`, never with a cache directive | -| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | -| `cacheTtl: 0` | `Cache-Control: no-store` | +| `cacheTtlSeconds: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | +| `cacheTtlSeconds: 0` | `Cache-Control: no-store` | | an error answer (401/429/5xx) | never carries `Cache-Control`, and `outputMapping` is never applied to it | ### What an unmatched request answers @@ -1263,7 +1263,7 @@ runs the same gates your publish path does: | **Namespace** (ADR-0121 D1/D2) | a `path` outside `/api/v1/apps//`, or a stack declaring `apis:` with no explicit `manifest.namespace` | | **Supported target** | `type: 'script'` / `'proxy'` (neither executes in 17.x), an `object_operation` missing `objectParams.object` or `.operation`, a `flow` naming no `target` | | **Mapping** | a mapping `transform` (there is no transformation registry), an unusable dot path (empty segment, `__proto__`), two entries writing the same target path, or `inputMapping` on a `find` / `get` / `delete` operation that never reads a body | -| **Policy** | `authRequired: false` without `rateLimit.enabled: true` (ADR-0121 D6), an unusable armed budget, a negative `cacheTtl`, or `cacheTtl` on a non-GET method | +| **Policy** | `authRequired: false` without `rateLimit.enabled: true` (ADR-0121 D6), an unusable armed budget, a negative `cacheTtlSeconds`, or `cacheTtlSeconds` on a non-GET method | | **Uniqueness** | two endpoints in one stack claiming the same `METHOD` + path | diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 4919abf597..eab09d4411 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -89,7 +89,7 @@ const result = AuthEndpointSchema.parse(data); | **verificationUrl** | `string` | ✅ | URL the user should open in a browser | | **expiresAt** | `string` | ✅ | ISO timestamp when the code expires | | **intervalSeconds** | `number` | optional (default: `2`) | Recommended polling interval in seconds | -| **interval** | `never` | optional | [REMOVED] `DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 (#14478 ruling B) — the polling cadence is a duration and its unit lived only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. This response is not an RFC 8628 device-authorization payload — it renames every RFC field it carries — so the standard does not fix the bare spelling here. | +| **interval** | `never` | optional | [REMOVED] `DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the polling cadence is a duration and its unit lived only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. This response is not an RFC 8628 device-authorization payload — it renames every RFC field it carries — so the standard does not fix the bare spelling here. | --- diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 652128a63a..e5821b7a49 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -453,7 +453,7 @@ const result = ApiErrorSchema.parse(data); | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | | **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | -| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | @@ -667,7 +667,7 @@ const result = ApiErrorSchema.parse(data); | **cacheEnabled** | `boolean` | optional (default: `true`) | Enable per-request result caching | | **cacheKeyFn** | `string` | optional | Name or identifier of the cache key function | | **cacheTtlSeconds** | `number` | optional | Cache time-to-live in seconds (0 = no expiration) | -| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **coalesceRequests** | `boolean` | optional (default: `true`) | Deduplicate identical requests within a batch window | | **maxConcurrency** | `integer` | optional | Maximum parallel batch requests | diff --git a/content/docs/references/api/endpoint.mdx b/content/docs/references/api/endpoint.mdx index 522bdd5b2b..82f94e4d8d 100644 --- a/content/docs/references/api/endpoint.mdx +++ b/content/docs/references/api/endpoint.mdx @@ -40,7 +40,7 @@ const result = ApiEndpointSchema.parse(data); | **authRequired** | `boolean` | optional (default: `true`) | Require authentication | | **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Rate limiting policy | | **cacheTtlSeconds** | `number` | optional | Response cache TTL in seconds | -| **cacheTtl** | `never` | optional | [REMOVED] `ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged, and it stays GET-only. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **cacheTtl** | `never` | optional | [REMOVED] `ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged, and it stays GET-only. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 8fe3ad290e..1aabfa964f 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -48,7 +48,7 @@ const result = EnhancedApiErrorSchema.parse(data); | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | | **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | -| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | @@ -164,7 +164,7 @@ const result = EnhancedApiErrorSchema.parse(data); | **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | | **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | | **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | -| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | | **details** | `any` | optional | Additional error context | | **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | | **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx index bb81b2d518..07d3fca8ff 100644 --- a/content/docs/references/api/plugin-rest-api.mdx +++ b/content/docs/references/api/plugin-rest-api.mdx @@ -186,8 +186,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | | **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | -| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | -| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | @@ -305,7 +305,7 @@ const result = ErrorHandlingConfigSchema.parse(data); | **enableETag** | `boolean` | optional (default: `true`) | Enable ETag generation | | **enableCaching** | `boolean` | optional (default: `true`) | Enable HTTP caching | | **defaultCacheTtlSeconds** | `integer` | optional (default: `300`) | Default cache TTL in seconds | -| **defaultCacheTtl** | `never` | optional | [REMOVED] `RestApiPluginConfig.performance.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged. | +| **defaultCacheTtl** | `never` | optional | [REMOVED] `RestApiPluginConfig.performance.defaultCacheTtl` was renamed to `defaultCacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged. | --- @@ -364,8 +364,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **rateLimit** | `string` | optional | Rate limit policy name | | **cacheable** | `boolean` | optional (default: `false`) | Whether response can be cached | | **cacheTtlSeconds** | `integer` | optional | Cache TTL in seconds | -| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | -| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **cacheTtl** | `never` | optional | [REMOVED] `RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the neighbouring request timeout two lines above is in MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged. | | **handlerStatus** | `never` | optional | [REMOVED] `RestApiEndpoint.handlerStatus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no registrar, dispatcher or adapter consulted the key, so an endpoint declared `stub` or `planned` was served exactly like an `implemented` one, and the `501 NOT_IMPLEMENTED` its docstring promised is raised by the declarative-endpoint executor for a target it cannot serve, never from this field. Delete the key. An endpoint that has no handler yet is simply not registered; a declared-but-unbuilt route answering 501 is not a platform capability (ruling record, 2026-09-01). | ### Nested Shape: `RestApiRouteRegistration.middleware[number]` diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index ece7ec4d51..b38ec27f03 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -79,7 +79,7 @@ HTTP method — the full routing vocabulary (`api/*` endpoints, router and REST- | **public** | `boolean` | optional (default: `false`) | Is publicly accessible | | **permissions** | `string[]` | optional | Required permissions | | **timeoutMs** | `integer` | optional | Execution timeout in ms | -| **timeout** | `never` | optional | [REMOVED] `RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **rateLimit** | `string` | optional | Rate limit policy name | diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index e4f1009e47..14070cc488 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -437,9 +437,9 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **maxReconnectAttempts** | `integer` | optional (default: `5`) | Maximum reconnection attempts | | **pingIntervalMs** | `integer` | optional (default: `30000`) | Ping interval in milliseconds | | **timeoutMs** | `integer` | optional (default: `5000`) | Message timeout in milliseconds | -| **reconnectInterval** | `never` | optional | [REMOVED] `WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; the value (milliseconds) is unchanged. | -| **pingInterval** | `never` | optional | [REMOVED] `WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is unchanged. | -| **timeout** | `never` | optional | [REMOVED] `WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **reconnectInterval** | `never` | optional | [REMOVED] `WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; the value (milliseconds) is unchanged. | +| **pingInterval** | `never` | optional | [REMOVED] `WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **headers** | `Record` | optional | Custom headers for WebSocket handshake | @@ -726,7 +726,7 @@ This schema accepts one of the following structures: | **reconnectAttempts** | `number` | optional (default: `5`) | Maximum reconnection attempts for clients | | **presence** | `boolean` | optional (default: `false`) | Enable presence tracking | | **cursorSharing** | `boolean` | optional (default: `false`) | Enable collaborative cursor sharing | -| **heartbeatInterval** | `never` | optional | [REMOVED] `WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in @objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; the value (milliseconds) is unchanged. | +| **heartbeatInterval** | `never` | optional | [REMOVED] `WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; the value (milliseconds) is unchanged. | --- diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 5e2325ca69..23e58d5225 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -295,8 +295,8 @@ export const DeviceRequestResponseSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ interval: retiredKey( - '`DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the polling cadence is a duration and its unit lived only in the ' + '`DeviceRequestResponse.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' + + 'the polling cadence is a duration and its unit lived only in the ' + 'describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + 'This response is not an RFC 8628 device-authorization payload — it renames every RFC ' + 'field it carries — so the standard does not fix the bare spelling here.', diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index 82d4d52279..24c524804a 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -407,8 +407,8 @@ export const DataLoaderConfigSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ cacheTtl: retiredKey( - '`DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`DataLoaderConfig.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', ), coalesceRequests: z.boolean().default(true).describe('Deduplicate identical requests within a batch window'), diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index e36de711d1..ab1d46b74a 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -196,8 +196,8 @@ export const ApiEndpointSchema = strictObject({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ cacheTtl: retiredKey( - '`ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`ApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `cacheTtlSeconds`; the value (seconds) is ' + 'unchanged, and it stays GET-only. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index dabf299d28..32af58595d 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -408,8 +408,8 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ retryAfter: retiredKey( - '`EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is ' + 'unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response ' + 'header — that header keeps its RFC 9110 name and is untouched.', diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index 47354e8dd3..a6f98505b3 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -210,14 +210,14 @@ export const RestApiEndpointSchema = lazySchema(() => z.object({ /** Tombstones for the two renames above (#15677, ruling B on #14478). */ timeout: retiredKey( - '`RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`RestApiEndpoint.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose, and the neighbouring cache TTL two lines below is in SECONDS. ' + 'Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', ), cacheTtl: retiredKey( - '`RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`RestApiEndpoint.cacheTtl` was renamed to `cacheTtlSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose, and the neighbouring request timeout two lines above is in ' + 'MILLISECONDS. Rename the key to `cacheTtlSeconds`; the value (seconds) is unchanged.', ), @@ -738,7 +738,7 @@ export const RestApiPluginConfigSchema = z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ defaultCacheTtl: retiredKey( '`RestApiPluginConfig.performance.defaultCacheTtl` was renamed to ' - + '`defaultCacheTtlSeconds` in @objectstack/spec 17 (#14478 ruling B) — the unit of a ' + + '`defaultCacheTtlSeconds` in @objectstack/spec 17 — the unit of a ' + 'duration-shaped number lives in the key name, not only in the describe prose. Rename ' + 'the key to `defaultCacheTtlSeconds`; the value (seconds) is unchanged.', ), diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 126f0f00c6..396425112d 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -102,8 +102,8 @@ export const RouteDefinitionSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ timeout: retiredKey( - '`RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`RouteDefinition.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', ), rateLimit: z.string().optional().describe('Rate limit policy name'), diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index c428ab37b0..760241b053 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -432,19 +432,19 @@ export const WebSocketConfigSchema = lazySchema(() => z.object({ /** Tombstones for the three renames above (#15677, ruling B on #14478). */ reconnectInterval: retiredKey( '`WebSocketConfig.reconnectInterval` was renamed to `reconnectIntervalMs` in ' - + '@objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + 'the key name, not only in the describe prose. Rename the key to `reconnectIntervalMs`; ' + 'the value (milliseconds) is unchanged.', ), pingInterval: retiredKey( - '`WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`WebSocketConfig.pingInterval` was renamed to `pingIntervalMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `pingIntervalMs`; the value (milliseconds) is ' + 'unchanged.', ), timeout: retiredKey( - '`WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the unit of a duration-shaped number lives in the key name, not only ' + '`WebSocketConfig.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is ' + 'unchanged.', ), @@ -619,7 +619,7 @@ export const WebSocketServerConfigSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15677, ruling B on #14478). */ heartbeatInterval: retiredKey( '`WebSocketServerConfig.heartbeatInterval` was renamed to `heartbeatIntervalMs` in ' - + '@objectstack/spec 17 (#14478 ruling B) — the unit of a duration-shaped number lives in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in ' + 'the key name, not only in the describe prose. Rename the key to `heartbeatIntervalMs`; ' + 'the value (milliseconds) is unchanged.', ), From 960580850acfe5d8f335bb755788f0117479aaaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:51:03 +0000 Subject: [PATCH 12/33] docs(spec): drop the internal issue id from the four epoch-instant tombstone prescriptions (#15676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:doc-authoring` rule 3 forbids an internal issue id in customer-facing spec text (maintainer ruling 2026-08-12). The four `retiredKey()` prescriptions this stack card added each opened with a `(#14478 ruling B)` parenthetical — a citation-shaped token that resolves to nothing for the author who meets it the moment their key is refused, and which the generated reference pages publish verbatim. The parenthetical goes; the sentence keeps everything actionable it carried — the FROM to TO rename, that the value is unchanged, and the `SimplePresenceState.lastSeen` neighbour caveat — matching the shape the campaign's already-compliant tombstones use (`hook.timeout`, `job.timeout`, `DriverOptions.timeout`). The internal anchor is untouched in the adjacent `//` and `/** */` comments, which are not customer-facing and were never findings. `content/docs/references/**` regenerated with `pnpm --filter @objectstack/spec gen:docs` — no generated artifact was hand-edited. check:doc-authoring: 4 findings before, exit 0 after. check:duration-unit-keys: unmoved — 48 offender(s) among 215 duration-shaped numeric key(s), (6 declared `EpochMs` instant(s), 11 declared `externalVocabulary` mirror(s)). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/api/websocket.mdx | 4 ++-- content/docs/references/kernel/context.mdx | 4 ++-- .../kernel/startup-orchestrator.mdx | 4 ++-- packages/spec/src/api/websocket.zod.ts | 20 +++++++++---------- packages/spec/src/kernel/context.zod.ts | 8 ++++---- .../src/kernel/startup-orchestrator.zod.ts | 9 ++++----- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index 5838dcb034..0d01598748 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -363,7 +363,7 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **userName** | `string` | ✅ | User display name | | **status** | `Enum<'online' \| 'away' \| 'offline'>` | ✅ | User presence status | | **lastSeenAt** | `integer` | ✅ | Unix timestamp of last activity in milliseconds | -| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 (#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. | +| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. | | **metadata** | `Record` | optional | Additional presence metadata (e.g., current page, custom status) | @@ -452,7 +452,7 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **channel** | `string` | ✅ | Channel identifier (e.g., "record.account.123", "user.456") | | **payload** | `any` | ✅ | Event payload data | | **occurredAt** | `integer` | ✅ | Unix timestamp in milliseconds when the event occurred | -| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 (#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). | +| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). | --- diff --git a/content/docs/references/kernel/context.mdx b/content/docs/references/kernel/context.mdx index 96898c8f09..89a31c6f73 100644 --- a/content/docs/references/kernel/context.mdx +++ b/content/docs/references/kernel/context.mdx @@ -36,7 +36,7 @@ const result = KernelContextSchema.parse(data); | **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | | **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | -| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 (#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | +| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | --- @@ -72,7 +72,7 @@ Tenant-aware kernel runtime context | **startedAt** | `integer` | ✅ | Boot timestamp — Unix milliseconds | | **features** | `Record` | optional (default: `{}`) | Global feature toggles | | **previewMode** | `never` | optional | [REMOVED] `context.previewMode` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: none of its six keys (`autoLogin`, `simulatedRole`, `simulatedUserName`, `readOnly`, `expiresInSeconds`, `bannerMessage`) had a consumer in any repo, so an authored block parsed cleanly and configured NOTHING, while its own docstring promised an auth bypass ("skips authentication screens", "simulates an admin identity") and named a production guard no runtime ever received. Delete the key. Preview/demo deployments belong to the deployment layer, which owns auth per-project (`ArtifactKernelFactory` in the cloud distribution); `OS_PREVIEW_MODE` stays there as a routing-only switch. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (ruling record). | -| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 (#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | +| **startTime** | `never` | optional | [REMOVED] `context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 — the boot INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the key name used to leave to the describe prose. Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so spelling an instant that way would move it into the family the rule exists to separate it from. | | **tenantId** | `string` | ✅ | Resolved tenant identifier | | **tenantPlan** | `Enum<'free' \| 'pro' \| 'enterprise'>` | ✅ | Tenant subscription plan | | **tenantRegion** | `string` | optional | Tenant deployment region | diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index 3656c54112..945a88ca9e 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -37,7 +37,7 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | | **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | -| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 (#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | @@ -71,7 +71,7 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **healthy** | `boolean` | ✅ | Whether the plugin is healthy | | **checkedAt** | `integer` | ✅ | Unix timestamp in milliseconds when health check was performed | -| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 (#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | +| **timestamp** | `never` | optional | [REMOVED] `HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the instant the check RAN now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `checkedAt`; the value is unchanged (`Date.now()`). | | **details** | `Record` | optional | Optional plugin-specific health details | | **message** | `string` | optional | Error message if plugin is unhealthy | diff --git a/packages/spec/src/api/websocket.zod.ts b/packages/spec/src/api/websocket.zod.ts index 6043b75e2c..1b3a24e235 100644 --- a/packages/spec/src/api/websocket.zod.ts +++ b/packages/spec/src/api/websocket.zod.ts @@ -480,10 +480,10 @@ export const WebSocketEventSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15676, ruling B on #14478). */ timestamp: retiredKey( - '`WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, ' - + 'which declares the epoch-millisecond unit the bare key name left to the describe ' - + 'prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`).', + '`WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 — the ' + + 'event INSTANT now carries the shared `EpochMs` schema, which declares the ' + + 'epoch-millisecond unit the bare key name left to the describe prose. Rename the key ' + + 'to `occurredAt`; the value is unchanged (`Date.now()`).', ), })); @@ -519,12 +519,12 @@ export const SimplePresenceStateSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15676, ruling B on #14478). */ lastSeen: retiredKey( - '`SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` ' - + 'schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; ' - + 'the value is unchanged (`Date.now()`). Note the neighbouring ' - + '`PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a ' - + 'different type — an ISO-8601 datetime STRING — and is untouched.', + '`SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 — ' + + 'the last-activity INSTANT now carries the shared `EpochMs` schema, which declares ' + + 'the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged ' + + '(`Date.now()`). Note the neighbouring `PresenceState.lastSeen` ' + + '(api/realtime-shared.zod.ts) is a different key with a different type — an ' + + 'ISO-8601 datetime STRING — and is untouched.', ), metadata: z.record(z.string(), z.unknown()).optional().describe('Additional presence metadata (e.g., current page, custom status)'), })); diff --git a/packages/spec/src/kernel/context.zod.ts b/packages/spec/src/kernel/context.zod.ts index f589132f50..d126374286 100644 --- a/packages/spec/src/kernel/context.zod.ts +++ b/packages/spec/src/kernel/context.zod.ts @@ -29,10 +29,10 @@ const RUNTIME_MODE_PREVIEW_RETIRED = + 'experience becomes a product capability it re-declares fresh, with the ' + 'production-posture hard-refusal as the first-landed half.'; const START_TIME_RENAMED = - '`context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the boot INSTANT now carries the shared `EpochMs` schema, which ' - + 'declares the epoch-millisecond unit the key name used to leave to the describe prose. ' - + 'Rename the key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather ' + '`context.startTime` was renamed to `context.startedAt` in @objectstack/spec 17 — the ' + + 'boot INSTANT now carries the shared `EpochMs` schema, which declares the ' + + 'epoch-millisecond unit the key name used to leave to the describe prose. Rename the ' + + 'key to `startedAt`; the value is unchanged (`Date.now()`). `*At` rather ' + 'than `startTimeMs` deliberately: every `*Ms` key in this package is a DURATION, so ' + 'spelling an instant that way would move it into the family the rule exists to ' + 'separate it from.'; diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index 5bf90abcca..d63e75d975 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -103,11 +103,10 @@ export const HealthStatusSchema = lazySchema(() => z.object({ /** Tombstone for the rename above (#15676, ruling B on #14478). */ timestamp: retiredKey( - '`HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 ' - + '(#14478 ruling B) — the instant the check RAN now carries the shared `EpochMs` ' - + 'schema, which declares the epoch-millisecond unit the bare key name left to the ' - + 'describe prose. Rename the key to `checkedAt`; the value is unchanged ' - + '(`Date.now()`).', + '`HealthStatus.timestamp` was renamed to `checkedAt` in @objectstack/spec 17 — the ' + + 'instant the check RAN now carries the shared `EpochMs` schema, which declares the ' + + 'epoch-millisecond unit the bare key name left to the describe prose. Rename the key ' + + 'to `checkedAt`; the value is unchanged (`Date.now()`).', ), /** From b4633903b282cbb1e343c1a6e6ec99bde5dc4d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:00:56 +0000 Subject: [PATCH 13/33] chore(spec): regenerate the reference page the merge deferred (#15677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with card 1/6's advanced tip (960580850) touched api/websocket.zod.ts on both sides. The schema source auto-merged; the generated content/docs/references/api/websocket.mdx is routed to merge=os-regen, so the driver deferred it and the merge kept OUR side — silently dropping card 1/6's half. Regenerating from the merged tree is what repairs it, and it carries both sides: their two stripped prescriptions land (issue-id occurrences 2 -> 0) while my four renamed keys stay (6 -> 6). Not hand-edited and not resolved by taking a side: the bytes come from `pnpm --filter @objectstack/spec check:generated --fix` on the merged tree, and the staged diff was read before committing (`git diff` reads clean over this trap; only the staged diff shows it). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/api/websocket.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index 14070cc488..7820ee5ea9 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -363,7 +363,7 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **userName** | `string` | ✅ | User display name | | **status** | `Enum<'online' \| 'away' \| 'offline'>` | ✅ | User presence status | | **lastSeenAt** | `integer` | ✅ | Unix timestamp of last activity in milliseconds | -| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 (#14478 ruling B) — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. | +| **lastSeen** | `never` | optional | [REMOVED] `SimplePresenceState.lastSeen` was renamed to `lastSeenAt` in @objectstack/spec 17 — the last-activity INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit. Rename the key to `lastSeenAt`; the value is unchanged (`Date.now()`). Note the neighbouring `PresenceState.lastSeen` (api/realtime-shared.zod.ts) is a different key with a different type — an ISO-8601 datetime STRING — and is untouched. | | **metadata** | `Record` | optional | Additional presence metadata (e.g., current page, custom status) | @@ -455,7 +455,7 @@ Event pattern (supports wildcards like "record.*" or "*.created") | **channel** | `string` | ✅ | Channel identifier (e.g., "record.account.123", "user.456") | | **payload** | `any` | ✅ | Event payload data | | **occurredAt** | `integer` | ✅ | Unix timestamp in milliseconds when the event occurred | -| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 (#14478 ruling B) — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). | +| **timestamp** | `never` | optional | [REMOVED] `WebSocketEvent.timestamp` was renamed to `occurredAt` in @objectstack/spec 17 — the event INSTANT now carries the shared `EpochMs` schema, which declares the epoch-millisecond unit the bare key name left to the describe prose. Rename the key to `occurredAt`; the value is unchanged (`Date.now()`). | --- From ecbc4bec51fb60d5a73f12193f35463de56f83e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:34:31 +0000 Subject: [PATCH 14/33] wip(spec): rename the 14 kernel/ duration keys, tombstones on the old spellings (#15678) The schema half of stack card 3/6. Gate reads 36 -> 22 with src/kernel/ at 0. Readers, registrations and regenerated artifacts follow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/kernel/events/bus.zod.ts | 2 +- .../spec/src/kernel/events/handlers.zod.ts | 12 +++- packages/spec/src/kernel/events/queue.zod.ts | 15 ++++- .../spec/src/kernel/package-upgrade.zod.ts | 12 +++- .../kernel/plugin-lifecycle-advanced.zod.ts | 25 ++++++++- .../kernel/plugin-security-advanced.zod.ts | 55 +++++++++++++++++-- .../spec/src/kernel/plugin-security.zod.ts | 12 +++- .../spec/src/kernel/plugin-versioning.zod.ts | 13 ++++- .../src/kernel/startup-orchestrator.zod.ts | 44 ++++++++++++--- 9 files changed, 169 insertions(+), 21 deletions(-) diff --git a/packages/spec/src/kernel/events/bus.zod.ts b/packages/spec/src/kernel/events/bus.zod.ts index a398304afe..d58713342c 100644 --- a/packages/spec/src/kernel/events/bus.zod.ts +++ b/packages/spec/src/kernel/events/bus.zod.ts @@ -16,7 +16,7 @@ import { EventWebhookConfigSchema, EventMessageQueueConfigSchema, RealTimeNotifi * * @example * { - * "persistence": { "enabled": true, "retention": 365 }, + * "persistence": { "enabled": true, "retentionDays": 365 }, * "queue": { "concurrency": 20 }, * "eventSourcing": { "enabled": true }, * "webhooks": [], diff --git a/packages/spec/src/kernel/events/handlers.zod.ts b/packages/spec/src/kernel/events/handlers.zod.ts index 0873251b40..6dfd8e2836 100644 --- a/packages/spec/src/kernel/events/handlers.zod.ts +++ b/packages/spec/src/kernel/events/handlers.zod.ts @@ -11,6 +11,7 @@ import { z } from 'zod'; * Defines how to handle a specific event */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const EventHandlerSchema = lazySchema(() => z.object({ /** * Handler identifier @@ -82,7 +83,16 @@ export type EventRoute = z.input; */ export const EventPersistenceSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable event persistence'), - retention: z.number().int().positive().describe('Days to retain persisted events'), + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + retentionDays: z.number().int().positive().describe('Days to retain persisted events'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey( + '`EventPersistence.retention` was renamed to `retentionDays` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged.', + ), filter: z.unknown().optional().describe('Optional filter function to select which events to persist'), storage: z.enum(['database', 'file', 's3', 'custom']).default('database') .describe('Storage backend for persisted events'), diff --git a/packages/spec/src/kernel/events/queue.zod.ts b/packages/spec/src/kernel/events/queue.zod.ts index 6d389cb9a5..0cf8210808 100644 --- a/packages/spec/src/kernel/events/queue.zod.ts +++ b/packages/spec/src/kernel/events/queue.zod.ts @@ -21,6 +21,7 @@ import { z } from 'zod'; * } */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const EventQueueConfigSchema = lazySchema(() => z.object({ /** * Queue name @@ -125,7 +126,7 @@ export type EventReplayConfigParsed = z.infer; * { * "enabled": true, * "snapshotInterval": 100, - * "retention": 365 + * "retentionDays": 365 * } */ export const EventSourcingConfigSchema = lazySchema(() => z.object({ @@ -149,8 +150,18 @@ export const EventSourcingConfigSchema = lazySchema(() => z.object({ /** * Event retention */ - retention: z.number().int().positive().default(365) + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose, one key below the count-valued `snapshotRetention`. + retentionDays: z.number().int().positive().default(365) .describe('Days to retain events'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey( + '`EventSourcingConfig.retention` was renamed to `retentionDays` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged.' + + ' The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name.', + ), /** * Aggregate types diff --git a/packages/spec/src/kernel/package-upgrade.zod.ts b/packages/spec/src/kernel/package-upgrade.zod.ts index e724b5adf9..5cbc747556 100644 --- a/packages/spec/src/kernel/package-upgrade.zod.ts +++ b/packages/spec/src/kernel/package-upgrade.zod.ts @@ -36,6 +36,7 @@ import { ManifestSchema } from './manifest.zod'; * Type of change detected between package versions. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MetadataChangeTypeSchema = lazySchema(() => z.enum([ 'added', // New metadata item added in new version 'modified', // Existing metadata item modified @@ -121,9 +122,18 @@ export const UpgradePlanSchema = lazySchema(() => z.object({ })).optional().describe('Dependent packages that also need upgrading'), /** Estimated upgrade duration in seconds */ - estimatedDuration: z.number().int().min(0).optional() + // Renamed from `estimatedDuration` (#15678, #14478 ruling B): the unit lived + // only in the describe prose. + estimatedDurationSeconds: z.number().int().min(0).optional() .describe('Estimated upgrade duration in seconds'), + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + estimatedDuration: retiredKey( + '`UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `estimatedDurationSeconds`; the value (seconds) is unchanged.', + ), + /** Human-readable summary */ summary: z.string().optional().describe('Human-readable upgrade summary'), }).describe('Upgrade analysis plan generated before execution')); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts index 6d9a4c0a7e..f559e8b1b7 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts @@ -154,6 +154,19 @@ export const PluginHealthCheckSchema = lazySchema(() => z.object({ restartBackoff: retiredKey(RESTART_BACKOFF_RETIRED), })); +const UPTIME_RETIRED = + '`PluginHealthReport.metrics.uptime` was renamed to `uptimeMs` in @objectstack/spec 17 ' + + '— the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose, and this platform already spells a SECONDS-valued uptime with the same bare name ' + + 'on GET /health. Rename the key to `uptimeMs`; the value (milliseconds, `Date.now() - ' + + 'startTime`) is unchanged.'; + +const HEALTH_RESPONSE_TIME_RETIRED = + '`PluginHealthReport.metrics.responseTime` was renamed to `responseTimeMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `responseTimeMs`; the value ' + + '(milliseconds) is unchanged.'; + /** * Plugin Health Report * Detailed health information from a plugin @@ -178,12 +191,20 @@ export const PluginHealthReportSchema = lazySchema(() => z.object({ * Detailed metrics */ metrics: z.object({ - uptime: z.number().describe('Plugin uptime in milliseconds'), + // Renamed from `uptime` (#15678, #14478 ruling B): the unit lived only in the + // describe prose, and the neighbouring HTTP /health `uptime` is SECONDS. + uptimeMs: z.number().describe('Plugin uptime in milliseconds'), memoryUsage: z.number().optional().describe('Memory usage in bytes'), cpuUsage: z.number().optional().describe('CPU usage percentage'), activeConnections: z.number().optional().describe('Number of active connections'), errorRate: z.number().optional().describe('Error rate (errors per minute)'), - responseTime: z.number().optional().describe('Average response time in ms'), + // Renamed from `responseTime` (#15678, #14478 ruling B): the unit lived only + // in the describe prose. + responseTimeMs: z.number().optional().describe('Average response time in ms'), + + /** Tombstones for the two renames above (#15678, ruling B on #14478). */ + uptime: retiredKey(UPTIME_RETIRED), + responseTime: retiredKey(HEALTH_RESPONSE_TIME_RETIRED), }).partial().optional(), /** diff --git a/packages/spec/src/kernel/plugin-security-advanced.zod.ts b/packages/spec/src/kernel/plugin-security-advanced.zod.ts index 1b699b909a..af629b3427 100644 --- a/packages/spec/src/kernel/plugin-security-advanced.zod.ts +++ b/packages/spec/src/kernel/plugin-security-advanced.zod.ts @@ -22,6 +22,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Defines the scope of a permission */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const PermissionScopeSchema = lazySchema(() => z.enum([ 'global', // Applies to entire system 'tenant', // Applies to specific tenant @@ -295,6 +296,31 @@ export const RuntimeConfigSchema = lazySchema(() => z.object({ }).optional(), })); +const SANDBOX_PROCESS_TIMEOUT_RETIRED = + '`SandboxConfig.process.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.'; + +const TOKEN_EXPIRATION_RETIRED = + '`KernelSecurityPolicy.authentication.tokenExpiration` was renamed to ' + + '`tokenExpirationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose, and the sibling rate-limit window ' + + 'on this same policy is already `windowMs`. Rename the key to `tokenExpirationSeconds`; ' + + 'the value (seconds) is unchanged.'; + +const AUDIT_LOG_RETENTION_RETIRED = + '`KernelSecurityPolicy.auditLog.retention` was renamed to `retentionDays` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `retentionDays`; the value (days) is ' + + 'unchanged.'; + +const DISCLOSURE_RESPONSE_TIME_RETIRED = + '`PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to ' + + '`responseTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives ' + + 'in the key name, not only in the describe prose. This one is HOURS, while the same bare ' + + 'name on `PluginHealthReport.metrics` was milliseconds — which is the confusion the rule ' + + 'exists to remove. Rename the key to `responseTimeHours`; the value (hours) is unchanged.'; + /** * Sandbox Configuration * Defines how plugin is isolated @@ -349,7 +375,12 @@ export const SandboxConfigSchema = lazySchema(() => z.object({ process: z.object({ allowSpawn: z.boolean().default(false).describe('Allow spawning child processes'), allowedCommands: z.array(z.string()).optional().describe('Whitelisted commands'), - timeout: z.number().int().optional().describe('Process timeout in ms'), + // Renamed from `timeout` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + timeoutMs: z.number().int().optional().describe('Process timeout in ms'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + timeout: retiredKey(SANDBOX_PROCESS_TIMEOUT_RETIRED), }).optional(), /** @@ -583,7 +614,12 @@ export const KernelSecurityPolicySchema = lazySchema(() => z.object({ authentication: z.object({ required: z.boolean().default(true), methods: z.array(z.enum(['jwt', 'oauth2', 'api-key', 'session', 'certificate'])), - tokenExpiration: z.number().int().optional().describe('Token expiration in seconds'), + // Renamed from `tokenExpiration` (#15678, #14478 ruling B): the unit lived + // only in the describe prose, beside the already-suffixed `windowMs`. + tokenExpirationSeconds: z.number().int().optional().describe('Token expiration in seconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + tokenExpiration: retiredKey(TOKEN_EXPIRATION_RETIRED), }).optional(), /** @@ -602,7 +638,12 @@ export const KernelSecurityPolicySchema = lazySchema(() => z.object({ auditLog: z.object({ enabled: z.boolean().default(true), events: z.array(z.string()).optional().describe('Events to log'), - retention: z.number().int().optional().describe('Log retention in days'), + // Renamed from `retention` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + retentionDays: z.number().int().optional().describe('Log retention in days'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + retention: retiredKey(AUDIT_LOG_RETENTION_RETIRED), }).optional(), })); @@ -694,7 +735,13 @@ export const PluginSecurityManifestSchema = lazySchema(() => z.object({ */ vulnerabilityDisclosure: z.object({ policyUrl: z.string().url().optional(), - responseTime: z.number().int().optional().describe('Expected response time in hours'), + // Renamed from `responseTime` (#15678, #14478 ruling B): the unit lived only + // in the describe prose — and it is HOURS here, while the same bare name on + // PluginHealthReport.metrics was milliseconds. + responseTimeHours: z.number().int().optional().describe('Expected response time in hours'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + responseTime: retiredKey(DISCLOSURE_RESPONSE_TIME_RETIRED), bugBounty: z.boolean().default(false), }).optional(), })); diff --git a/packages/spec/src/kernel/plugin-security.zod.ts b/packages/spec/src/kernel/plugin-security.zod.ts index 0721054b5e..d4812c01b5 100644 --- a/packages/spec/src/kernel/plugin-security.zod.ts +++ b/packages/spec/src/kernel/plugin-security.zod.ts @@ -25,6 +25,7 @@ import { z } from 'zod'; * Vulnerability Severity */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const VulnerabilitySeverity = z.enum([ 'critical', 'high', @@ -512,7 +513,16 @@ export const PackageDependencyResolutionResultSchema = lazySchema(() => z.object /** * Resolution time (ms) */ - resolvedIn: z.number().int().min(0).optional().describe('Time taken to resolve dependencies in milliseconds'), + // Renamed from `resolvedIn` (#15678, #14478 ruling B): the unit lived only in + // the describe prose. + resolvedInMs: z.number().int().min(0).optional().describe('Time taken to resolve dependencies in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + resolvedIn: retiredKey( + '`PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `resolvedInMs`; the value (milliseconds) is unchanged.', + ), }).describe('Result of a dependency resolution process')); export type PackageDependencyResolutionResult = z.input; diff --git a/packages/spec/src/kernel/plugin-versioning.zod.ts b/packages/spec/src/kernel/plugin-versioning.zod.ts index 172263cbf7..a8ef8ede29 100644 --- a/packages/spec/src/kernel/plugin-versioning.zod.ts +++ b/packages/spec/src/kernel/plugin-versioning.zod.ts @@ -21,6 +21,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Standard SemVer format with optional pre-release and build metadata */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const SemanticVersionSchema = lazySchema(() => z.object({ major: z.number().int().min(0).describe('Major version (breaking changes)'), minor: z.number().int().min(0).describe('Minor version (backward compatible features)'), @@ -336,6 +337,11 @@ export const PluginDependencyResolutionResultSchema = lazySchema(() => z.object( .describe('Map of plugin ID to its dependencies'), })); +const ROLLOUT_DURATION_RETIRED = + '`MultiVersionSupport.rollout.duration` was renamed to `durationMs` in @objectstack/spec ' + + '17 — the unit of a duration-shaped number lives in the key name, not only in the ' + + 'describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged.'; + /** * Multi-Version Support Configuration * Allows running multiple versions of a plugin simultaneously @@ -381,8 +387,13 @@ export const MultiVersionSupportSchema = lazySchema(() => z.object({ strategy: z.enum(['percentage', 'blue-green', 'canary']), percentage: z.number().min(0).max(100).optional() .describe('Percentage of traffic to new version'), - duration: z.number().int().optional() + // Renamed from `duration` (#15678, #14478 ruling B): the unit lived only in + // the describe prose, beside the unit-less `percentage`. + durationMs: z.number().int().optional() .describe('Rollout duration in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + duration: retiredKey(ROLLOUT_DURATION_RETIRED), }).optional(), })); diff --git a/packages/spec/src/kernel/startup-orchestrator.zod.ts b/packages/spec/src/kernel/startup-orchestrator.zod.ts index d63e75d975..3d952c6ca6 100644 --- a/packages/spec/src/kernel/startup-orchestrator.zod.ts +++ b/packages/spec/src/kernel/startup-orchestrator.zod.ts @@ -22,7 +22,7 @@ import { z } from 'zod'; * * @example * { - * "timeout": 30000, + * "timeoutMs": 30000, * "rollbackOnFailure": true, * "healthCheck": false, * "parallel": false @@ -36,8 +36,18 @@ export const StartupOptionsSchema = lazySchema(() => z.object({ * Maximum time (ms) to wait for each plugin to start * @default 30000 (30 seconds) */ - timeout: z.number().int().min(0).optional().default(30000) + // Renamed from `timeout` (#15678, #14478 ruling B): the unit lived only in the + // describe prose. The contract's own `startWithTimeout(plugin, ctx, timeoutMs)` + // parameter already spelled it this way. + timeoutMs: z.number().int().min(0).optional().default(30000) .describe('Maximum time in milliseconds to wait for each plugin to start'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + timeout: retiredKey( + '`StartupOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Whether to rollback (destroy) already-started plugins on failure @@ -134,7 +144,7 @@ export type HealthStatus = z.input; * { * "plugin": { "name": "crm-plugin", "version": "1.0.0" }, * "success": true, - * "duration": 1250, + * "durationMs": 1250, * "health": { * "healthy": true, * "timestamp": 1706659200000 @@ -158,7 +168,16 @@ export const PluginStartupResultSchema = lazySchema(() => z.object({ /** * Time taken to start (milliseconds) */ - duration: z.number().min(0).describe('Time taken to start the plugin in milliseconds'), + // Renamed from `duration` (#15678, #14478 ruling B): the unit lived only in the + // describe prose. + durationMs: z.number().min(0).describe('Time taken to start the plugin in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + duration: retiredKey( + '`PluginStartupResult.duration` was renamed to `durationMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged.', + ), /** * Error if startup failed @@ -189,10 +208,10 @@ export type PluginStartupResult = z.input; * @example * { * "results": [ - * { "plugin": { "name": "plugin1" }, "success": true, "duration": 1200 }, - * { "plugin": { "name": "plugin2" }, "success": true, "duration": 850 } + * { "plugin": { "name": "plugin1" }, "success": true, "durationMs": 1200 }, + * { "plugin": { "name": "plugin2" }, "success": true, "durationMs": 850 } * ], - * "totalDuration": 2050, + * "totalDurationMs": 2050, * "allSuccessful": true * } */ @@ -205,7 +224,16 @@ export const StartupOrchestrationResultSchema = lazySchema(() => z.object({ /** * Total time taken for all plugins (milliseconds) */ - totalDuration: z.number().min(0).describe('Total time taken for all plugins in milliseconds'), + // Renamed from `totalDuration` (#15678, #14478 ruling B): the unit lived only + // in the describe prose. + totalDurationMs: z.number().min(0).describe('Total time taken for all plugins in milliseconds'), + + /** Tombstone for the rename above (#15678, ruling B on #14478). */ + totalDuration: retiredKey( + '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only ' + + 'in the describe prose. Rename the key to `totalDurationMs`; the value (milliseconds) is unchanged.', + ), /** * Whether all plugins started successfully From cf5c8340f7df99936e622535a7c09bc287c58339 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:42:43 +0000 Subject: [PATCH 15/33] wip(spec): readers and ADR-0087 registrations for the kernel/ renames (#15678) 14 retired-key entries and five semantic entries. No D2 conversion on this card: none of the twelve defs is a stack collection member or a stored sys_metadata row (stack.zod.ts declares no eventBus / startup / plugin-security root), so the conversion chain has no seam that would see one. Readers moved in core's health monitor, the kernel and contracts test suites, and the hand-written lifecycle protocol page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/protocol/kernel/lifecycle.mdx | 9 +- packages/core/src/health-monitor.ts | 2 +- packages/spec/authorable-defaults/kernel.json | 4 +- packages/spec/authorable-surface/kernel.json | 21 +- .../src/contracts/package-service.test.ts | 4 +- .../contracts/startup-orchestrator.test.ts | 22 +- packages/spec/src/kernel/events.test.ts | 30 +- .../spec/src/kernel/package-upgrade.test.ts | 2 +- .../kernel/plugin-lifecycle-advanced.test.ts | 6 +- .../kernel/plugin-security-advanced.test.ts | 4 +- .../spec/src/kernel/plugin-security.test.ts | 2 +- .../spec/src/kernel/plugin-versioning.test.ts | 2 +- .../src/kernel/startup-orchestrator.test.ts | 28 +- .../18.kernel__EventPersistence__retention.ts | 11 + ....kernel__EventSourcingConfig__retention.ts | 11 + ...ernelSecurityPolicy__auditLog.retention.ts | 10 + ...yPolicy__authentication.tokenExpiration.ts | 11 + ...__MultiVersionSupport__rollout.duration.ts | 12 + ...eDependencyResolutionResult__resolvedIn.ts | 10 + ...luginHealthReport__metrics.responseTime.ts | 11 + ...nel__PluginHealthReport__metrics.uptime.ts | 12 + ...t__vulnerabilityDisclosure.responseTime.ts | 12 + ...8.kernel__PluginStartupResult__duration.ts | 15 + ....kernel__SandboxConfig__process.timeout.ts | 13 + .../18.kernel__StartupOptions__timeout.ts | 12 + ...artupOrchestrationResult__totalDuration.ts | 10 + ....kernel__UpgradePlan__estimatedDuration.ts | 11 + ....kernel-event-bus-retention-unit-in-key.ts | 41 +++ ...package-lifecycle-durations-unit-in-key.ts | 43 +++ ...gin-health-report-durations-unit-in-key.ts | 44 +++ ...l-plugin-security-durations-unit-in-key.ts | 51 +++ ...rtup-orchestrator-durations-unit-in-key.ts | 43 +++ packages/spec/src/migrations/registry.ts | 335 ++++++++++++++++++ 33 files changed, 790 insertions(+), 64 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index ff3e155f8a..0499e2aaae 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -770,7 +770,7 @@ this shape over HTTP — it is an in-process model, not a wire body. "status": "healthy", "timestamp": "2024-01-15T11:00:00.000Z", "metrics": { - "uptime": 3600000 + "uptimeMs": 3600000 }, "checks": [ { "name": "healthCheck", "status": "passed" } @@ -789,12 +789,13 @@ this shape over HTTP — it is an in-process model, not a wire body. | `"plugin-loaded"` | no `checkMethod` is configured, **or** the configured name does not resolve to a function on the plugin | | `"health-check"` | the check **threw** — a `timeout` overrun included, since the race surfaces it as a rejection. A fixed name, neither the method's nor the default's, and always `status: "failed"` | -`metrics.uptime` is in **milliseconds** (`Date.now() - startTime`), unlike -the seconds-valued `uptime` of `GET /health` above, and the report carries no +`metrics.uptimeMs` is in **milliseconds** (`Date.now() - startTime`), unlike +the seconds-valued `uptime` of `GET /health` above — which is the very +collision the unit-in-the-key-name rule exists to remove — and the report carries no `version` field — it identifies its plugin by the key it is stored under. The optional `message` is set only when a check fails; the schema's remaining `metrics` fields (`memoryUsage`, `cpuUsage`, `activeConnections`, `errorRate`, -`responseTime`) and its `dependencies` array are declared but left unset by the +`responseTimeMs`) and its `dependencies` array are declared but left unset by the monitor today. ## Shutdown Sequence diff --git a/packages/core/src/health-monitor.ts b/packages/core/src/health-monitor.ts index 42f63f9da3..66311420b3 100644 --- a/packages/core/src/health-monitor.ts +++ b/packages/core/src/health-monitor.ts @@ -345,7 +345,7 @@ export class PluginHealthMonitor { timestamp: new Date().toISOString(), message, metrics: { - uptime: Date.now() - startTime, + uptimeMs: Date.now() - startTime, }, checks: checks.length > 0 ? checks : undefined, }; diff --git a/packages/spec/authorable-defaults/kernel.json b/packages/spec/authorable-defaults/kernel.json index c162a52d84..e37bcdb4ed 100644 --- a/packages/spec/authorable-defaults/kernel.json +++ b/packages/spec/authorable-defaults/kernel.json @@ -29,7 +29,7 @@ "kernel/EventQueueConfig:priorityEnabled = true", "kernel/EventReplayConfig:speed = 1", "kernel/EventSourcingConfig:enabled = false", - "kernel/EventSourcingConfig:retention = 365", + "kernel/EventSourcingConfig:retentionDays = 365", "kernel/EventSourcingConfig:snapshotInterval = 100", "kernel/EventSourcingConfig:snapshotRetention = 10", "kernel/EventTypeDefinition:deprecated = false", @@ -141,7 +141,7 @@ "kernel/StartupOptions:healthCheck = false", "kernel/StartupOptions:parallel = false", "kernel/StartupOptions:rollbackOnFailure = true", - "kernel/StartupOptions:timeout = 30000", + "kernel/StartupOptions:timeoutMs = 30000", "kernel/TenantRuntimeContext:features = {}", "kernel/TenantRuntimeContext:mode = \"production\"", "kernel/UpgradePackageRequest:createSnapshot = true", diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index 016597856f..194cc55140 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -126,7 +126,8 @@ "kernel/EventMetadata:userId", "kernel/EventPersistence:enabled", "kernel/EventPersistence:filter", - "kernel/EventPersistence:retention", + "kernel/EventPersistence:retention [RETIRED]", + "kernel/EventPersistence:retentionDays", "kernel/EventPersistence:storage", "kernel/EventQueueConfig:concurrency", "kernel/EventQueueConfig:deadLetterQueue", @@ -144,7 +145,8 @@ "kernel/EventRoute:transform", "kernel/EventSourcingConfig:aggregateTypes", "kernel/EventSourcingConfig:enabled", - "kernel/EventSourcingConfig:retention", + "kernel/EventSourcingConfig:retention [RETIRED]", + "kernel/EventSourcingConfig:retentionDays", "kernel/EventSourcingConfig:snapshotInterval", "kernel/EventSourcingConfig:snapshotRetention", "kernel/EventSourcingConfig:storage", @@ -419,7 +421,8 @@ "kernel/PackageDependencyResolutionResult:errors", "kernel/PackageDependencyResolutionResult:graph", "kernel/PackageDependencyResolutionResult:installOrder", - "kernel/PackageDependencyResolutionResult:resolvedIn", + "kernel/PackageDependencyResolutionResult:resolvedIn [RETIRED]", + "kernel/PackageDependencyResolutionResult:resolvedInMs", "kernel/PackageDependencyResolutionResult:status", "kernel/Plugin:author", "kernel/Plugin:default", @@ -576,7 +579,8 @@ "kernel/PluginSecurityManifest:trustLevel", "kernel/PluginSecurityManifest:vulnerabilities", "kernel/PluginSecurityManifest:vulnerabilityDisclosure", - "kernel/PluginStartupResult:duration", + "kernel/PluginStartupResult:duration [RETIRED]", + "kernel/PluginStartupResult:durationMs", "kernel/PluginStartupResult:error", "kernel/PluginStartupResult:health", "kernel/PluginStartupResult:plugin", @@ -746,11 +750,13 @@ "kernel/StartupOptions:healthCheck", "kernel/StartupOptions:parallel", "kernel/StartupOptions:rollbackOnFailure", - "kernel/StartupOptions:timeout", + "kernel/StartupOptions:timeout [RETIRED]", + "kernel/StartupOptions:timeoutMs", "kernel/StartupOrchestrationResult:allSuccessful", "kernel/StartupOrchestrationResult:results", "kernel/StartupOrchestrationResult:rolledBack", - "kernel/StartupOrchestrationResult:totalDuration", + "kernel/StartupOrchestrationResult:totalDuration [RETIRED]", + "kernel/StartupOrchestrationResult:totalDurationMs", "kernel/TenantRuntimeContext:appName", "kernel/TenantRuntimeContext:cwd", "kernel/TenantRuntimeContext:features", @@ -787,7 +793,8 @@ "kernel/UpgradePlan:affectedCustomizations", "kernel/UpgradePlan:changes", "kernel/UpgradePlan:dependencyUpgrades", - "kernel/UpgradePlan:estimatedDuration", + "kernel/UpgradePlan:estimatedDuration [RETIRED]", + "kernel/UpgradePlan:estimatedDurationSeconds", "kernel/UpgradePlan:fromVersion", "kernel/UpgradePlan:impactLevel", "kernel/UpgradePlan:migrationScripts", diff --git a/packages/spec/src/contracts/package-service.test.ts b/packages/spec/src/contracts/package-service.test.ts index db4dc8e1e3..14dea660e6 100644 --- a/packages/spec/src/contracts/package-service.test.ts +++ b/packages/spec/src/contracts/package-service.test.ts @@ -170,7 +170,7 @@ describe('Package Service Contract', () => { requiresMigration: true, migrationScripts: ['migrations/v2_add_account_fields.ts'], dependencyUpgrades: [{ packageId: 'com.acme.core', fromVersion: '1.2.0', toVersion: '2.0.0' }], - estimatedDuration: 120, + estimatedDurationSeconds: 120, summary: 'Major upgrade with 3 metadata changes and 1 migration', }), upgrade: async () => ({ success: true, phase: 'completed' }), @@ -187,7 +187,7 @@ describe('Package Service Contract', () => { expect(plan.requiresMigration).toBe(true); expect(plan.migrationScripts).toHaveLength(1); expect(plan.dependencyUpgrades).toHaveLength(1); - expect(plan.estimatedDuration).toBe(120); + expect(plan.estimatedDurationSeconds).toBe(120); }); it('should execute upgrade and support rollback', async () => { diff --git a/packages/spec/src/contracts/startup-orchestrator.test.ts b/packages/spec/src/contracts/startup-orchestrator.test.ts index 9888f309b4..9971963a60 100644 --- a/packages/spec/src/contracts/startup-orchestrator.test.ts +++ b/packages/spec/src/contracts/startup-orchestrator.test.ts @@ -17,14 +17,14 @@ describe('Startup Orchestrator Contract', () => { const options: StartupOptions = {}; expect(options).toBeDefined(); - expect(options.timeout).toBeUndefined(); + expect(options.timeoutMs).toBeUndefined(); expect(options.rollbackOnFailure).toBeUndefined(); }); it('parses the input tier into the defaulted StartupOptions tier', () => { const parsed: StartupOptionsParsed = StartupOptionsSchema.parse({}); - expect(parsed.timeout).toBe(30000); + expect(parsed.timeoutMs).toBe(30000); expect(parsed.rollbackOnFailure).toBe(true); expect(parsed.healthCheck).toBe(false); expect(parsed.parallel).toBe(false); @@ -32,14 +32,14 @@ describe('Startup Orchestrator Contract', () => { it('should allow full options', () => { const options: StartupOptions = { - timeout: 30000, + timeoutMs: 30000, rollbackOnFailure: true, healthCheck: true, parallel: false, context: { db: 'postgres' }, }; - expect(options.timeout).toBe(30000); + expect(options.timeoutMs).toBe(30000); expect(options.rollbackOnFailure).toBe(true); expect(options.healthCheck).toBe(true); expect(options.parallel).toBe(false); @@ -78,11 +78,11 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: true, - duration: 150, + durationMs: 150, }; expect(result.success).toBe(true); - expect(result.duration).toBe(150); + expect(result.durationMs).toBe(150); expect(result.error).toBeUndefined(); }); @@ -91,7 +91,7 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: false, - duration: 30000, + durationMs: 30000, // The kernel schema declares the serializable projection, not a live // Error instance — what a wire/log consumer of the result can carry. error: { name: 'Error', message: 'Timeout' }, @@ -106,7 +106,7 @@ describe('Startup Orchestrator Contract', () => { const result: PluginStartupResult = { plugin, success: true, - duration: 50, + durationMs: 50, health: { healthy: true, checkedAt: Date.now(), @@ -125,7 +125,7 @@ describe('Startup Orchestrator Contract', () => { return plugins.map((p) => ({ plugin: p, success: true, - duration: 10, + durationMs: 10, })); }, rollback: async (_startedPlugins) => {}, @@ -151,14 +151,14 @@ describe('Startup Orchestrator Contract', () => { return pluginList.map((p) => ({ plugin: p, success: true, - duration: options.timeout ? 10 : 20, + durationMs: options.timeoutMs ? 10 : 20, })); }, rollback: async () => {}, checkHealth: async () => ({ healthy: true, checkedAt: Date.now() }), }; - const results = await orchestrator.orchestrateStartup(plugins, { timeout: 5000 }); + const results = await orchestrator.orchestrateStartup(plugins, { timeoutMs: 5000 }); expect(results).toHaveLength(2); expect(results[0].success).toBe(true); expect(results[1].plugin.name).toBe('auth'); diff --git a/packages/spec/src/kernel/events.test.ts b/packages/spec/src/kernel/events.test.ts index 3c91808ea6..1bf49542b6 100644 --- a/packages/spec/src/kernel/events.test.ts +++ b/packages/spec/src/kernel/events.test.ts @@ -358,7 +358,7 @@ describe('EventPersistenceSchema', () => { it('should accept valid minimal persistence config', () => { const config: EventPersistence = { enabled: true, - retention: 30, + retentionDays: 30, }; expect(() => EventPersistenceSchema.parse(config)).not.toThrow(); @@ -366,7 +366,7 @@ describe('EventPersistenceSchema', () => { it('should apply default values', () => { const config = EventPersistenceSchema.parse({ - retention: 30, + retentionDays: 30, }); expect(config.enabled).toBe(false); @@ -375,42 +375,42 @@ describe('EventPersistenceSchema', () => { it('should accept config with all fields', () => { const config = { enabled: true, - retention: 90, + retentionDays: 90, filter: (event: Event) => event.name.startsWith('audit.'), }; const parsed = EventPersistenceSchema.parse(config); expect(parsed.enabled).toBe(true); - expect(parsed.retention).toBe(90); + expect(parsed.retentionDays).toBe(90); expect(parsed.filter).toBeDefined(); }); it('should accept different retention periods', () => { const retentions = [1, 7, 30, 90, 365]; - retentions.forEach(retention => { - const config = { enabled: true, retention }; + retentions.forEach(retentionDays => { + const config = { enabled: true, retentionDays }; const parsed = EventPersistenceSchema.parse(config); - expect(parsed.retention).toBe(retention); + expect(parsed.retentionDays).toBe(retentionDays); }); }); it('should reject negative retention', () => { expect(() => EventPersistenceSchema.parse({ enabled: true, - retention: -1, + retentionDays: -1, })).toThrow(); expect(() => EventPersistenceSchema.parse({ enabled: true, - retention: 0, + retentionDays: 0, })).toThrow(); }); it('should accept filter function', () => { const config = { enabled: true, - retention: 60, + retentionDays: 60, filter: (event: Event) => { return event.name.startsWith('critical.') || event.metadata.source === 'security.plugin'; @@ -423,7 +423,7 @@ describe('EventPersistenceSchema', () => { it('should handle disabled persistence', () => { const config = { enabled: false, - retention: 30, + retentionDays: 30, }; const parsed = EventPersistenceSchema.parse(config); @@ -479,7 +479,7 @@ describe('Event System Integration', () => { // Configure persistence const persistence: EventPersistence = { enabled: true, - retention: 90, + retentionDays: 90, filter: (e: Event) => e.name.startsWith('user.'), }; @@ -567,7 +567,7 @@ describe('EventSourcingConfigSchema', () => { const config = { enabled: true, snapshotInterval: 100, - retention: 365, + retentionDays: 365, aggregateTypes: ['order', 'customer'], }; @@ -580,7 +580,7 @@ describe('EventSourcingConfigSchema', () => { expect(config.enabled).toBe(false); expect(config.snapshotInterval).toBe(100); expect(config.snapshotRetention).toBe(10); - expect(config.retention).toBe(365); + expect(config.retentionDays).toBe(365); }); }); @@ -751,7 +751,7 @@ describe('EventBusConfigSchema', () => { const config: EventBusConfig = { persistence: { enabled: true, - retention: 365, + retentionDays: 365, }, queue: { concurrency: 20, diff --git a/packages/spec/src/kernel/package-upgrade.test.ts b/packages/spec/src/kernel/package-upgrade.test.ts index 15081e4e4e..388ceac465 100644 --- a/packages/spec/src/kernel/package-upgrade.test.ts +++ b/packages/spec/src/kernel/package-upgrade.test.ts @@ -107,7 +107,7 @@ describe('UpgradePlanSchema', () => { dependencyUpgrades: [ { packageId: 'com.acme.base', fromVersion: '1.0.0', toVersion: '1.1.0' }, ], - estimatedDuration: 120, + estimatedDurationSeconds: 120, summary: 'Major upgrade adding Deals module and restructuring Account object', }; const parsed = UpgradePlanSchema.parse(plan); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index 84aa2e5657..6658a07e62 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -126,12 +126,12 @@ describe('Plugin Lifecycle Advanced Schemas', () => { timestamp: new Date().toISOString(), message: 'Plugin is operating normally', metrics: { - uptime: 3600000, + uptimeMs: 3600000, memoryUsage: 52428800, cpuUsage: 15.5, activeConnections: 10, errorRate: 0.1, - responseTime: 150, + responseTimeMs: 150, }, checks: [ { @@ -153,7 +153,7 @@ describe('Plugin Lifecycle Advanced Schemas', () => { }; const result = PluginHealthReportSchema.parse(report); expect(result.status).toBe('healthy'); - expect(result.metrics?.uptime).toBe(3600000); + expect(result.metrics?.uptimeMs).toBe(3600000); expect(result.checks).toHaveLength(2); }); diff --git a/packages/spec/src/kernel/plugin-security-advanced.test.ts b/packages/spec/src/kernel/plugin-security-advanced.test.ts index f3555f4daf..18117bcc10 100644 --- a/packages/spec/src/kernel/plugin-security-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-security-advanced.test.ts @@ -243,7 +243,7 @@ describe('Plugin Security Advanced Schemas', () => { authentication: { required: true, methods: ['jwt' as const, 'api-key' as const], - tokenExpiration: 3600, + tokenExpirationSeconds: 3600, }, encryption: { dataAtRest: true, @@ -254,7 +254,7 @@ describe('Plugin Security Advanced Schemas', () => { auditLog: { enabled: true, events: ['auth', 'data-access', 'config-change'], - retention: 90, + retentionDays: 90, }, }; const result = KernelSecurityPolicySchema.parse(policy); diff --git a/packages/spec/src/kernel/plugin-security.test.ts b/packages/spec/src/kernel/plugin-security.test.ts index 5c01471616..54bde78284 100644 --- a/packages/spec/src/kernel/plugin-security.test.ts +++ b/packages/spec/src/kernel/plugin-security.test.ts @@ -184,7 +184,7 @@ describe('Plugin Security Protocol', () => { conflicts: [], errors: [], installOrder: ['com.acme.app'], - resolvedIn: 150, + resolvedInMs: 150, }; const result = PackageDependencyResolutionResultSchema.safeParse(validResult); diff --git a/packages/spec/src/kernel/plugin-versioning.test.ts b/packages/spec/src/kernel/plugin-versioning.test.ts index 2357a6ff0d..379c820653 100644 --- a/packages/spec/src/kernel/plugin-versioning.test.ts +++ b/packages/spec/src/kernel/plugin-versioning.test.ts @@ -350,7 +350,7 @@ describe('Plugin Versioning Schemas', () => { enabled: true, strategy: 'canary' as const, percentage: 10, - duration: 3600000, + durationMs: 3600000, }, }; const result = MultiVersionSupportSchema.parse(config); diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index 461a113c3d..b5fcce9a12 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -12,7 +12,7 @@ describe('Startup Orchestrator Protocol', () => { const options = {}; const result = StartupOptionsSchema.parse(options); - expect(result.timeout).toBe(30000); + expect(result.timeoutMs).toBe(30000); expect(result.rollbackOnFailure).toBe(true); expect(result.healthCheck).toBe(false); expect(result.parallel).toBe(false); @@ -20,7 +20,7 @@ describe('Startup Orchestrator Protocol', () => { it('should validate custom options', () => { const options = { - timeout: 60000, + timeoutMs: 60000, rollbackOnFailure: false, healthCheck: true, parallel: true, @@ -30,14 +30,14 @@ describe('Startup Orchestrator Protocol', () => { const result = StartupOptionsSchema.safeParse(options); expect(result.success).toBe(true); if (result.success) { - expect(result.data.timeout).toBe(60000); + expect(result.data.timeoutMs).toBe(60000); expect(result.data.context).toEqual({ custom: 'data' }); } }); it('should reject negative timeout', () => { const options = { - timeout: -1000, + timeoutMs: -1000, }; const result = StartupOptionsSchema.safeParse(options); @@ -80,7 +80,7 @@ describe('Startup Orchestrator Protocol', () => { version: '1.0.0', }, success: true, - duration: 1250, + durationMs: 1250, }; const result = PluginStartupResultSchema.safeParse(successResult); @@ -94,7 +94,7 @@ describe('Startup Orchestrator Protocol', () => { version: '1.0.0', }, success: false, - duration: 500, + durationMs: 500, error: { name: 'Error', message: 'Connection failed' }, }; @@ -108,7 +108,7 @@ describe('Startup Orchestrator Protocol', () => { name: 'crm-plugin', }, success: true, - duration: 1250, + durationMs: 1250, health: { healthy: true, checkedAt: Date.now(), @@ -123,7 +123,7 @@ describe('Startup Orchestrator Protocol', () => { const invalidResult = { plugin: { name: 'test' }, success: true, - duration: -100, + durationMs: -100, }; const result = PluginStartupResultSchema.safeParse(invalidResult); @@ -138,15 +138,15 @@ describe('Startup Orchestrator Protocol', () => { { plugin: { name: 'plugin1', version: '1.0.0' }, success: true, - duration: 1200, + durationMs: 1200, }, { plugin: { name: 'plugin2', version: '2.0.0' }, success: true, - duration: 850, + durationMs: 850, }, ], - totalDuration: 2050, + totalDurationMs: 2050, allSuccessful: true, }; @@ -160,16 +160,16 @@ describe('Startup Orchestrator Protocol', () => { { plugin: { name: 'plugin1' }, success: true, - duration: 1200, + durationMs: 1200, }, { plugin: { name: 'plugin2' }, success: false, - duration: 850, + durationMs: 850, error: { name: 'Error', message: 'Startup failed' }, }, ], - totalDuration: 2050, + totalDurationMs: 2050, allSuccessful: false, rolledBack: ['plugin1'], }; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts new file mode 100644 index 0000000000..c235962efb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventPersistence__retention.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `EventPersistence.retention` +// said "Days to retain persisted events" in prose and nothing else. Renamed to +// `retentionDays`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: an `EventPersistence` hangs off `EventBusConfig`, the event +// bus's construction argument — never a stack collection member (`stack.zod.ts` +// declares no `eventBus` key) and never a stored sys_metadata row, so the +// conversion chain has no seam that would see one. The semantic entry +// `kernel-event-bus-retention-unit-in-key` carries the prescription. +export const entry = 'kernel/EventPersistence:retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts new file mode 100644 index 0000000000..806d93bc86 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__EventSourcingConfig__retention.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `EventSourcingConfig.retention` +// said "Days to retain events" in prose and nothing else — two keys above the +// count-valued `snapshotRetention`, so `retention: 365` and +// `snapshotRetention: 10` read as the same kind of number and are not. Renamed +// to `retentionDays`; the value is unchanged, and `snapshotRetention` keeps its +// name because a count has no unit to carry. Tombstoned with `retiredKey()`. +// No D2 conversion, for the reason the sibling `EventPersistence:retention` +// entry records; `kernel-event-bus-retention-unit-in-key` is the prescription. +export const entry = 'kernel/EventSourcingConfig:retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts new file mode 100644 index 0000000000..b2dfd34de6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__auditLog.retention.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `KernelSecurityPolicy.auditLog.retention` said "Log retention in days" in +// prose and nothing else. Renamed to `retentionDays`; the value is unchanged. +// Tombstoned with `retiredKey()`. This is the THIRD bare `retention` this card +// renames and the second unit-bearing one to land on `retentionDays` — the +// spelling is now uniform across the kernel. No D2 conversion; see +// `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/KernelSecurityPolicy:auditLog.retention'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts new file mode 100644 index 0000000000..8a572cf130 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__KernelSecurityPolicy__authentication.tokenExpiration.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `KernelSecurityPolicy.authentication.tokenExpiration` said "Token expiration +// in seconds" in prose and nothing else — on a policy whose rate-limit window +// two blocks above was ALREADY spelled `windowMs`, so one policy document +// carried both conventions. Renamed to `tokenExpirationSeconds`; the value is +// unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a +// `KernelSecurityPolicy` is a plugin security manifest's policy block, never a +// stack collection member. See `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/KernelSecurityPolicy:authentication.tokenExpiration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts new file mode 100644 index 0000000000..63b1f9b42c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MultiVersionSupport__rollout.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `MultiVersionSupport.rollout.duration` said "Rollout duration in +// milliseconds" in prose and nothing else, directly beside the unit-less +// `percentage` — two bare numbers on one block, one a proportion and one a +// span. Renamed to `durationMs`; the value is unchanged, and `percentage` +// keeps its name because a proportion has no time unit to carry. Tombstoned +// with `retiredKey()`. No D2 conversion: `MultiVersionSupport` is a plugin +// version-routing configuration a host constructs, never a stack collection +// member. See `kernel-package-lifecycle-durations-unit-in-key`. +export const entry = 'kernel/MultiVersionSupport:rollout.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts new file mode 100644 index 0000000000..9d1d31e11d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PackageDependencyResolutionResult__resolvedIn.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `PackageDependencyResolutionResult.resolvedIn` said "Time taken to resolve +// dependencies in milliseconds" in prose and nothing else. Renamed to +// `resolvedInMs`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: the result is EMITTED by a dependency resolution run, never +// authored into a metadata document. See +// `kernel-package-lifecycle-durations-unit-in-key`. +export const entry = 'kernel/PackageDependencyResolutionResult:resolvedIn'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts new file mode 100644 index 0000000000..13ac09eb66 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.responseTime.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` +// said "Average response time in ms" in prose and nothing else. Renamed to +// `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`. +// ⚠️ Not to be confused with `PluginSecurityManifest.vulnerabilityDisclosure.responseTime`, +// the identically-named key this same card renames to `responseTimeHours` — +// same bare name, different unit, which is the confusion ruling B removes. No +// D2 conversion; `kernel-plugin-health-report-durations-unit-in-key` carries +// the prescription. +export const entry = 'kernel/PluginHealthReport:metrics.responseTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts new file mode 100644 index 0000000000..de4dfeac42 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginHealthReport__metrics.uptime.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.uptime` +// said "Plugin uptime in milliseconds" in prose and nothing else, while this +// same platform serves a SECONDS-valued `uptime` on `GET /health` (the protocol +// lifecycle page had to spend a paragraph telling the two apart). Renamed to +// `uptimeMs`; the value is unchanged. Tombstoned with `retiredKey()` inside the +// live `metrics` block — a tombstone whose siblings must keep parsing. No D2 +// conversion: a health report is emitted by the monitor at runtime +// (`packages/core/src/health-monitor.ts`), never authored. See +// `kernel-plugin-health-report-durations-unit-in-key`. +export const entry = 'kernel/PluginHealthReport:metrics.uptime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts new file mode 100644 index 0000000000..e66cb4e21f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginSecurityManifest__vulnerabilityDisclosure.responseTime.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` said "Expected +// response time in hours" in prose and nothing else. Renamed to +// `responseTimeHours`; the value is unchanged. This is the card's sharpest +// case: `PluginHealthReport.metrics.responseTime` carried the SAME bare name +// for a MILLISECOND value, so `responseTime: 24` meant a day on one kernel +// shape and 24ms on another. Tombstoned with `retiredKey()`. No D2 conversion: +// a security manifest is a package artifact a publisher ships, never a stack +// collection member. See `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts new file mode 100644 index 0000000000..7b23713e3b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__PluginStartupResult__duration.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `PluginStartupResult.duration` +// said "Time taken to start the plugin in milliseconds" in prose and nothing +// else. Renamed to `durationMs`; the value is unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion: the result is EMITTED by the orchestrator +// per plugin at boot, never authored. +// +// ⚠️ Note for anyone grepping: `packages/core/src/plugin-loader.ts` declares +// its OWN local `PluginStartupResult` interface — a DIFFERENT type +// (`{ success, pluginName, startTime?, error?, timedOut? }`) with no +// `duration` key at all. It is not a reader of this schema, it is untouched by +// this rename, and the divergence between the two shapes is filed separately. +// See `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/PluginStartupResult:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts new file mode 100644 index 0000000000..8d780e8b3a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__SandboxConfig__process.timeout.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `SandboxConfig.process.timeout` +// said "Process timeout in ms" in prose and nothing else. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside +// the live `process` block. ⚠️ Note for anyone grepping this file: the +// neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key whose +// describe names no unit at all, so it is outside the gate's population and is +// untouched here. No D2 conversion: a `SandboxConfig` is the isolation +// argument a host or a plugin security manifest constructs, never a stack +// collection member or a stored row. See +// `kernel-plugin-security-durations-unit-in-key`. +export const entry = 'kernel/SandboxConfig:process.timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts new file mode 100644 index 0000000000..429fbaad76 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOptions__timeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `StartupOptions.timeout` said +// "Maximum time in milliseconds to wait for each plugin to start" in prose and +// nothing else — while the very contract that consumes it, +// `IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs)`, already +// named its own parameter `timeoutMs`. One boundary, two spellings. Renamed to +// `timeoutMs`; the value and the 30000 default are unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion: `StartupOptions` is the argument a host +// passes to `orchestrateStartup()` at boot, never a stack collection member or +// a stored row. See `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/StartupOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts new file mode 100644 index 0000000000..1a59d97e09 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__StartupOrchestrationResult__totalDuration.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. +// `StartupOrchestrationResult.totalDuration` said "Total time taken for all +// plugins in milliseconds" in prose and nothing else. Renamed to +// `totalDurationMs`; the value is unchanged, and it now agrees with the +// per-plugin `durationMs` it sums. Tombstoned with `retiredKey()`. No D2 +// conversion: the result is EMITTED at the end of a boot, never authored. See +// `kernel-startup-orchestrator-durations-unit-in-key`. +export const entry = 'kernel/StartupOrchestrationResult:totalDuration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts new file mode 100644 index 0000000000..a4b2ea5c73 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__UpgradePlan__estimatedDuration.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15678 (stack card 3/6 of #14478) — ruling B. `UpgradePlan.estimatedDuration` +// said "Estimated upgrade duration in seconds" in prose and nothing else — and +// SECONDS is the minority unit in this package, which is exactly why the bare +// name misleads. Renamed to `estimatedDurationSeconds`; the value is unchanged. +// Tombstoned with `retiredKey()`. No D2 conversion: an `UpgradePlan` is +// GENERATED by `IPackageService.planUpgrade()` before an upgrade runs and +// carried on the `UpgradeResult`, never authored into a metadata document. The +// semantic entry `kernel-package-lifecycle-durations-unit-in-key` carries it. +export const entry = 'kernel/UpgradePlan:estimatedDuration'; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts new file mode 100644 index 0000000000..6e5b245bf6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-event-bus-retention-unit-in-key.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-event-bus-retention-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two event-bus retention windows whose name carried no unit: ' + + 'EventPersistence.retention (kernel/events/handlers.zod.ts) and ' + + 'EventSourcingConfig.retention (kernel/events/queue.zod.ts)', + replacement: 'retentionDays on both — rename each key; both values are unchanged, and so is ' + + 'the 365 default on EventSourcingConfig', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'What makes these two one entry rather than two is the neighbour they share and the one ' + + 'they do not. Both hang off EventBusConfig, so an author configuring a bus met the same ' + + 'bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare ' + + 'retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, ' + + 'not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind ' + + 'of number and are not. Suffixing the duration separates the families at the authoring ' + + 'site; snapshotRetention keeps its name, because a count has no unit to carry. Both are ' + + 'retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in ' + + 'silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event ' + + 'bus construction argument a host builds in code (stack.zod.ts declares no eventBus key ' + + 'and no metadata kind is bound to one), so it is never a stack collection member and ' + + 'never a stored sys_metadata row, and the conversion chain has no seam that would see ' + + 'one. That is what ruling B prescribes for a key that is not authorable metadata, and ' + + 'the disposition the epoch-instant renames on this same kernel took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every ' + + 'literal handed to an event bus spells retentionDays; authoring either old spelling fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps ' + + 'events for ninety days exactly as `retention: 90` did, and a config that omits the key ' + + 'still gets the 365 default on EventSourcingConfig. The positive-integer bound rides ' + + 'along with the renamed key, so a zero or negative window is still refused — the pin ' + + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' + + 'dropped.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts new file mode 100644 index 0000000000..8fa7a5ebd3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-package-lifecycle-durations-unit-in-key.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-package-lifecycle-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three package and version lifecycle durations whose name carried no unit: ' + + 'UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), ' + + 'PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and ' + + 'MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)', + replacement: 'estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every ' + + 'value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one story told to one audience — a package ' + + 'being planned, resolved and rolled out — and because the group is precisely where the ' + + 'unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are ' + + 'MILLISECONDS, three adjacent measurements of the same install, two units, none of them ' + + 'named. A reader who learned the unit from one of these three learned it wrongly for the ' + + 'other two. The rollout case adds a second confusion of its own: duration sat directly ' + + 'beside the unit-less percentage, so one block carried a proportion and a span as ' + + 'indistinguishable bare numbers; percentage keeps its name, because a proportion has no ' + + 'time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so ' + + 'a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a ' + + 'PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport ' + + 'is a version-routing argument a host constructs — none is a stack collection member or ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see one. That ' + + 'is what ruling B prescribes for a key that is not authorable metadata. #15678, #14478, ' + + 'ADR-0087.', + acceptanceCriteria: + 'Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and ' + + 'every caller reads it under that name; every dependency-resolution producer returns ' + + 'resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old ' + + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription. Behaviour is unchanged in every case, and the unit split is the thing to ' + + 'check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two ' + + 'MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value ' + + 'between the two would be a thousand-fold error the parse cannot catch, since both ' + + 'bounds accept any non-negative integer.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts new file mode 100644 index 0000000000..9c957a2a31 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-health-report-durations-unit-in-key.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-plugin-health-report-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two plugin health-report metrics whose name carried no unit: ' + + 'PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'uptimeMs and responseTimeMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'uptime is the case this rule was written for, and this repo had already paid for it in ' + + 'documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a ' + + 'MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a ' + + 'standing paragraph whose whole job was telling the two apart ("metrics.uptime is in ' + + 'milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning ' + + 'that has to exist is the symptom; the key name is where the fix belongs. responseTime ' + + 'moves with it because it is a sibling in the same metrics block and because the ' + + 'identical bare name means HOURS on ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. ' + + 'The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a ' + + 'percentage, activeConnections is a count and errorRate is a rate — none is a duration, ' + + 'and this rule reaches durations only. Both are retiredKey() tombstones inside the live ' + + 'metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 ' + + 'conversion: a health report is EMITTED by the monitor each round ' + + '(packages/core/src/health-monitor.ts) and kept in memory — never authored into a ' + + 'metadata document, never a stored sys_metadata row — so the conversion chain has no ' + + 'seam that would see one, the same disposition HealthStatus.timestamp took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely ' + + 'packages/core/src/health-monitor.ts, the one production writer, whose metrics block now ' + + 'reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime ' + + 'moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: the value is still Date.now() - startTime in milliseconds, and a report that ' + + 'omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not ' + + 'part of this and must not be renamed with it: the seconds-valued uptime of the ' + + 'GET /health response body, and the free-form HealthStatus.details record, which is a ' + + 'z.record whose contents this rule does not reach.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts new file mode 100644 index 0000000000..88fd8a138e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-plugin-security-durations-unit-in-key.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-plugin-security-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four plugin-security durations whose name carried no unit: ' + + 'SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, ' + + 'KernelSecurityPolicy.auditLog.retention and ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime ' + + '(kernel/plugin-security-advanced.zod.ts)', + replacement: 'timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These four are one entry because they are one document — everything here hangs off a ' + + 'PluginSecurityManifest — and because together they are this rule\'s clearest case in ' + + 'the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units ' + + '(milliseconds, seconds, days, hours) and not one of them said so in its name. The ' + + 'sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher ' + + 'promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by ' + + 'the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a ' + + 'day on one kernel shape and a fortieth of a second on another, with nothing at the ' + + 'authoring site to tell them apart. The policy was already inconsistent with itself, ' + + 'too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled ' + + 'windowMs, so one security policy carried both conventions. All four are retiredKey() ' + + 'tombstones inside live blocks whose siblings must keep parsing; no shape here is ' + + 'strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 ' + + 'conversion: a PluginSecurityManifest is a package artifact a publisher ships and a ' + + 'SandboxConfig is the isolation argument a host constructs, so neither is a stack ' + + 'collection member or a stored sys_metadata row and the conversion chain has no seam ' + + 'that would see one. That is what ruling B prescribes for a key that is not authorable ' + + 'metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this ' + + 'same file names no unit anywhere, so it is outside the gate\'s population and outside ' + + 'this rename. #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and ' + + 'PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin ' + + 'sandbox or security manifest, spells the suffixed keys; authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned ' + + 'process after thirty seconds exactly as `timeout: 30000` did, a policy with ' + + '`tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still ' + + 'keeps ninety days of audit log, and `responseTimeHours: 24` still promises a ' + + 'twenty-four-hour disclosure response. Every integer bound rides along with its renamed ' + + 'key. Verify the sharp pair explicitly: a manifest and a health report in the same ' + + 'codebase must now read responseTimeHours and responseTimeMs respectively, and neither ' + + 'accepts the bare name.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts new file mode 100644 index 0000000000..d89a1fd9a6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-startup-orchestrator-durations-unit-in-key.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'kernel-startup-orchestrator-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three startup-orchestration durations whose name carried no unit: ' + + 'StartupOptions.timeout, PluginStartupResult.duration and ' + + 'StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)', + replacement: 'timeoutMs, durationMs and totalDurationMs — rename each key; every value is ' + + 'unchanged, and so is the 30000 default on StartupOptions', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one boundary: a host passes StartupOptions ' + + 'in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back ' + + 'from the same call. The file already contained its own counter-example — ' + + 'IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter ' + + 'timeoutMs while the options object beside it said timeout, so one contract carried both ' + + 'conventions and the suffixed one was already the honest half. totalDuration is the sum ' + + 'of the per-plugin durations, so the two had to move together or the aggregate would ' + + 'have been spelled unlike its parts. All three are retiredKey() tombstones; none of ' + + 'these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry ' + + 'and not a D2 conversion: StartupOptions is a boot-time call argument and the two result ' + + 'shapes are emitted measurements, so none is ever a stack collection member or a stored ' + + 'sys_metadata row and the conversion chain has no seam that would see one — the same ' + + 'disposition HealthStatus.timestamp took on this very file ' + + '(epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ' + + '#15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every ' + + 'implementation that BUILDS a PluginStartupResult spells durationMs and every one that ' + + 'builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits ' + + 'five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults ' + + 'to 30000, and the non-negative bounds ride along with the renamed keys so a negative ' + + 'timeout or a negative duration is still refused. One thing this rename deliberately ' + + 'does NOT touch: packages/core/src/plugin-loader.ts declares its own local ' + + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' + + 'duration key — which is not a reader of this schema and is unchanged.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d9853ea10d..a55c380619 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7408,6 +7408,208 @@ const step18: MigrationStep = { + 'OS_PREVIEW_BASE_DOMAINS keep working exactly as documented ' + '(deployment routing, never identity).', }, + { + id: 'kernel-event-bus-retention-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two event-bus retention windows whose name carried no unit: ' + + 'EventPersistence.retention (kernel/events/handlers.zod.ts) and ' + + 'EventSourcingConfig.retention (kernel/events/queue.zod.ts)', + replacement: 'retentionDays on both — rename each key; both values are unchanged, and so is ' + + 'the 365 default on EventSourcingConfig', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'What makes these two one entry rather than two is the neighbour they share and the one ' + + 'they do not. Both hang off EventBusConfig, so an author configuring a bus met the same ' + + 'bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare ' + + 'retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, ' + + 'not a span of time. `retention: 365` and `snapshotRetention: 10` read as the same kind ' + + 'of number and are not. Suffixing the duration separates the families at the authoring ' + + 'site; snapshotRetention keeps its name, because a count has no unit to carry. Both are ' + + 'retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in ' + + 'silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event ' + + 'bus construction argument a host builds in code (stack.zod.ts declares no eventBus key ' + + 'and no metadata kind is bound to one), so it is never a stack collection member and ' + + 'never a stored sys_metadata row, and the conversion chain has no seam that would see ' + + 'one. That is what ruling B prescribes for a key that is not authorable metadata, and ' + + 'the disposition the epoch-instant renames on this same kernel took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every ' + + 'literal handed to an event bus spells retentionDays; authoring either old spelling fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in both cases: a bus configured with `retentionDays: 90` keeps ' + + 'events for ninety days exactly as `retention: 90` did, and a config that omits the key ' + + 'still gets the 365 default on EventSourcingConfig. The positive-integer bound rides ' + + 'along with the renamed key, so a zero or negative window is still refused — the pin ' + + 'covering that in kernel/events.test.ts was moved onto the new spelling rather than ' + + 'dropped.', + }, + { + id: 'kernel-package-lifecycle-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three package and version lifecycle durations whose name carried no unit: ' + + 'UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), ' + + 'PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and ' + + 'MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts)', + replacement: 'estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every ' + + 'value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one story told to one audience — a package ' + + 'being planned, resolved and rolled out — and because the group is precisely where the ' + + 'unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are ' + + 'MILLISECONDS, three adjacent measurements of the same install, two units, none of them ' + + 'named. A reader who learned the unit from one of these three learned it wrongly for the ' + + 'other two. The rollout case adds a second confusion of its own: duration sat directly ' + + 'beside the unit-less percentage, so one block carried a proportion and a span as ' + + 'indistinguishable bare numbers; percentage keeps its name, because a proportion has no ' + + 'time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so ' + + 'a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a ' + + 'PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport ' + + 'is a version-routing argument a host constructs — none is a stack collection member or ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see one. That ' + + 'is what ruling B prescribes for a key that is not authorable metadata. #15678, #14478, ' + + 'ADR-0087.', + acceptanceCriteria: + 'Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and ' + + 'every caller reads it under that name; every dependency-resolution producer returns ' + + 'resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old ' + + 'spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription. Behaviour is unchanged in every case, and the unit split is the thing to ' + + 'check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two ' + + 'MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value ' + + 'between the two would be a thousand-fold error the parse cannot catch, since both ' + + 'bounds accept any non-negative integer.', + }, + { + id: 'kernel-plugin-health-report-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two plugin health-report metrics whose name carried no unit: ' + + 'PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime ' + + '(kernel/plugin-lifecycle-advanced.zod.ts)', + replacement: 'uptimeMs and responseTimeMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'uptime is the case this rule was written for, and this repo had already paid for it in ' + + 'documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a ' + + 'MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a ' + + 'standing paragraph whose whole job was telling the two apart ("metrics.uptime is in ' + + 'milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning ' + + 'that has to exist is the symptom; the key name is where the fix belongs. responseTime ' + + 'moves with it because it is a sibling in the same metrics block and because the ' + + 'identical bare name means HOURS on ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. ' + + 'The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a ' + + 'percentage, activeConnections is a count and errorRate is a rate — none is a duration, ' + + 'and this rule reaches durations only. Both are retiredKey() tombstones inside the live ' + + 'metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 ' + + 'conversion: a health report is EMITTED by the monitor each round ' + + '(packages/core/src/health-monitor.ts) and kept in memory — never authored into a ' + + 'metadata document, never a stored sys_metadata row — so the conversion chain has no ' + + 'seam that would see one, the same disposition HealthStatus.timestamp took ' + + '(epoch-instant-keys-renamed). #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely ' + + 'packages/core/src/health-monitor.ts, the one production writer, whose metrics block now ' + + 'reads `uptimeMs: Date.now() - startTime`. Every consumer reading result.metrics?.uptime ' + + 'moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: the value is still Date.now() - startTime in milliseconds, and a report that ' + + 'omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not ' + + 'part of this and must not be renamed with it: the seconds-valued uptime of the ' + + 'GET /health response body, and the free-form HealthStatus.details record, which is a ' + + 'z.record whose contents this rule does not reach.', + }, + { + id: 'kernel-plugin-security-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the four plugin-security durations whose name carried no unit: ' + + 'SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, ' + + 'KernelSecurityPolicy.auditLog.retention and ' + + 'PluginSecurityManifest.vulnerabilityDisclosure.responseTime ' + + '(kernel/plugin-security-advanced.zod.ts)', + replacement: 'timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These four are one entry because they are one document — everything here hangs off a ' + + 'PluginSecurityManifest — and because together they are this rule\'s clearest case in ' + + 'the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units ' + + '(milliseconds, seconds, days, hours) and not one of them said so in its name. The ' + + 'sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher ' + + 'promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by ' + + 'the same card, the identical bare name meant MILLISECONDS. So `responseTime: 24` was a ' + + 'day on one kernel shape and a fortieth of a second on another, with nothing at the ' + + 'authoring site to tell them apart. The policy was already inconsistent with itself, ' + + 'too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled ' + + 'windowMs, so one security policy carried both conventions. All four are retiredKey() ' + + 'tombstones inside live blocks whose siblings must keep parsing; no shape here is ' + + 'strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 ' + + 'conversion: a PluginSecurityManifest is a package artifact a publisher ships and a ' + + 'SandboxConfig is the isolation argument a host constructs, so neither is a stack ' + + 'collection member or a stored sys_metadata row and the conversion chain has no seam ' + + 'that would see one. That is what ruling B prescribes for a key that is not authorable ' + + 'metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this ' + + 'same file names no unit anywhere, so it is outside the gate\'s population and outside ' + + 'this rename. #15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and ' + + 'PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin ' + + 'sandbox or security manifest, spells the suffixed keys; authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: a sandbox given `timeoutMs: 30000` kills a spawned ' + + 'process after thirty seconds exactly as `timeout: 30000` did, a policy with ' + + '`tokenExpirationSeconds: 3600` still expires tokens hourly, `retentionDays: 90` still ' + + 'keeps ninety days of audit log, and `responseTimeHours: 24` still promises a ' + + 'twenty-four-hour disclosure response. Every integer bound rides along with its renamed ' + + 'key. Verify the sharp pair explicitly: a manifest and a health report in the same ' + + 'codebase must now read responseTimeHours and responseTimeMs respectively, and neither ' + + 'accepts the bare name.', + }, + { + id: 'kernel-startup-orchestrator-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the three startup-orchestration durations whose name carried no unit: ' + + 'StartupOptions.timeout, PluginStartupResult.duration and ' + + 'StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)', + replacement: 'timeoutMs, durationMs and totalDurationMs — rename each key; every value is ' + + 'unchanged, and so is the 30000 default on StartupOptions', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These three are one entry because they are one boundary: a host passes StartupOptions ' + + 'in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back ' + + 'from the same call. The file already contained its own counter-example — ' + + 'IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter ' + + 'timeoutMs while the options object beside it said timeout, so one contract carried both ' + + 'conventions and the suffixed one was already the honest half. totalDuration is the sum ' + + 'of the per-plugin durations, so the two had to move together or the aggregate would ' + + 'have been spelled unlike its parts. All three are retiredKey() tombstones; none of ' + + 'these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry ' + + 'and not a D2 conversion: StartupOptions is a boot-time call argument and the two result ' + + 'shapes are emitted measurements, so none is ever a stack collection member or a stored ' + + 'sys_metadata row and the conversion chain has no seam that would see one — the same ' + + 'disposition HealthStatus.timestamp took on this very file ' + + '(epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ' + + '#15678, #14478, ADR-0087.', + acceptanceCriteria: + 'Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every ' + + 'implementation that BUILDS a PluginStartupResult spells durationMs and every one that ' + + 'builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling ' + + 'fails to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged in every case: an orchestrator given `timeoutMs: 5000` waits ' + + 'five seconds per plugin exactly as `timeout: 5000` did, an omitted key still defaults ' + + 'to 30000, and the non-negative bounds ride along with the renamed keys so a negative ' + + 'timeout or a negative duration is still refused. One thing this rename deliberately ' + + 'does NOT touch: packages/core/src/plugin-loader.ts declares its own local ' + + 'PluginStartupResult interface — a different type, carrying startTime rather than any ' + + 'duration key — which is not a reader of this schema and is unchanged.', + }, { id: 'memory-persistence-placeholder-refused', surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + @@ -9710,6 +9912,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `${defKey}:${name}` membership per def, never by radiating from a neighbour. // See `18.integration__Connector__errorMapping.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:errorMapping', + // #15678 (stack card 3/6 of #14478) — ruling B. `EventPersistence.retention` + // said "Days to retain persisted events" in prose and nothing else. Renamed to + // `retentionDays`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: an `EventPersistence` hangs off `EventBusConfig`, the event + // bus's construction argument — never a stack collection member (`stack.zod.ts` + // declares no `eventBus` key) and never a stored sys_metadata row, so the + // conversion chain has no seam that would see one. The semantic entry + // `kernel-event-bus-retention-unit-in-key` carries the prescription. + 'kernel/EventPersistence:retention', + // #15678 (stack card 3/6 of #14478) — ruling B. `EventSourcingConfig.retention` + // said "Days to retain events" in prose and nothing else — two keys above the + // count-valued `snapshotRetention`, so `retention: 365` and + // `snapshotRetention: 10` read as the same kind of number and are not. Renamed + // to `retentionDays`; the value is unchanged, and `snapshotRetention` keeps its + // name because a count has no unit to carry. Tombstoned with `retiredKey()`. + // No D2 conversion, for the reason the sibling `EventPersistence:retention` + // entry records; `kernel-event-bus-retention-unit-in-key` is the prescription. + 'kernel/EventSourcingConfig:retention', // #15676 — the epoch-instant half of #14478 ruling B. `HealthStatus.timestamp` // is the instant the health check RAN: it moved onto the shared `EpochMs` schema // and was renamed `checkedAt`, which also states what the instant marks. @@ -9794,6 +10014,23 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // // Registered under 18, not 17, for the reason that sibling entry records. 'kernel/KernelContext:startTime', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `KernelSecurityPolicy.auditLog.retention` said "Log retention in days" in + // prose and nothing else. Renamed to `retentionDays`; the value is unchanged. + // Tombstoned with `retiredKey()`. This is the THIRD bare `retention` this card + // renames and the second unit-bearing one to land on `retentionDays` — the + // spelling is now uniform across the kernel. No D2 conversion; see + // `kernel-plugin-security-durations-unit-in-key`. + 'kernel/KernelSecurityPolicy:auditLog.retention', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `KernelSecurityPolicy.authentication.tokenExpiration` said "Token expiration + // in seconds" in prose and nothing else — on a policy whose rate-limit window + // two blocks above was ALREADY spelled `windowMs`, so one policy document + // carried both conventions. Renamed to `tokenExpirationSeconds`; the value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: a + // `KernelSecurityPolicy` is a plugin security manifest's policy block, never a + // stack collection member. See `kernel-plugin-security-durations-unit-in-key`. + 'kernel/KernelSecurityPolicy:authentication.tokenExpiration', // #11332 — ADR-0049 enforce-or-remove on the plugin manifest's three dead // top-level containers (triage graded 2026-08-23; cloud leg measured clean // 2026-08-29 on #12400 with positive controls). The census found ZERO reads @@ -10123,6 +10360,24 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // through the tombstone (`tsc` + the parse) and the D3 semantic entry // `metadata-customization-protocol-retired`. 'kernel/MetadataPluginConfig:mergeStrategy', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `MultiVersionSupport.rollout.duration` said "Rollout duration in + // milliseconds" in prose and nothing else, directly beside the unit-less + // `percentage` — two bare numbers on one block, one a proportion and one a + // span. Renamed to `durationMs`; the value is unchanged, and `percentage` + // keeps its name because a proportion has no time unit to carry. Tombstoned + // with `retiredKey()`. No D2 conversion: `MultiVersionSupport` is a plugin + // version-routing configuration a host constructs, never a stack collection + // member. See `kernel-package-lifecycle-durations-unit-in-key`. + 'kernel/MultiVersionSupport:rollout.duration', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `PackageDependencyResolutionResult.resolvedIn` said "Time taken to resolve + // dependencies in milliseconds" in prose and nothing else. Renamed to + // `resolvedInMs`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: the result is EMITTED by a dependency resolution run, never + // authored into a metadata document. See + // `kernel-package-lifecycle-durations-unit-in-key`. + 'kernel/PackageDependencyResolutionResult:resolvedIn', // #12032 — ADR-0049 enforce-or-remove, one class over from #12428 (PR #12571) // and #12340 (PR #12425) in the same host-driven lifecycle library, and for a // sharper reason than either: this key HAD a reader that acted, and what it did @@ -10282,6 +10537,77 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // registration-time refusal in `PluginHealthMonitor.registerPlugin` is the door // for the audience that exists. 'kernel/PluginHealthCheck:restartBackoff', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.responseTime` + // said "Average response time in ms" in prose and nothing else. Renamed to + // `responseTimeMs`; the value is unchanged. Tombstoned with `retiredKey()`. + // ⚠️ Not to be confused with `PluginSecurityManifest.vulnerabilityDisclosure.responseTime`, + // the identically-named key this same card renames to `responseTimeHours` — + // same bare name, different unit, which is the confusion ruling B removes. No + // D2 conversion; `kernel-plugin-health-report-durations-unit-in-key` carries + // the prescription. + 'kernel/PluginHealthReport:metrics.responseTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginHealthReport.metrics.uptime` + // said "Plugin uptime in milliseconds" in prose and nothing else, while this + // same platform serves a SECONDS-valued `uptime` on `GET /health` (the protocol + // lifecycle page had to spend a paragraph telling the two apart). Renamed to + // `uptimeMs`; the value is unchanged. Tombstoned with `retiredKey()` inside the + // live `metrics` block — a tombstone whose siblings must keep parsing. No D2 + // conversion: a health report is emitted by the monitor at runtime + // (`packages/core/src/health-monitor.ts`), never authored. See + // `kernel-plugin-health-report-durations-unit-in-key`. + 'kernel/PluginHealthReport:metrics.uptime', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` said "Expected + // response time in hours" in prose and nothing else. Renamed to + // `responseTimeHours`; the value is unchanged. This is the card's sharpest + // case: `PluginHealthReport.metrics.responseTime` carried the SAME bare name + // for a MILLISECOND value, so `responseTime: 24` meant a day on one kernel + // shape and 24ms on another. Tombstoned with `retiredKey()`. No D2 conversion: + // a security manifest is a package artifact a publisher ships, never a stack + // collection member. See `kernel-plugin-security-durations-unit-in-key`. + 'kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `PluginStartupResult.duration` + // said "Time taken to start the plugin in milliseconds" in prose and nothing + // else. Renamed to `durationMs`; the value is unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion: the result is EMITTED by the orchestrator + // per plugin at boot, never authored. + // + // ⚠️ Note for anyone grepping: `packages/core/src/plugin-loader.ts` declares + // its OWN local `PluginStartupResult` interface — a DIFFERENT type + // (`{ success, pluginName, startTime?, error?, timedOut? }`) with no + // `duration` key at all. It is not a reader of this schema, it is untouched by + // this rename, and the divergence between the two shapes is filed separately. + // See `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/PluginStartupResult:duration', + // #15678 (stack card 3/6 of #14478) — ruling B. `SandboxConfig.process.timeout` + // said "Process timeout in ms" in prose and nothing else. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()` inside + // the live `process` block. ⚠️ Note for anyone grepping this file: the + // neighbouring `RuntimeConfig.resourceLimits.timeout` is a DIFFERENT key whose + // describe names no unit at all, so it is outside the gate's population and is + // untouched here. No D2 conversion: a `SandboxConfig` is the isolation + // argument a host or a plugin security manifest constructs, never a stack + // collection member or a stored row. See + // `kernel-plugin-security-durations-unit-in-key`. + 'kernel/SandboxConfig:process.timeout', + // #15678 (stack card 3/6 of #14478) — ruling B. `StartupOptions.timeout` said + // "Maximum time in milliseconds to wait for each plugin to start" in prose and + // nothing else — while the very contract that consumes it, + // `IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs)`, already + // named its own parameter `timeoutMs`. One boundary, two spellings. Renamed to + // `timeoutMs`; the value and the 30000 default are unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion: `StartupOptions` is the argument a host + // passes to `orchestrateStartup()` at boot, never a stack collection member or + // a stored row. See `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/StartupOptions:timeout', + // #15678 (stack card 3/6 of #14478) — ruling B. + // `StartupOrchestrationResult.totalDuration` said "Total time taken for all + // plugins in milliseconds" in prose and nothing else. Renamed to + // `totalDurationMs`; the value is unchanged, and it now agrees with the + // per-plugin `durationMs` it sums. Tombstoned with `retiredKey()`. No D2 + // conversion: the result is EMITTED at the end of a boot, never authored. See + // `kernel-startup-orchestrator-durations-unit-in-key`. + 'kernel/StartupOrchestrationResult:totalDuration', // #11846 — the `TenantRuntimeContextSchema` copy of // `kernel/KernelContext:previewMode`: the def is `KernelContextSchema.extend(…)`, // so the tombstone lands in this walked shape too and `authorable-surface/` @@ -10296,6 +10622,15 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // ratchet records the two copies separately, so both are declared here. The // `previewMode` retirement registered its two copies the same way. 'kernel/TenantRuntimeContext:startTime', + // #15678 (stack card 3/6 of #14478) — ruling B. `UpgradePlan.estimatedDuration` + // said "Estimated upgrade duration in seconds" in prose and nothing else — and + // SECONDS is the minority unit in this package, which is exactly why the bare + // name misleads. Renamed to `estimatedDurationSeconds`; the value is unchanged. + // Tombstoned with `retiredKey()`. No D2 conversion: an `UpgradePlan` is + // GENERATED by `IPackageService.planUpgrade()` before an upgrade runs and + // carried on the `UpgradeResult`, never authored into a metadata document. The + // semantic entry `kernel-package-lifecycle-durations-unit-in-key` carries it. + 'kernel/UpgradePlan:estimatedDuration', // #12497 — the RESPONSE-side face of `security/ObjectPermission:allowPurge` // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key From 6bf111999a01860514e9f7099a3a886b3303b34b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 12:50:43 +0000 Subject: [PATCH 16/33] wip(spec): tombstone refusal tests and regenerated reference pages (#15678) Per-key refusal tests assert the prescription (issue code + rename text), not a bare throw, plus acceptance pins at the same magnitudes and defaults. Two deliberate NEGATIVE controls: RuntimeConfig.resourceLimits.timeout names no unit so it stays bare, and EventSourcingConfig.snapshotRetention is a count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/kernel/events-bus.mdx | 10 +- .../references/kernel/events-handlers.mdx | 3 +- .../docs/references/kernel/events-queue.mdx | 3 +- .../references/kernel/package-upgrade.mdx | 6 +- .../kernel/plugin-lifecycle-advanced.mdx | 8 +- .../kernel/plugin-security-advanced.mdx | 22 +++-- .../references/kernel/plugin-security.mdx | 3 +- .../references/kernel/plugin-versioning.mdx | 5 +- .../kernel/startup-orchestrator.mdx | 14 ++- packages/spec/src/kernel/events.test.ts | 31 ++++++ .../spec/src/kernel/package-upgrade.test.ts | 32 ++++++ .../kernel/plugin-lifecycle-advanced.test.ts | 42 ++++++++ .../kernel/plugin-security-advanced.test.ts | 98 +++++++++++++++++++ .../spec/src/kernel/plugin-security.test.ts | 23 +++++ .../spec/src/kernel/plugin-versioning.test.ts | 27 +++++ .../src/kernel/startup-orchestrator.test.ts | 63 ++++++++++++ 16 files changed, 362 insertions(+), 28 deletions(-) diff --git a/content/docs/references/kernel/events-bus.mdx b/content/docs/references/kernel/events-bus.mdx index 03b4c077e4..e476b9284c 100644 --- a/content/docs/references/kernel/events-bus.mdx +++ b/content/docs/references/kernel/events-bus.mdx @@ -27,9 +27,9 @@ const result = EventBusConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **persistence** | `{ enabled: boolean; retention: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | +| **persistence** | `{ enabled: boolean; retentionDays: integer; filter?: any; storage: Enum<'database' \| 'file' \| 's3' \| 'custom'> }` | optional | Event persistence configuration | | **queue** | `{ name: string; concurrency: integer; retryPolicy?: object; deadLetterQueue?: string; … }` | optional | Event queue configuration | -| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retention: integer; … }` | optional | Event sourcing configuration | +| **eventSourcing** | `{ enabled: boolean; snapshotInterval: integer; snapshotRetention: integer; retentionDays: integer; … }` | optional | Event sourcing configuration | | **replay** | `{ enabled: boolean }` | optional | Event replay configuration | | **webhooks** | `{ id?: string; eventPattern: string; url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH'>; … }[]` | optional | Webhook configurations | | **messageQueue** | `{ provider: Enum<'kafka' \| 'rabbitmq' \| 'aws-sqs' \| 'redis-pubsub' \| 'google-pubsub' \| 'azure-service-bus'>; topic: string; eventPattern: string; partitionKey?: string; … }` | optional | Message queue integration | @@ -42,7 +42,8 @@ const result = EventBusConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | +| **retentionDays** | `integer` | ✅ | Days to retain persisted events | +| **retention** | `never` | optional | [REMOVED] `EventPersistence.retention` was renamed to `retentionDays` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged. | | **filter** | `any` | optional | Optional filter function to select which events to persist | | **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | optional (default: `"database"`) | Storage backend for persisted events | @@ -63,7 +64,8 @@ const result = EventBusConfigSchema.parse(data); | **enabled** | `boolean` | optional (default: `false`) | Enable event sourcing | | **snapshotInterval** | `integer` | optional (default: `100`) | Create snapshot every N events | | **snapshotRetention** | `integer` | optional (default: `10`) | Number of snapshots to retain | -| **retention** | `integer` | optional (default: `365`) | Days to retain events | +| **retentionDays** | `integer` | optional (default: `365`) | Days to retain events | +| **retention** | `never` | optional | [REMOVED] `EventSourcingConfig.retention` was renamed to `retentionDays` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged. The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name. | | **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | | **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | diff --git a/content/docs/references/kernel/events-handlers.mdx b/content/docs/references/kernel/events-handlers.mdx index b1f1377621..5761bbef89 100644 --- a/content/docs/references/kernel/events-handlers.mdx +++ b/content/docs/references/kernel/events-handlers.mdx @@ -54,7 +54,8 @@ const result = EventHandlerSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable event persistence | -| **retention** | `integer` | ✅ | Days to retain persisted events | +| **retentionDays** | `integer` | ✅ | Days to retain persisted events | +| **retention** | `never` | optional | [REMOVED] `EventPersistence.retention` was renamed to `retentionDays` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged. | | **filter** | `any` | optional | Optional filter function to select which events to persist | | **storage** | `Enum<'database' \| 'file' \| 's3' \| 'custom'>` | optional (default: `"database"`) | Storage backend for persisted events | diff --git a/content/docs/references/kernel/events-queue.mdx b/content/docs/references/kernel/events-queue.mdx index 29aecd6f32..ebdf5816c5 100644 --- a/content/docs/references/kernel/events-queue.mdx +++ b/content/docs/references/kernel/events-queue.mdx @@ -70,7 +70,8 @@ const result = EventQueueConfigSchema.parse(data); | **enabled** | `boolean` | optional (default: `false`) | Enable event sourcing | | **snapshotInterval** | `integer` | optional (default: `100`) | Create snapshot every N events | | **snapshotRetention** | `integer` | optional (default: `10`) | Number of snapshots to retain | -| **retention** | `integer` | optional (default: `365`) | Days to retain events | +| **retentionDays** | `integer` | optional (default: `365`) | Days to retain events | +| **retention** | `never` | optional | [REMOVED] `EventSourcingConfig.retention` was renamed to `retentionDays` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged. The neighbouring `snapshotRetention` is a COUNT of snapshots, not a duration, so it keeps its name. | | **aggregateTypes** | `string[]` | optional | Aggregate types to enable event sourcing for | | **storage** | `{ type: Enum<'database' \| 'file' \| 's3' \| 'eventstore'>; options?: Record }` | optional | Event store configuration | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 387886614b..b396ad4217 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -198,7 +198,8 @@ Upgrade package response | **requiresMigration** | `boolean` | optional (default: `false`) | Whether data migration scripts are needed | | **migrationScripts** | `string[]` | optional | Paths to migration scripts | | **dependencyUpgrades** | `{ packageId: string; fromVersion: string; toVersion: string }[]` | optional | Dependent packages that also need upgrading | -| **estimatedDuration** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDurationSeconds** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDuration** | `never` | optional | [REMOVED] `UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `estimatedDurationSeconds`; the value (seconds) is unchanged. | | **summary** | `string` | optional | Human-readable upgrade summary | @@ -241,7 +242,8 @@ Upgrade analysis plan generated before execution | **requiresMigration** | `boolean` | optional (default: `false`) | Whether data migration scripts are needed | | **migrationScripts** | `string[]` | optional | Paths to migration scripts | | **dependencyUpgrades** | `{ packageId: string; fromVersion: string; toVersion: string }[]` | optional | Dependent packages that also need upgrading | -| **estimatedDuration** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDurationSeconds** | `integer` | optional | Estimated upgrade duration in seconds | +| **estimatedDuration** | `never` | optional | [REMOVED] `UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `estimatedDurationSeconds`; the value (seconds) is unchanged. | | **summary** | `string` | optional | Human-readable upgrade summary | ### Nested Shape: `UpgradePlan.changes[number]` diff --git a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx index ee0e588bbc..8874201504 100644 --- a/content/docs/references/kernel/plugin-lifecycle-advanced.mdx +++ b/content/docs/references/kernel/plugin-lifecycle-advanced.mdx @@ -79,7 +79,7 @@ const result = HotReloadConfigSchema.parse(data); | **status** | `Enum<'healthy' \| 'degraded' \| 'unhealthy' \| 'failed' \| 'recovering' \| 'unknown'>` | ✅ | Current health status of the plugin | | **timestamp** | `string` | ✅ | | | **message** | `string` | optional | | -| **metrics** | `{ uptime?: number; memoryUsage?: number; cpuUsage?: number; activeConnections?: number; … }` | optional | | +| **metrics** | `{ uptimeMs?: number; memoryUsage?: number; cpuUsage?: number; activeConnections?: number; … }` | optional | | | **checks** | `{ name: string; status: Enum<'passed' \| 'failed' \| 'warning'>; message?: string; data?: Record }[]` | optional | | | **dependencies** | `{ pluginId: string; status: Enum<'healthy' \| 'degraded' \| 'unhealthy' \| 'failed' \| 'recovering' \| 'unknown'>; message?: string }[]` | optional | | @@ -87,12 +87,14 @@ const result = HotReloadConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **uptime** | `number` | optional | Plugin uptime in milliseconds | +| **uptimeMs** | `number` | optional | Plugin uptime in milliseconds | | **memoryUsage** | `number` | optional | Memory usage in bytes | | **cpuUsage** | `number` | optional | CPU usage percentage | | **activeConnections** | `number` | optional | Number of active connections | | **errorRate** | `number` | optional | Error rate (errors per minute) | -| **responseTime** | `number` | optional | Average response time in ms | +| **responseTimeMs** | `number` | optional | Average response time in ms | +| **uptime** | `never` | optional | [REMOVED] `PluginHealthReport.metrics.uptime` was renamed to `uptimeMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and this platform already spells a SECONDS-valued uptime with the same bare name on GET /health. Rename the key to `uptimeMs`; the value (milliseconds, `Date.now() - startTime`) is unchanged. | +| **responseTime** | `never` | optional | [REMOVED] `PluginHealthReport.metrics.responseTime` was renamed to `responseTimeMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `responseTimeMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `PluginHealthReport.checks[number]` diff --git a/content/docs/references/kernel/plugin-security-advanced.mdx b/content/docs/references/kernel/plugin-security-advanced.mdx index 7f244f83de..10a4294e67 100644 --- a/content/docs/references/kernel/plugin-security-advanced.mdx +++ b/content/docs/references/kernel/plugin-security-advanced.mdx @@ -42,9 +42,9 @@ const result = KernelSecurityPolicySchema.parse(data); | **csp** | `{ directives?: Record; reportOnly: boolean }` | optional | | | **cors** | `{ allowedOrigins: string[]; allowedMethods: string[]; allowedHeaders: string[]; allowCredentials: boolean; … }` | optional | | | **rateLimit** | `{ enabled: boolean; maxRequests: integer; windowMs: integer; strategy: Enum<'fixed' \| 'sliding' \| 'token-bucket'> }` | optional | | -| **authentication** | `{ required: boolean; methods: Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]; tokenExpiration?: integer }` | optional | | +| **authentication** | `{ required: boolean; methods: Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]; tokenExpirationSeconds?: integer }` | optional | | | **encryption** | `{ dataAtRest: boolean; dataInTransit: boolean; algorithm?: string; minKeyLength?: integer }` | optional | | -| **auditLog** | `{ enabled: boolean; events?: string[]; retention?: integer }` | optional | | +| **auditLog** | `{ enabled: boolean; events?: string[]; retentionDays?: integer }` | optional | | ### Nested Shape: `KernelSecurityPolicy.rateLimit` @@ -61,7 +61,8 @@ const result = KernelSecurityPolicySchema.parse(data); | :--- | :--- | :--- | :--- | | **required** | `boolean` | optional (default: `true`) | | | **methods** | `Enum<'jwt' \| 'oauth2' \| 'api-key' \| 'session' \| 'certificate'>[]` | ✅ | | -| **tokenExpiration** | `integer` | optional | Token expiration in seconds | +| **tokenExpirationSeconds** | `integer` | optional | Token expiration in seconds | +| **tokenExpiration** | `never` | optional | [REMOVED] `KernelSecurityPolicy.authentication.tokenExpiration` was renamed to `tokenExpirationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling rate-limit window on this same policy is already `windowMs`. Rename the key to `tokenExpirationSeconds`; the value (seconds) is unchanged. | ### Nested Shape: `KernelSecurityPolicy.encryption` @@ -78,7 +79,8 @@ const result = KernelSecurityPolicySchema.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | | | **events** | `string[]` | optional | Events to log | -| **retention** | `integer` | optional | Log retention in days | +| **retentionDays** | `integer` | optional | Log retention in days | +| **retention** | `never` | optional | [REMOVED] `KernelSecurityPolicy.auditLog.retention` was renamed to `retentionDays` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retentionDays`; the value (days) is unchanged. | --- @@ -270,7 +272,7 @@ Scope of permission application | **codeSigning** | `{ signed: boolean; signature?: string; certificate?: string; algorithm?: string; … }` | optional | | | **certifications** | `{ name: string; issuer: string; issuedDate: string; expiryDate?: string; … }[]` | optional | | | **securityContact** | `{ email?: string; url?: string; pgpKey?: string }` | optional | | -| **vulnerabilityDisclosure** | `{ policyUrl?: string; responseTime?: integer; bugBounty?: boolean }` | optional | | +| **vulnerabilityDisclosure** | `{ policyUrl?: string; responseTimeHours?: integer; bugBounty?: boolean }` | optional | | ### Nested Shape: `PluginSecurityManifest.sandbox` @@ -281,7 +283,7 @@ Scope of permission application | **runtime** | `{ engine?: Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>; engineConfig?: object; resourceLimits?: object }` | optional | Execution environment and isolation settings | | **filesystem** | `{ mode?: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedPaths?: string[]; deniedPaths?: string[]; maxFileSize?: integer }` | optional | | | **network** | `{ mode?: Enum<'none' \| 'local' \| 'restricted' \| 'full'>; allowedHosts?: string[]; deniedHosts?: string[]; allowedPorts?: number[]; … }` | optional | | -| **process** | `{ allowSpawn?: boolean; allowedCommands?: string[]; timeout?: integer }` | optional | | +| **process** | `{ allowSpawn?: boolean; allowedCommands?: string[]; timeoutMs?: integer }` | optional | | | **memory** | `{ maxHeap?: integer; maxStack?: integer }` | optional | | | **cpu** | `{ maxCpuPercent?: number; maxThreads?: integer }` | optional | | | **environment** | `{ mode?: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedVars?: string[]; deniedVars?: string[] }` | optional | | @@ -301,7 +303,8 @@ Scope of permission application | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **policyUrl** | `string` | optional | | -| **responseTime** | `integer` | optional | Expected response time in hours | +| **responseTimeHours** | `integer` | optional | Expected response time in hours | +| **responseTime** | `never` | optional | [REMOVED] `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to `responseTimeHours` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one is HOURS, while the same bare name on `PluginHealthReport.metrics` was milliseconds — which is the confusion the rule exists to remove. Rename the key to `responseTimeHours`; the value (hours) is unchanged. | | **bugBounty** | `boolean` | optional (default: `false`) | | @@ -380,7 +383,7 @@ Type of resource being accessed | **runtime** | `{ engine: Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>; engineConfig?: object; resourceLimits?: object }` | optional | Execution environment and isolation settings | | **filesystem** | `{ mode: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedPaths?: string[]; deniedPaths?: string[]; maxFileSize?: integer }` | optional | | | **network** | `{ mode: Enum<'none' \| 'local' \| 'restricted' \| 'full'>; allowedHosts?: string[]; deniedHosts?: string[]; allowedPorts?: number[]; … }` | optional | | -| **process** | `{ allowSpawn: boolean; allowedCommands?: string[]; timeout?: integer }` | optional | | +| **process** | `{ allowSpawn: boolean; allowedCommands?: string[]; timeoutMs?: integer }` | optional | | | **memory** | `{ maxHeap?: integer; maxStack?: integer }` | optional | | | **cpu** | `{ maxCpuPercent?: number; maxThreads?: integer }` | optional | | | **environment** | `{ mode: Enum<'none' \| 'readonly' \| 'restricted' \| 'full'>; allowedVars?: string[]; deniedVars?: string[] }` | optional | | @@ -418,7 +421,8 @@ Type of resource being accessed | :--- | :--- | :--- | :--- | | **allowSpawn** | `boolean` | optional (default: `false`) | Allow spawning child processes | | **allowedCommands** | `string[]` | optional | Whitelisted commands | -| **timeout** | `integer` | optional | Process timeout in ms | +| **timeoutMs** | `integer` | optional | Process timeout in ms | +| **timeout** | `never` | optional | [REMOVED] `SandboxConfig.process.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `SandboxConfig.memory` diff --git a/content/docs/references/kernel/plugin-security.mdx b/content/docs/references/kernel/plugin-security.mdx index 568528cc5d..6ad7b823c9 100644 --- a/content/docs/references/kernel/plugin-security.mdx +++ b/content/docs/references/kernel/plugin-security.mdx @@ -169,7 +169,8 @@ Result of a dependency resolution process | **conflicts** | `{ package: string; conflicts: object[]; resolution?: object; severity: Enum<'error' \| 'warning' \| 'info'> }[]` | optional (default: `[]`) | List of dependency conflicts detected during resolution | | **errors** | `{ package: string; error: string }[]` | optional (default: `[]`) | Errors encountered during dependency resolution | | **installOrder** | `string[]` | optional (default: `[]`) | Topologically sorted list of package IDs for installation | -| **resolvedIn** | `integer` | optional | Time taken to resolve dependencies in milliseconds | +| **resolvedInMs** | `integer` | optional | Time taken to resolve dependencies in milliseconds | +| **resolvedIn** | `never` | optional | [REMOVED] `PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `resolvedInMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `PackageDependencyResolutionResult.graph` diff --git a/content/docs/references/kernel/plugin-versioning.mdx b/content/docs/references/kernel/plugin-versioning.mdx index f5d4c1e277..c133075a53 100644 --- a/content/docs/references/kernel/plugin-versioning.mdx +++ b/content/docs/references/kernel/plugin-versioning.mdx @@ -146,7 +146,7 @@ Compatibility level between versions | **maxConcurrentVersions** | `integer` | optional (default: `2`) | How many versions can run at the same time | | **selectionStrategy** | `Enum<'latest' \| 'stable' \| 'compatible' \| 'pinned' \| 'canary' \| 'custom'>` | optional (default: `"latest"`) | | | **routing** | `{ condition: string \| object; version: string; priority?: integer }[]` | optional | | -| **rollout** | `{ enabled?: boolean; strategy: Enum<'percentage' \| 'blue-green' \| 'canary'>; percentage?: number; duration?: integer }` | optional | | +| **rollout** | `{ enabled?: boolean; strategy: Enum<'percentage' \| 'blue-green' \| 'canary'>; percentage?: number; durationMs?: integer }` | optional | | ### Nested Shape: `MultiVersionSupport.routing[number]` @@ -163,7 +163,8 @@ Compatibility level between versions | **enabled** | `boolean` | optional (default: `false`) | | | **strategy** | `Enum<'percentage' \| 'blue-green' \| 'canary'>` | ✅ | | | **percentage** | `number` | optional | Percentage of traffic to new version | -| **duration** | `integer` | optional | Rollout duration in milliseconds | +| **durationMs** | `integer` | optional | Rollout duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `MultiVersionSupport.rollout.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | --- diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index 945a88ca9e..ed261a553f 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -52,7 +52,8 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | | **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | | **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | @@ -84,7 +85,8 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **timeout** | `integer` | optional (default: `30000`) | Maximum time in milliseconds to wait for each plugin to start | +| **timeoutMs** | `integer` | optional (default: `30000`) | Maximum time in milliseconds to wait for each plugin to start | +| **timeout** | `never` | optional | [REMOVED] `StartupOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **rollbackOnFailure** | `boolean` | optional (default: `true`) | Whether to rollback already-started plugins if any plugin fails | | **healthCheck** | `boolean` | optional (default: `false`) | Whether to run health checks after plugin startup | | **parallel** | `boolean` | optional (default: `false`) | Whether to start plugins in parallel when dependencies allow | @@ -99,8 +101,9 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **results** | `{ plugin: object; success: boolean; duration: number; error?: object; … }[]` | ✅ | Startup results for each plugin | -| **totalDuration** | `number` | ✅ | Total time taken for all plugins in milliseconds | +| **results** | `{ plugin: object; success: boolean; durationMs: number; error?: object; … }[]` | ✅ | Startup results for each plugin | +| **totalDurationMs** | `number` | ✅ | Total time taken for all plugins in milliseconds | +| **totalDuration** | `never` | optional | [REMOVED] `StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `totalDurationMs`; the value (milliseconds) is unchanged. | | **allSuccessful** | `boolean` | ✅ | Whether all plugins started successfully | | **rolledBack** | `string[]` | optional | Names of plugins that were rolled back | @@ -110,7 +113,8 @@ const result = HealthStatusSchema.parse(data); | :--- | :--- | :--- | :--- | | **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | | **success** | `boolean` | ✅ | Whether the plugin started successfully | -| **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **durationMs** | `number` | ✅ | Time taken to start the plugin in milliseconds | +| **duration** | `never` | optional | [REMOVED] `PluginStartupResult.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | | **health** | `{ healthy: boolean; checkedAt: integer; details?: Record; message?: string }` | optional | Health status after startup if health check was enabled | diff --git a/packages/spec/src/kernel/events.test.ts b/packages/spec/src/kernel/events.test.ts index 1bf49542b6..06538a7549 100644 --- a/packages/spec/src/kernel/events.test.ts +++ b/packages/spec/src/kernel/events.test.ts @@ -816,3 +816,34 @@ describe('Enhanced Event Handler', () => { expect(() => EventHandlerSchema.parse(handler)).not.toThrow(); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` +// tombstones, so the refusal carries the RENAME (the prescription IS the +// payload) rather than a bare unrecognized-key error, and the value survives at +// the same magnitude. Asserting the message, not just `.toThrow()`: a bare +// throw stays green when the schema throws for some unrelated reason. +describe('Event bus retention windows carry their unit (#15678)', () => { + it.each([ + ['EventPersistence', EventPersistenceSchema, { enabled: true }], + ['EventSourcingConfig', EventSourcingConfigSchema, {}], + ])('%s REFUSES the retired `retention` with the rename in the message', (def, schema, base) => { + const result = schema.safeParse({ ...base, retention: 90 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'retention'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`${def}.retention\` was renamed to \`retentionDays\``); + }); + + it('accepts `retentionDays` at the same magnitude on both defs', () => { + expect(EventPersistenceSchema.parse({ enabled: true, retentionDays: 90 }).retentionDays).toBe(90); + expect(EventSourcingConfigSchema.parse({ retentionDays: 90 }).retentionDays).toBe(90); + }); + + it('keeps the count-valued `snapshotRetention` bare — a count has no unit to carry', () => { + const parsed = EventSourcingConfigSchema.parse({ snapshotRetention: 10, retentionDays: 365 }); + expect(parsed.snapshotRetention).toBe(10); + expect(parsed.retentionDays).toBe(365); + }); +}); diff --git a/packages/spec/src/kernel/package-upgrade.test.ts b/packages/spec/src/kernel/package-upgrade.test.ts index 388ceac465..b5dd7bb082 100644 --- a/packages/spec/src/kernel/package-upgrade.test.ts +++ b/packages/spec/src/kernel/package-upgrade.test.ts @@ -280,3 +280,35 @@ describe('RollbackPackageResponseSchema', () => { expect(parsed.restoredVersion).toBe('1.0.0'); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone, +// so the refusal carries the RENAME rather than a bare unrecognized-key error. +describe('UpgradePlan.estimatedDuration carries its unit (#15678)', () => { + const basePlan = { + packageId: 'com.acme.crm', + fromVersion: '1.0.0', + toVersion: '2.0.0', + impactLevel: 'low' as const, + changes: [], + }; + + it('REFUSES the retired `estimatedDuration` with the rename in the message', () => { + const result = UpgradePlanSchema.safeParse({ ...basePlan, estimatedDuration: 120 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'estimatedDuration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`UpgradePlan.estimatedDuration` was renamed to `estimatedDurationSeconds`', + ); + // The prescription must name the unit, because SECONDS is the minority unit + // in this package and the sibling rename on this card is milliseconds. + expect(issue!.message).toContain('the value (seconds) is unchanged'); + }); + + it('accepts `estimatedDurationSeconds` at the same magnitude', () => { + const parsed = UpgradePlanSchema.parse({ ...basePlan, estimatedDurationSeconds: 120 }); + expect(parsed.estimatedDurationSeconds).toBe(120); + }); +}); diff --git a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts index 6658a07e62..0cb85f7e59 100644 --- a/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-lifecycle-advanced.test.ts @@ -301,3 +301,45 @@ describe('Plugin Lifecycle Advanced Schemas', () => { }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` +// tombstones inside the live `metrics` block, so the refusal carries the RENAME +// and the block's other members must keep parsing beside it. +describe('PluginHealthReport metrics durations carry their unit (#15678)', () => { + const base = { status: 'healthy' as const, timestamp: new Date().toISOString() }; + + it.each([ + ['uptime', 'uptimeMs', 3600000], + ['responseTime', 'responseTimeMs', 150], + ])('REFUSES the retired `metrics.%s` with the rename to `%s` in the message', (old, next, value) => { + const result = PluginHealthReportSchema.safeParse({ ...base, metrics: { [old]: value } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === `metrics.${old}`); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + `\`PluginHealthReport.metrics.${old}\` was renamed to \`${next}\``, + ); + }); + + it('accepts the suffixed metrics beside their unchanged non-duration siblings', () => { + const parsed = PluginHealthReportSchema.parse({ + ...base, + metrics: { + uptimeMs: 3600000, + responseTimeMs: 150, + // Not durations, so this rule does not reach them and they keep their + // bare names: bytes, a percentage, a count and a rate. + memoryUsage: 52428800, + cpuUsage: 15.5, + activeConnections: 10, + errorRate: 0.1, + }, + }); + expect(parsed.metrics?.uptimeMs).toBe(3600000); + expect(parsed.metrics?.responseTimeMs).toBe(150); + expect(parsed.metrics?.memoryUsage).toBe(52428800); + expect(parsed.metrics?.activeConnections).toBe(10); + }); +}); diff --git a/packages/spec/src/kernel/plugin-security-advanced.test.ts b/packages/spec/src/kernel/plugin-security-advanced.test.ts index 18117bcc10..2174e95a0a 100644 --- a/packages/spec/src/kernel/plugin-security-advanced.test.ts +++ b/packages/spec/src/kernel/plugin-security-advanced.test.ts @@ -306,3 +306,101 @@ describe('Plugin Security Advanced Schemas', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. This file is the rule's sharpest case in the +// spec — FOUR durations on one security manifest carried FOUR DIFFERENT units +// (ms, seconds, days, hours) and none said so in its name. All four old +// spellings are `retiredKey()` tombstones inside live blocks, so the refusal +// carries the RENAME and each block's other members keep parsing beside it. +describe('Plugin security durations carry their unit (#15678)', () => { + it('SandboxConfig REFUSES the retired `process.timeout` with the rename in the message', () => { + const result = SandboxConfigSchema.safeParse({ process: { timeout: 30000 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'process.timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`SandboxConfig.process.timeout` was renamed to `timeoutMs`'); + }); + + it.each([ + ['authentication.tokenExpiration', 'tokenExpirationSeconds', 3600, + { authentication: { methods: ['jwt' as const], tokenExpiration: 3600 } }], + ['auditLog.retention', 'retentionDays', 90, + { auditLog: { retention: 90 } }], + ])('KernelSecurityPolicy REFUSES the retired `%s` with the rename to `%s`', (old, next, _v, policy) => { + const result = KernelSecurityPolicySchema.safeParse(policy); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === old); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain(`\`KernelSecurityPolicy.${old}\` was renamed to \`${next}\``); + }); + + it('accepts the suffixed policy keys beside the already-suffixed `windowMs`', () => { + const parsed = KernelSecurityPolicySchema.parse({ + rateLimit: { maxRequests: 100, windowMs: 60000 }, + authentication: { methods: ['jwt' as const], tokenExpirationSeconds: 3600 }, + auditLog: { retentionDays: 90 }, + }); + expect(parsed.rateLimit?.windowMs).toBe(60000); + expect(parsed.authentication?.tokenExpirationSeconds).toBe(3600); + expect(parsed.auditLog?.retentionDays).toBe(90); + }); + + it('accepts the suffixed sandbox key beside its non-duration siblings', () => { + const parsed = SandboxConfigSchema.parse({ + process: { allowSpawn: false, allowedCommands: ['git'], timeoutMs: 30000 }, + }); + expect(parsed.process?.timeoutMs).toBe(30000); + expect(parsed.process?.allowSpawn).toBe(false); + }); + + // The pair the rule exists for: ONE bare name, TWO units, two kernel shapes. + it('PluginSecurityManifest REFUSES `vulnerabilityDisclosure.responseTime` and names HOURS', () => { + const result = PluginSecurityManifestSchema.safeParse({ + pluginId: 'com.acme.analytics', + trustLevel: 'trusted' as const, + permissions: { permissions: [] }, + sandbox: { level: 'strict' as const }, + vulnerabilityDisclosure: { responseTime: 24 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find( + (i) => i.path.join('.') === 'vulnerabilityDisclosure.responseTime', + ); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`PluginSecurityManifest.vulnerabilityDisclosure.responseTime` was renamed to ' + + '`responseTimeHours`', + ); + // Not `responseTimeMs`: the identically-named health-report key IS + // milliseconds, and confusing the two is a 3,600,000x error. + expect(issue!.message).not.toContain('`responseTimeMs`'); + expect(issue!.message).toContain('the value (hours) is unchanged'); + }); + + it('accepts `responseTimeHours` beside the unchanged `bugBounty`', () => { + const parsed = PluginSecurityManifestSchema.parse({ + pluginId: 'com.acme.analytics', + trustLevel: 'trusted' as const, + permissions: { permissions: [] }, + sandbox: { level: 'strict' as const }, + vulnerabilityDisclosure: { responseTimeHours: 24, bugBounty: true }, + }); + expect(parsed.vulnerabilityDisclosure?.responseTimeHours).toBe(24); + expect(parsed.vulnerabilityDisclosure?.bugBounty).toBe(true); + }); + + // A NEGATIVE control on the same file: this key names no unit anywhere, so it + // is outside the gate's population and outside this rename. Without it, a + // later sweep reads the four renames above as "every timeout on this file". + it('leaves `RuntimeConfig.resourceLimits.timeout` bare — its describe names no unit', () => { + const parsed = RuntimeConfigSchema.parse({ + engine: 'process' as const, + resourceLimits: { maxMemory: 1073741824, timeout: 60000 }, + }); + expect(parsed.resourceLimits?.timeout).toBe(60000); + }); +}); diff --git a/packages/spec/src/kernel/plugin-security.test.ts b/packages/spec/src/kernel/plugin-security.test.ts index 54bde78284..6b60b876a3 100644 --- a/packages/spec/src/kernel/plugin-security.test.ts +++ b/packages/spec/src/kernel/plugin-security.test.ts @@ -340,3 +340,26 @@ describe('Plugin Security Protocol', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone, +// so the refusal carries the RENAME rather than a bare unrecognized-key error. +describe('PackageDependencyResolutionResult.resolvedIn carries its unit (#15678)', () => { + const base = { status: 'success' as const, installOrder: ['com.acme.app'] }; + + it('REFUSES the retired `resolvedIn` with the rename in the message', () => { + const result = PackageDependencyResolutionResultSchema.safeParse({ ...base, resolvedIn: 150 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'resolvedIn'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`PackageDependencyResolutionResult.resolvedIn` was renamed to `resolvedInMs`', + ); + }); + + it('accepts `resolvedInMs` at the same magnitude', () => { + const parsed = PackageDependencyResolutionResultSchema.parse({ ...base, resolvedInMs: 150 }); + expect(parsed.resolvedInMs).toBe(150); + }); +}); diff --git a/packages/spec/src/kernel/plugin-versioning.test.ts b/packages/spec/src/kernel/plugin-versioning.test.ts index 379c820653..cfec1e1d7b 100644 --- a/packages/spec/src/kernel/plugin-versioning.test.ts +++ b/packages/spec/src/kernel/plugin-versioning.test.ts @@ -454,3 +454,30 @@ describe('Plugin Versioning Schemas', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. The old spelling is a `retiredKey()` tombstone +// inside the live `rollout` block, so the refusal carries the RENAME and the +// block's unit-less `percentage` must keep parsing beside it. +describe('MultiVersionSupport rollout duration carries its unit (#15678)', () => { + it('REFUSES the retired `rollout.duration` with the rename in the message', () => { + const result = MultiVersionSupportSchema.safeParse({ + rollout: { strategy: 'canary' as const, duration: 3600000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'rollout.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`MultiVersionSupport.rollout.duration` was renamed to `durationMs`', + ); + }); + + it('accepts `durationMs` beside the unit-less `percentage`, which keeps its name', () => { + const parsed = MultiVersionSupportSchema.parse({ + rollout: { enabled: true, strategy: 'canary' as const, percentage: 10, durationMs: 3600000 }, + }); + expect(parsed.rollout?.durationMs).toBe(3600000); + expect(parsed.rollout?.percentage).toBe(10); + }); +}); diff --git a/packages/spec/src/kernel/startup-orchestrator.test.ts b/packages/spec/src/kernel/startup-orchestrator.test.ts index b5fcce9a12..d1f64ba101 100644 --- a/packages/spec/src/kernel/startup-orchestrator.test.ts +++ b/packages/spec/src/kernel/startup-orchestrator.test.ts @@ -179,3 +179,66 @@ describe('Startup Orchestrator Protocol', () => { }); }); }); + +// #15678 (stack card 3/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. All three old spellings are `retiredKey()` +// tombstones, so the refusal carries the RENAME (the prescription IS the +// payload) rather than a bare unrecognized-key error. This contract already +// contained its own counter-example: `startWithTimeout(plugin, ctx, timeoutMs)` +// named its parameter correctly while the options object beside it did not. +describe('Startup orchestration durations carry their unit (#15678)', () => { + it('StartupOptions REFUSES the retired `timeout` with the rename in the message', () => { + const result = StartupOptionsSchema.safeParse({ timeout: 60000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`StartupOptions.timeout` was renamed to `timeoutMs`'); + }); + + it('PluginStartupResult REFUSES the retired `duration` with the rename in the message', () => { + const result = PluginStartupResultSchema.safeParse({ + plugin: { name: 'crm-plugin' }, + success: true, + duration: 1250, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`PluginStartupResult.duration` was renamed to `durationMs`'); + }); + + it('StartupOrchestrationResult REFUSES the retired `totalDuration` with the rename', () => { + const result = StartupOrchestrationResultSchema.safeParse({ + results: [], + totalDuration: 2050, + allSuccessful: true, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'totalDuration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`StartupOrchestrationResult.totalDuration` was renamed to `totalDurationMs`', + ); + }); + + it('the aggregate and its parts now agree: totalDurationMs sums durationMs', () => { + const parsed = StartupOrchestrationResultSchema.parse({ + results: [ + { plugin: { name: 'plugin1' }, success: true, durationMs: 1200 }, + { plugin: { name: 'plugin2' }, success: true, durationMs: 850 }, + ], + totalDurationMs: 2050, + allSuccessful: true, + }); + expect(parsed.totalDurationMs).toBe(2050); + expect(parsed.results.reduce((sum, r) => sum + r.durationMs, 0)).toBe(2050); + }); + + it('keeps the 30000 default under the renamed key', () => { + expect(StartupOptionsSchema.parse({}).timeoutMs).toBe(30000); + expect(StartupOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + }); +}); From 8f2b8f389f44862bfa7ca899a3cc4a580d5f0afb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:16:27 +0000 Subject: [PATCH 17/33] docs(changeset): the fourteen kernel/ duration renames (#15678) @objectstack/spec minor + @objectstack/core patch, BREAKING banner naming every renamed key, adr-0087: registered with the five semantic ids. Documents the two unit collisions the rename removes (responseTime hours vs ms; uptime ms vs the seconds-valued GET /health) and the three keys deliberately left bare. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../kernel-duration-keys-unit-in-key-name.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .changeset/kernel-duration-keys-unit-in-key-name.md diff --git a/.changeset/kernel-duration-keys-unit-in-key-name.md b/.changeset/kernel-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..bbc197eff1 --- /dev/null +++ b/.changeset/kernel-duration-keys-unit-in-key-name.md @@ -0,0 +1,109 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": patch +--- + +feat(spec)!: the fourteen `kernel/` duration keys carry their unit in the key name (#15678, ruling B on #14478) + + + +**BREAKING** — fourteen published `kernel/` duration keys are renamed and +tombstoned. Shipped as `minor` under the repo's launch-window convention for +breaking changes; the hand-migration prescriptions are registered under protocol +major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, +「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions and card 2/6 (#15677) cleared `api/`; this card clears `kernel/`. +Measured with the gate itself: `src/kernel/**` goes from 14 offenders to **0**, +and the whole-tree count falls **36 → 22**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `EventPersistence.retention` | `retentionDays` | days | +| `EventSourcingConfig.retention` | `retentionDays` | days | +| `UpgradePlan.estimatedDuration` | `estimatedDurationSeconds` | seconds | +| `PluginHealthReport.metrics.uptime` | `uptimeMs` | milliseconds | +| `PluginHealthReport.metrics.responseTime` | `responseTimeMs` | milliseconds | +| `SandboxConfig.process.timeout` | `timeoutMs` | milliseconds | +| `KernelSecurityPolicy.authentication.tokenExpiration` | `tokenExpirationSeconds` | seconds | +| `KernelSecurityPolicy.auditLog.retention` | `retentionDays` | days | +| `PluginSecurityManifest.vulnerabilityDisclosure.responseTime` | `responseTimeHours` | hours | +| `PackageDependencyResolutionResult.resolvedIn` | `resolvedInMs` | milliseconds | +| `MultiVersionSupport.rollout.duration` | `durationMs` | milliseconds | +| `StartupOptions.timeout` | `timeoutMs` | milliseconds | +| `PluginStartupResult.duration` | `durationMs` | milliseconds | +| `StartupOrchestrationResult.totalDuration` | `totalDurationMs` | milliseconds | + +**Every value is unchanged** — only key names move, and every default moves with +its key (`StartupOptions` still defaults to 30000, `EventSourcingConfig` to +365). Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` at the +authoring site (input type `never`) and fails the parse with the rename +prescription rather than a bare unrecognized-key error. + +## ⚠️ Two collisions this rename removes — check these by hand, not by search-and-replace + +**`responseTime` meant two different units on two kernel shapes.** On +`PluginSecurityManifest.vulnerabilityDisclosure` it is HOURS (how fast a +publisher promises to answer a vulnerability report); on +`PluginHealthReport.metrics` the identical bare name is MILLISECONDS. So +`responseTime: 24` was a day on one shape and a fortieth of a second on the +other, with nothing at the authoring site to tell them apart. They land on +`responseTimeHours` and `responseTimeMs` respectively — do not let one +find-and-replace rewrite both. + +**`uptime` is milliseconds here and SECONDS on `GET /health`.** That collision +was already costing prose: the protocol lifecycle page carried a standing +paragraph whose only job was telling the two apart. `metrics.uptime` becomes +`metrics.uptimeMs`; the seconds-valued `uptime` of the HTTP health body is a +separate, unchanged surface and must not be renamed with it. + +A third split worth reading before you migrate: `estimatedDurationSeconds: 120` +is two MINUTES while `durationMs: 3600000` is one HOUR. Three adjacent +measurements of the same package install carried two different units, and no +parse can catch a value moved between them — both bounds accept any +non-negative integer. + +## Dispositions — five semantic entries, no D2 conversion + +Justified per key rather than defaulted, and this card's answer is uniform: +**none of the fourteen gets an ADR-0087 D2 conversion.** A D2 conversion runs +over a stack document, and `stack.zod.ts` declares no `eventBus`, `startup`, +`upgrade` or plugin-security root — none of these twelve defs is a stack +collection member or a registered metadata kind stored as a `sys_metadata` row, +so the conversion chain has no seam that would see one. They are host +construction arguments (`EventBusConfig`, `StartupOptions`, `SandboxConfig`, +`MultiVersionSupport`), package artifacts (`PluginSecurityManifest`) and +runtime-emitted measurements (`PluginHealthReport`, `PluginStartupResult`, +`StartupOrchestrationResult`, `UpgradePlan`, +`PackageDependencyResolutionResult`). Each therefore carries a **semantic** +entry, which is the disposition `kernel/HealthStatus:timestamp` already holds on +one of these very files (`epoch-instant-keys-renamed`, card 1/6) and what ruling +B prescribes for a key that is not authorable metadata. All fourteen are +registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + +## Keys deliberately left alone + +`EventSourcingConfig.snapshotRetention` is a COUNT of snapshots and +`MultiVersionSupport.rollout.percentage` is a proportion — neither is a +duration, so neither has a unit to carry and both keep their names. +`RuntimeConfig.resourceLimits.timeout` names no unit anywhere in its prose, so +it is outside the gate's population and outside this rename; a pin test asserts +that, so a later sweep cannot read the four security renames as "every timeout +on that file". + +## Readers moved in the same PR, at the same magnitude + +`@objectstack/core`'s health monitor (`metrics.uptimeMs: Date.now() - +startTime`), the kernel and contracts test suites, and the hand-written +`content/docs/protocol/kernel/lifecycle.mdx`, whose `uptime` paragraph now +states the collision the rename removes. + +⚠️ `packages/core/src/plugin-loader.ts` declares its OWN local +`PluginStartupResult` interface — a different type, carrying `startTime` rather +than any duration key. It is not a reader of this schema, it is untouched by +this rename, and the divergence between the two shapes is tracked separately. From b5f2a841bdd1474acd4f9c63716df764c32f9477 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:28:43 +0000 Subject: [PATCH 18/33] wip(spec): rename the 15 system/ duration keys, tombstones on the old spellings (#15679) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/system/cache.zod.ts | 37 +++++++++++-- packages/spec/src/system/collaboration.zod.ts | 31 ++++++++++- .../spec/src/system/disaster-recovery.zod.ts | 17 +++++- packages/spec/src/system/metrics.zod.ts | 52 +++++++++++++++++-- .../spec/src/system/object-storage.zod.ts | 37 ++++++++++++- .../spec/src/system/registry-config.zod.ts | 44 ++++++++++++++-- packages/spec/src/system/tracing.zod.ts | 15 +++++- packages/spec/src/system/worker.zod.ts | 16 +++++- 8 files changed, 227 insertions(+), 22 deletions(-) diff --git a/packages/spec/src/system/cache.zod.ts b/packages/spec/src/system/cache.zod.ts index 5f353d1277..e1f1860752 100644 --- a/packages/spec/src/system/cache.zod.ts +++ b/packages/spec/src/system/cache.zod.ts @@ -30,6 +30,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * @see ../../api/http-cache.zod.ts for HTTP-level caching */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; /** * Cache eviction strategy — the single declaration of the `CacheStrategy` @@ -48,11 +49,31 @@ export const CacheStrategySchema = lazySchema(() => z.enum([ export type CacheStrategy = z.input; +const CACHE_TIER_TTL_RETIRED = + '`CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose, and the ' + + 'sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; ' + + 'the value (seconds) and the 300 default are unchanged.'; + +const CIRCUIT_BREAKER_RESET_TIMEOUT_RETIRED = + '`CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to ' + + '`resetTimeoutSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number ' + + 'lives in the key name, not only in the describe prose, and the sibling `lockout` block ' + + 'on this same schema already spells `lockTimeoutMs`. Rename the key to ' + + '`resetTimeoutSeconds`; the value (seconds) and the 30 default are unchanged. Note the ' + + 'two are NOT the same unit: this one is seconds, `lockTimeoutMs` is milliseconds.'; + export const CacheTierSchema = lazySchema(() => z.object({ name: z.string().describe('Unique cache tier name'), type: z.enum(['memory', 'redis', 'memcached', 'cdn']).describe('Cache backend type'), maxSize: z.number().optional().describe('Max size in MB'), - ttl: z.number().default(300).describe('Default TTL in seconds'), + // Renamed from `ttl` (#15679, #14478 ruling B): the unit lived only in the + // describe prose, while the sibling `maxSize` on this very tier is MB — two + // bare numbers side by side, neither naming its unit at the authoring site. + ttlSeconds: z.number().default(300).describe('Default TTL in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + ttl: retiredKey(CACHE_TIER_TTL_RETIRED), strategy: CacheStrategySchema.default('lru').describe('Eviction strategy'), warmup: z.boolean().default(false).describe('Pre-populate cache on startup'), }).describe('Configuration for a single cache tier in the hierarchy')); @@ -111,7 +132,7 @@ export type CacheConsistency = z.input; * ```typescript * const prevention: CacheAvalanchePrevention = { * jitterTtl: { enabled: true, maxJitterSeconds: 60 }, - * circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeout: 30 }, + * circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutSeconds: 30 }, * lockout: { enabled: true, lockTimeoutMs: 5000 }, * }; * ``` @@ -127,7 +148,13 @@ export const CacheAvalanchePreventionSchema = lazySchema(() => z.object({ circuitBreaker: z.object({ enabled: z.boolean().default(false).describe('Enable circuit breaker for backend protection'), failureThreshold: z.number().default(5).describe('Failures before circuit opens'), - resetTimeout: z.number().default(30).describe('Seconds before half-open state'), + // Renamed from `resetTimeout` (#15679, #14478 ruling B): the unit lived only + // in the describe prose, and the sibling `lockout.lockTimeoutMs` on this same + // schema already spelled its own unit — one shape, two conventions. + resetTimeoutSeconds: z.number().default(30).describe('Seconds before half-open state'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + resetTimeout: retiredKey(CIRCUIT_BREAKER_RESET_TIMEOUT_RETIRED), }).optional().describe('Circuit breaker for backend protection'), /** Cache lock to prevent thundering herd on key miss */ @@ -175,8 +202,8 @@ export type CacheWarmupParsed = z.infer; * const distributedCache: DistributedCacheConfig = { * enabled: true, * tiers: [ - * { name: 'l1', type: 'memory', maxSize: 100, ttl: 60, strategy: 'lru' }, - * { name: 'l2', type: 'redis', maxSize: 1000, ttl: 300, strategy: 'lru' }, + * { name: 'l1', type: 'memory', maxSize: 100, ttlSeconds: 60, strategy: 'lru' }, + * { name: 'l2', type: 'redis', maxSize: 1000, ttlSeconds: 300, strategy: 'lru' }, * ], * invalidation: [ * { trigger: 'update', scope: 'key' }, diff --git a/packages/spec/src/system/collaboration.zod.ts b/packages/spec/src/system/collaboration.zod.ts index 9be9905b4b..1feb4309a1 100644 --- a/packages/spec/src/system/collaboration.zod.ts +++ b/packages/spec/src/system/collaboration.zod.ts @@ -21,6 +21,7 @@ import { z } from 'zod'; * Types of operations in Operational Transformation */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const OTOperationType = z.enum([ 'insert', // Insert characters at position 'delete', // Delete characters at position @@ -460,18 +461,44 @@ export type CollaborationMode = z.input; * Collaboration Session Config * Configuration for a collaboration session */ +const SESSION_IDLE_TIMEOUT_RETIRED = + '`CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. This one was the live 1000x collision the rule exists to ' + + 'remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare ' + + 'name meant five minutes here and three and a half days there. Rename the key to ' + + '`idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged.'; + +const SNAPSHOT_INTERVAL_RETIRED = + '`CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `intervalMs`; the value (milliseconds) ' + + 'is unchanged.'; + export const CollaborationSessionConfigSchema = lazySchema(() => z.object({ mode: CollaborationMode.describe('Collaboration mode to use'), enableCursorSharing: z.boolean().optional().default(true).describe('Enable cursor sharing'), enablePresence: z.boolean().optional().default(true).describe('Enable presence tracking'), enableAwareness: z.boolean().optional().default(true).describe('Enable awareness state'), maxUsers: z.number().int().positive().optional().describe('Maximum concurrent users'), - idleTimeout: z.number().int().positive().optional().default(300000).describe('Idle timeout in milliseconds'), + // Renamed from `idleTimeout` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, and a SECONDS-valued `idleTimeout` existed on the tenant + // surface at the same time — 300000 meant five minutes here and three and a half + // days there, with nothing at either authoring site to tell them apart. + idleTimeoutMs: z.number().int().positive().optional().default(300000).describe('Idle timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + idleTimeout: retiredKey(SESSION_IDLE_TIMEOUT_RETIRED), conflictResolution: z.enum(['ot', 'crdt', 'manual']).optional().default('ot').describe('Conflict resolution strategy'), persistence: z.boolean().optional().default(true).describe('Enable operation persistence'), snapshot: z.object({ enabled: z.boolean().describe('Enable periodic snapshots'), - interval: z.number().int().positive().describe('Snapshot interval in milliseconds'), + // Renamed from `interval` (#15679, #14478 ruling B): the unit lived only in + // the describe prose. + intervalMs: z.number().int().positive().describe('Snapshot interval in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + interval: retiredKey(SNAPSHOT_INTERVAL_RETIRED), }).optional().describe('Snapshot configuration'), })); diff --git a/packages/spec/src/system/disaster-recovery.zod.ts b/packages/spec/src/system/disaster-recovery.zod.ts index 0732edaea1..fef0d0530b 100644 --- a/packages/spec/src/system/disaster-recovery.zod.ts +++ b/packages/spec/src/system/disaster-recovery.zod.ts @@ -23,6 +23,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; * ``` */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const BackupStrategySchema = lazySchema(() => z.enum([ 'full', 'incremental', @@ -112,7 +113,19 @@ export const FailoverConfigSchema = lazySchema(() => z.object({ /** Automatic failover enabled */ autoFailover: z.boolean().default(true).describe('Enable automatic failover'), /** Health check interval in seconds */ - healthCheckInterval: z.number().default(30).describe('Health check interval in seconds'), + // Renamed from `healthCheckInterval` (#15679, #14478 ruling B): the unit lived + // only in the describe prose. Its neighbour `dns.ttl` on this same schema keeps + // its bare name under the externalVocabulary exemption — that key mirrors the + // DNS resource-record field, this one mirrors nothing outside the repo. + healthCheckIntervalSeconds: z.number().default(30).describe('Health check interval in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + healthCheckInterval: retiredKey( + '`FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, ' + + 'not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the ' + + 'value (seconds) and the 30 default are unchanged.', + ), /** Number of consecutive failures before triggering failover */ failureThreshold: z.number().default(3).describe('Consecutive failures before failover'), /** Regions/zones for disaster recovery */ @@ -195,7 +208,7 @@ export type RTOParsed = z.infer; * failover: { * mode: 'active_passive', * autoFailover: true, - * healthCheckInterval: 30, + * healthCheckIntervalSeconds: 30, * failureThreshold: 3, * regions: [ * { name: 'us-east-1', role: 'primary' }, diff --git a/packages/spec/src/system/metrics.zod.ts b/packages/spec/src/system/metrics.zod.ts index 93c1c73c7d..6e941789eb 100644 --- a/packages/spec/src/system/metrics.zod.ts +++ b/packages/spec/src/system/metrics.zod.ts @@ -19,6 +19,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * Standard Prometheus metric types */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MetricType = z.enum([ 'counter', // Monotonically increasing value 'gauge', // Value that can go up and down @@ -319,6 +320,20 @@ export type TimeSeries = z.input; /** * Metric Aggregation Configuration */ +const WINDOW_SIZE_RETIRED = + 'The aggregation/SLI window key `size` was renamed to `durationSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or ' + + 'row count everywhere else in this spec, so the rename drops it rather than bolting a ' + + 'unit onto it. Rename `window.size` to `window.durationSeconds` on both ' + + 'MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged.'; + +const SLO_PERIOD_DURATION_RETIRED = + '`ServiceLevelObjective.period.duration` was renamed to `durationSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) ' + + 'is unchanged.'; + export const MetricAggregationConfigSchema = lazySchema(() => z.object({ /** * Aggregation type @@ -330,9 +345,20 @@ export const MetricAggregationConfigSchema = lazySchema(() => z.object({ */ window: z.object({ /** - * Window size in seconds + * Window duration in seconds */ - size: z.number().int().positive().describe('Window size in seconds'), + // Renamed from `size` (#15679, #14478 ruling B). NOT the gate's mechanical + // `sizeSeconds`: `size` is byte-count vocabulary everywhere else in this spec + // (`CacheTier.maxSize` is MB, `RegistryConfig.cache.maxSize` is bytes, this + // file's own `batch.size` is a row count), so `sizeSeconds` would have kept + // the misleading half of the name and bolted a unit onto it. The parent key is + // already `window`, so `windowSeconds` would stutter as `window.windowSeconds`. + // `durationSeconds` names what the number IS, and matches the sibling period + // length one schema down (`ServiceLevelObjective.period.durationSeconds`). + durationSeconds: z.number().int().positive().describe('Window duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + size: retiredKey(WINDOW_SIZE_RETIRED), /** * Sliding window (true) or tumbling window (false) @@ -415,9 +441,20 @@ export const ServiceLevelIndicatorSchema = lazySchema(() => z.object({ */ window: z.object({ /** - * Window size in seconds + * Window duration in seconds */ - size: z.number().int().positive().describe('Window size in seconds'), + // Renamed from `size` (#15679, #14478 ruling B). NOT the gate's mechanical + // `sizeSeconds`: `size` is byte-count vocabulary everywhere else in this spec + // (`CacheTier.maxSize` is MB, `RegistryConfig.cache.maxSize` is bytes, this + // file's own `batch.size` is a row count), so `sizeSeconds` would have kept + // the misleading half of the name and bolted a unit onto it. The parent key is + // already `window`, so `windowSeconds` would stutter as `window.windowSeconds`. + // `durationSeconds` names what the number IS, and matches the sibling period + // length one schema down (`ServiceLevelObjective.period.durationSeconds`). + durationSeconds: z.number().int().positive().describe('Window duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + size: retiredKey(WINDOW_SIZE_RETIRED), /** * Rolling window (true) or calendar-aligned (false) @@ -478,7 +515,12 @@ export const ServiceLevelObjectiveSchema = lazySchema(() => z.object({ /** * Duration in seconds (for rolling) */ - duration: z.number().int().positive().optional().describe('Duration in seconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in + // the describe prose. + durationSeconds: z.number().int().positive().optional().describe('Duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey(SLO_PERIOD_DURATION_RETIRED), /** * Calendar period (for calendar) diff --git a/packages/spec/src/system/object-storage.zod.ts b/packages/spec/src/system/object-storage.zod.ts index d35b46744d..215e459d28 100644 --- a/packages/spec/src/system/object-storage.zod.ts +++ b/packages/spec/src/system/object-storage.zod.ts @@ -26,6 +26,7 @@ import { SystemIdentifierSchema } from '../shared/identifiers.zod'; * Defines the lifecycle and persistence guarantee of the storage area. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const StorageScopeSchema = lazySchema(() => z.enum([ 'global', // Global application-wide storage 'tenant', // Tenant-scoped storage (multi-tenant apps) @@ -252,13 +253,40 @@ export type MultipartUploadConfigParsed = z.infer z.object({ acl: StorageAclSchema.default('private').describe('Default access control level'), allowedOrigins: z.array(z.string()).optional().describe('CORS allowed origins'), allowedMethods: z.array(z.enum(['GET', 'PUT', 'POST', 'DELETE', 'HEAD'])).optional().describe('CORS allowed HTTP methods'), allowedHeaders: z.array(z.string()).optional().describe('CORS allowed headers'), exposeHeaders: z.array(z.string()).optional().describe('CORS exposed headers'), - maxAge: z.number().min(0).optional().describe('CORS preflight cache duration in seconds'), + // Renamed from `maxAge` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. ⚠️ Deliberately NOT an `externalVocabulary` mirror, unlike its + // twin `shared/CorsConfig.maxAge`: every bucket-CORS standard this key is + // forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, GCS + // `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so the bare spelling was a + // DEVIATION from the cited standard, not a mirror of it. The Fetch response + // header `Access-Control-Max-Age` that `CorsConfig.maxAge` mirrors genuinely + // carries no unit token, which is why that twin keeps its marker and this one + // is renamed. Preserve the asymmetry. + maxAgeSeconds: z.number().min(0).optional().describe('CORS preflight cache duration in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + maxAge: retiredKey(ACCESS_CONTROL_MAX_AGE_RETIRED), corsEnabled: z.boolean().default(false).describe('Enable CORS configuration'), publicAccess: z.object({ allowPublicRead: z.boolean().default(false).describe('Allow public read access'), @@ -447,7 +475,12 @@ export const StorageConnectionSchema = lazySchema(() => z.object({ endpoint: z.string().optional().describe('Custom endpoint URL'), region: z.string().optional().describe('Default region'), useSSL: z.boolean().default(true).describe('Use SSL/TLS for connections'), - timeout: z.number().min(0).optional().describe('Connection timeout in milliseconds'), + // Renamed from `timeout` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. + timeoutMs: z.number().min(0).optional().describe('Connection timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + timeout: retiredKey(STORAGE_CONNECTION_TIMEOUT_RETIRED), })); export type StorageConnection = z.input; diff --git a/packages/spec/src/system/registry-config.zod.ts b/packages/spec/src/system/registry-config.zod.ts index 67dbf8a7f3..9c6bc71f03 100644 --- a/packages/spec/src/system/registry-config.zod.ts +++ b/packages/spec/src/system/registry-config.zod.ts @@ -14,6 +14,7 @@ import { z } from 'zod'; * Defines how registries synchronize with upstreams */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const RegistrySyncPolicySchema = lazySchema(() => z.enum([ 'manual', // Manual synchronization only 'auto', // Automatic synchronization @@ -24,6 +25,26 @@ export const RegistrySyncPolicySchema = lazySchema(() => z.enum([ * Registry Upstream Configuration * Configuration for upstream registry connection */ +const UPSTREAM_SYNC_INTERVAL_RETIRED = + '`RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose, and the `timeout` beside it on this same block is ' + + 'milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the ' + + 'min-60 bound are unchanged.'; + +const UPSTREAM_TIMEOUT_RETIRED = + '`RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the ' + + 'unit of a duration-shaped number lives in the key name, not only in the describe prose, ' + + 'and `syncIntervalSeconds` on this same block is seconds. Rename the key to ' + + '`timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are ' + + 'unchanged.'; + +const REGISTRY_CACHE_TTL_RETIRED = + '`RegistryConfig.cache.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the ' + + 'unit of a duration-shaped number lives in the key name, not only in the describe prose, ' + + 'and the sibling `maxSize` in this same cache block is bytes. Rename the key to ' + + '`ttlSeconds`; the value (seconds) and the 3600 default are unchanged.'; + export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Upstream registry URL @@ -39,8 +60,14 @@ export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Sync interval in seconds (for auto sync) */ - syncInterval: z.number().int().min(60).optional() + // Renamed from `syncInterval` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, and the `timeout` two keys down was MILLISECONDS — one + // upstream block, two units, neither spelled at the authoring site. + syncIntervalSeconds: z.number().int().min(60).optional() .describe('Auto-sync interval in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + syncInterval: retiredKey(UPSTREAM_SYNC_INTERVAL_RETIRED), /** * Authentication credentials @@ -66,8 +93,14 @@ export const RegistryUpstreamSchema = lazySchema(() => z.object({ /** * Timeout settings */ - timeout: z.number().int().min(1000).default(30000) + // Renamed from `timeout` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. Milliseconds here, while `syncIntervalSeconds` above is + // seconds — the min(1000) bound reads as sixteen minutes under the wrong one. + timeoutMs: z.number().int().min(1000).default(30000) .describe('Request timeout in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + timeout: retiredKey(UPSTREAM_TIMEOUT_RETIRED), /** * Retry configuration @@ -161,8 +194,13 @@ export const RegistryConfigSchema = lazySchema(() => z.object({ */ cache: z.object({ enabled: z.boolean().default(true), - ttl: z.number().int().min(0).default(3600) + // Renamed from `ttl` (#15679, #14478 ruling B): the unit lived only in the + // describe prose, and the sibling `maxSize` in this same cache block is BYTES. + ttlSeconds: z.number().int().min(0).default(3600) .describe('Cache TTL in seconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + ttl: retiredKey(REGISTRY_CACHE_TTL_RETIRED), maxSize: z.number().int().optional() .describe('Maximum cache size in bytes'), }).optional(), diff --git a/packages/spec/src/system/tracing.zod.ts b/packages/spec/src/system/tracing.zod.ts index 1104dff411..976315c6c7 100644 --- a/packages/spec/src/system/tracing.zod.ts +++ b/packages/spec/src/system/tracing.zod.ts @@ -19,6 +19,7 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; * W3C Trace Context tracestate header */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const TraceStateSchema = lazySchema(() => z.object({ /** * Vendor-specific key-value pairs @@ -211,7 +212,19 @@ export const SpanSchema = lazySchema(() => z.object({ /** * Duration in milliseconds */ - duration: z.number().nonnegative().optional().describe('Duration in milliseconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in the + // describe prose. OpenTelemetry, which this shape mirrors, carries the span + // length as a start/end nanosecond pair rather than a key named `duration`, so + // there is no external spelling to mirror here — this is a rename, not an + // `externalVocabulary` marker. + durationMs: z.number().nonnegative().optional().describe('Duration in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey( + '`Span.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename ' + + 'the key to `durationMs`; the value (milliseconds) is unchanged.', + ), /** * Span status diff --git a/packages/spec/src/system/worker.zod.ts b/packages/spec/src/system/worker.zod.ts index ab9fe743b3..785ff81cff 100644 --- a/packages/spec/src/system/worker.zod.ts +++ b/packages/spec/src/system/worker.zod.ts @@ -40,6 +40,7 @@ import { z } from 'zod'; * Lower numbers = higher priority */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const TaskPriority = z.enum([ 'critical', // 0 - Must execute immediately 'high', // 1 - Execute soon @@ -254,7 +255,7 @@ export type TaskExecutionResult = z.input; * "concurrency": 10, * "rateLimit": { * "max": 100, - * "duration": 60000 + * "durationMs": 60000 * } * } */ @@ -274,7 +275,18 @@ export const QueueConfigSchema = lazySchema(() => z.object({ */ rateLimit: z.object({ max: z.number().int().positive().describe('Maximum tasks per duration'), - duration: z.number().int().positive().describe('Duration in milliseconds'), + // Renamed from `duration` (#15679, #14478 ruling B): the unit lived only in + // the describe prose, while `TaskResult.durationMs` earlier in this same file + // already spelled the identical measurement correctly. + durationMs: z.number().int().positive().describe('Duration in milliseconds'), + + /** Tombstone for the rename above (#15679, ruling B on #14478). */ + duration: retiredKey( + '`QueueConfig.rateLimit.duration` was renamed to `durationMs` in @objectstack/spec ' + + '17 — the unit of a duration-shaped number lives in the key name, not only in the ' + + 'describe prose, and `TaskResult.durationMs` on this same file already spelled it ' + + 'that way. Rename the key to `durationMs`; the value (milliseconds) is unchanged.', + ), }).optional().describe('Rate limit configuration'), /** From 28f60ba9da0644afbb79bfd85fc73d86def958bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:35:43 +0000 Subject: [PATCH 19/33] wip(spec): readers, tombstone refusal tests and ADR-0087 registrations for the system/ renames (#15679) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../18.system__AccessControlConfig__maxAge.ts | 16 + ...Prevention__circuitBreaker.resetTimeout.ts | 10 + .../retired-keys/18.system__CacheTier__ttl.ts | 12 + ...CollaborationSessionConfig__idleTimeout.ts | 12 + ...orationSessionConfig__snapshot.interval.ts | 10 + ...em__FailoverConfig__healthCheckInterval.ts | 13 + ...m__MetricAggregationConfig__window.size.ts | 14 + ...system__QueueConfig__rateLimit.duration.ts | 12 + .../18.system__RegistryConfig__cache.ttl.ts | 9 + ....system__RegistryUpstream__syncInterval.ts | 11 + .../18.system__RegistryUpstream__timeout.ts | 12 + ...tem__ServiceLevelIndicator__window.size.ts | 11 + ..._ServiceLevelObjective__period.duration.ts | 12 + .../retired-keys/18.system__Span__duration.ts | 12 + .../18.system__StorageConnection__timeout.ts | 9 + .../18.system-cache-durations-unit-in-key.ts | 37 ++ ...tem-collaboration-durations-unit-in-key.ts | 37 ++ ...lover-health-check-interval-unit-in-key.ts | 30 ++ ...em-metrics-window-durations-unit-in-key.ts | 41 ++ ...em-object-storage-durations-unit-in-key.ts | 37 ++ ...m-registry-config-durations-unit-in-key.ts | 35 ++ ...ystem-tracing-span-duration-unit-in-key.ts | 34 ++ ...r-queue-rate-limit-duration-unit-in-key.ts | 30 ++ packages/spec/src/migrations/registry.ts | 394 ++++++++++++++++++ packages/spec/src/system/cache.test.ts | 63 ++- .../spec/src/system/collaboration.test.ts | 48 ++- .../spec/src/system/disaster-recovery.test.ts | 40 +- packages/spec/src/system/metrics.test.ts | 96 ++++- .../spec/src/system/object-storage.test.ts | 57 ++- .../spec/src/system/registry-config.test.ts | 70 +++- packages/spec/src/system/tracing.test.ts | 38 +- packages/spec/src/system/worker.test.ts | 34 +- 32 files changed, 1257 insertions(+), 39 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts new file mode 100644 index 0000000000..d83c51754a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__AccessControlConfig__maxAge.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said +// "CORS preflight cache duration in seconds" in prose and nothing else. +// ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, +// and the asymmetry with its twin is load-bearing: every bucket-CORS standard +// this value is forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, +// GCS `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so marking it would have +// exempted a DEVIATION from the cited standard rather than a mirror of it. The +// twin `shared/CorsConfig.maxAge` DID get the marker, because the Fetch response +// header it mirrors — `Access-Control-Max-Age` — genuinely carries no unit token. +// Two `maxAge` keys, opposite sides of the line; do not harmonise them. Renamed +// to `maxAgeSeconds`; the value is unchanged. Tombstoned with `retiredKey()`. No +// D2 conversion: not a stack collection member, not a stored row. +// See `system-object-storage-durations-unit-in-key`. +export const entry = 'system/AccessControlConfig:maxAge'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts new file mode 100644 index 0000000000..b0cdc8ae5d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheAvalanchePrevention__circuitBreaker.resetTimeout.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` +// said "Seconds before half-open state" in prose only, while the `lockout` block +// three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, +// two conventions, and the two are not even the same unit. Renamed to +// `resetTimeoutSeconds`; the value and the 30 default are unchanged. Tombstoned +// with `retiredKey()`. No D2 conversion: not a stack collection member, not a +// stored row. See `system-cache-durations-unit-in-key`. +export const entry = 'system/CacheAvalanchePrevention:circuitBreaker.resetTimeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts new file mode 100644 index 0000000000..7f7198f8de --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CacheTier__ttl.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `CacheTier.ttl` said "Default TTL +// in seconds" in prose and nothing else, on a tier whose sibling `maxSize` is a +// size in MB: two bare numbers side by side, neither naming its unit at the +// authoring site. Renamed to `ttlSeconds`; the value and the 300 default are +// unchanged. Tombstoned with `retiredKey()` — `CacheTierSchema` is a plain +// `z.object()`, so a bare deletion would strip the old key in silence. No D2 +// conversion: `stack.zod.ts` declares no `cache` collection and a cache tier is +// never a stored metadata row, so the conversion chain has no seam that sees it. +// See `system-cache-durations-unit-in-key`. +export const entry = 'system/CacheTier:ttl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts new file mode 100644 index 0000000000..8043fdf00b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__idleTimeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. This is the live 1000x collision +// that got the whole population ruled: `CollaborationSessionConfig.idleTimeout` +// is MILLISECONDS while the tenant surface carried its own `idleTimeout` in +// SECONDS, so `idleTimeout: 300000` meant five minutes here and three and a half +// days there, with nothing at either authoring site to tell them apart. Renamed +// to `idleTimeoutMs`; the value and the 300000 default are unchanged. Tombstoned +// with `retiredKey()`. No D2 conversion: `stack.zod.ts` declares no +// `collaboration` collection and a session config is a runtime call argument, +// not a stored metadata row. See `system-collaboration-durations-unit-in-key`. +export const entry = 'system/CollaborationSessionConfig:idleTimeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts new file mode 100644 index 0000000000..9a86e6ad4c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__CollaborationSessionConfig__snapshot.interval.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `snapshot.interval` said +// "Snapshot interval in milliseconds" in prose and nothing else. It moves in the +// same stroke as its parent's `idleTimeout`: both are session-lifetime durations +// on one config object, and leaving one bare would have kept exactly the +// ambiguity the rename removes. Renamed to `intervalMs`; the value is unchanged. +// Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. +// See `system-collaboration-durations-unit-in-key`. +export const entry = 'system/CollaborationSessionConfig:snapshot.interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts new file mode 100644 index 0000000000..f3d857a57b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__FailoverConfig__healthCheckInterval.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` +// said "Health check interval in seconds" in prose and nothing else. Renamed to +// `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. +// ⚠️ Its neighbour `FailoverConfig.dns.ttl` on this same schema keeps its bare +// name and is NOT part of this rename — that key carries an `externalVocabulary` +// marker because it mirrors the DNS resource-record TTL field (RFC 1035 §4.1.3), +// spelled `ttl` by every provider API it is forwarded to. This one mirrors +// nothing outside the repo. Tombstoned with `retiredKey()`. No D2 conversion: +// not a stack collection member, not a stored row. +// See `system-failover-health-check-interval-unit-in-key`. +export const entry = 'system/FailoverConfig:healthCheckInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts new file mode 100644 index 0000000000..b35aa4ae24 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__MetricAggregationConfig__window.size.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `MetricAggregationConfig.window.size` +// said "Window size in seconds" in prose and nothing else. Renamed to +// `durationSeconds`, NOT to the gate's mechanical `sizeSeconds`: `size` is +// byte/row-count vocabulary everywhere else in this spec (`CacheTier.maxSize` is +// MB, `RegistryConfig.cache.maxSize` is bytes, this file's own `batch.size` is a +// row count), so `sizeSeconds` would have preserved the misleading half of the +// name and bolted a unit onto it. `windowSeconds` was rejected too — the parent +// key is already `window`, so it would read `window.windowSeconds`. The value is +// unchanged. Tombstoned with `retiredKey()`. No D2 conversion: `stack.zod.ts` +// declares no `metrics` collection and an aggregation config is not a stored +// metadata row. See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/MetricAggregationConfig:window.size'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts new file mode 100644 index 0000000000..65e81eb040 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__QueueConfig__rateLimit.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` +// said "Duration in milliseconds" in prose and nothing else — while +// `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled +// the identical measurement correctly. The counter-example was in the file, which +// is what makes this one a drift rather than a convention. Renamed to +// `durationMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 +// conversion: `stack.zod.ts` declares `jobs`, not `queues`, and a queue config is +// worker host configuration rather than a stored metadata row. +// See `system-worker-queue-rate-limit-duration-unit-in-key`. +export const entry = 'system/QueueConfig:rateLimit.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts new file mode 100644 index 0000000000..21d5a0df46 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryConfig__cache.ttl.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryConfig.cache.ttl` said +// "Cache TTL in seconds" in prose and nothing else, next to a `maxSize` in the +// same cache block measured in BYTES. Renamed to `ttlSeconds`; the value and the +// 3600 default are unchanged. Tombstoned with `retiredKey()`. No D2 conversion: +// not a stack collection member, not a stored row. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryConfig:cache.ttl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts new file mode 100644 index 0000000000..3747bec065 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__syncInterval.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.syncInterval` +// said "Auto-sync interval in seconds" in prose and nothing else, on a block +// whose `timeout` two keys down was MILLISECONDS: one upstream declaration, two +// units, neither spelled at the authoring site. Renamed to `syncIntervalSeconds`; +// the value and the min-60 bound are unchanged. Tombstoned with `retiredKey()`. +// No D2 conversion: `stack.zod.ts` declares no `registry` collection and a +// registry config is host configuration, not a stored metadata row. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryUpstream:syncInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts new file mode 100644 index 0000000000..ecf8f27619 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__RegistryUpstream__timeout.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.timeout` said +// "Request timeout in milliseconds" in prose and nothing else, beside a +// seconds-valued `syncInterval` on the same block. Its `min(1000)` bound is the +// sharpest reading of why the rule exists: under the wrong unit that floor reads +// as sixteen minutes rather than one second, and no parse can catch the mistake +// because both readings are in range. Renamed to `timeoutMs`; the value, the +// 30000 default and the min-1000 bound are unchanged. Tombstoned with +// `retiredKey()`. No D2 conversion, for its sibling's reason. +// See `system-registry-config-durations-unit-in-key`. +export const entry = 'system/RegistryUpstream:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts new file mode 100644 index 0000000000..f835dc1436 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelIndicator__window.size.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. The second of the two +// byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the +// same prose and takes the same new name, `durationSeconds`, for the reason +// recorded on its twin (`system/MetricAggregationConfig:window.size`). Registered +// as its own row because the authorable surface is per DEF, not per source line: +// an author migrating an SLI never reads the aggregation-config entry. The value +// is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. +// See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/ServiceLevelIndicator:window.size'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts new file mode 100644 index 0000000000..b09eec90d2 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__ServiceLevelObjective__period.duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `ServiceLevelObjective.period.duration` +// said "Duration in seconds" in prose and nothing else. Renamed to +// `durationSeconds`; the value is unchanged. This key is why the two `window.size` +// keys above land on `durationSeconds` rather than `sizeSeconds`: the file already +// spelled a window length as a `duration` one schema down, so the three +// measurements now read alike instead of one of them borrowing byte vocabulary. +// Tombstoned with `retiredKey()`. No D2 conversion: an SLO is not a stack +// collection member and not a stored metadata row. +// See `system-metrics-window-durations-unit-in-key`. +export const entry = 'system/ServiceLevelObjective:period.duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts new file mode 100644 index 0000000000..18d4ca0f35 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__Span__duration.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `Span.duration` said "Duration in +// milliseconds" in prose and nothing else, on a shape that already spells its two +// instants `startTime` / `endTime`. Renamed to `durationMs`; the value is +// unchanged. Not an `externalVocabulary` mirror: OpenTelemetry, which this shape +// follows, carries span length as a start/end nanosecond PAIR and declares no key +// named `duration` at all, so there is no external spelling to mirror here. +// Tombstoned with `retiredKey()`. No D2 conversion: a span is a runtime-emitted +// measurement, never authored metadata and never a stored `sys_metadata` row. +// See `system-tracing-span-duration-unit-in-key`. +export const entry = 'system/Span:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts new file mode 100644 index 0000000000..e0d8a64564 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__StorageConnection__timeout.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15679 (stack card 4/6 of #14478) — ruling B. `StorageConnection.timeout` said +// "Connection timeout in milliseconds" in prose and nothing else. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 +// conversion: `stack.zod.ts` declares no `objectStorage` collection and a storage +// connection is host configuration, not a stored metadata row. +// See `system-object-storage-durations-unit-in-key`. +export const entry = 'system/StorageConnection:timeout'; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts new file mode 100644 index 0000000000..749f2f66f1 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-cache-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-cache-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two cache durations whose name carried no unit: CacheTier.ttl and ' + + 'CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)', + replacement: 'ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 ' + + 'TTL default and the 30 reset default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These two are one entry because they are one file and one authoring session: a ' + + 'cache tier and the avalanche-prevention block that protects it. Each sat beside a ' + + 'number in a DIFFERENT unit with nothing at the authoring site to separate them — ' + + 'CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout ' + + '(seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That ' + + 'last pair is the sharpest case on this file: one shape already carried both ' + + 'conventions, and the suffixed one was the honest half. Both are retiredKey() ' + + 'tombstones; neither shape is strict, so a bare deletion would strip in silence and ' + + 'the unknown-key error could not carry the rename. Why a semantic entry and not a D2 ' + + 'conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor ' + + 'an avalanche-prevention block is a registered metadata kind stored as a sys_metadata ' + + 'row, so the conversion chain has no seam that would see one. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every author of a CacheTier spells ttlSeconds and every author of a ' + + 'CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either ' + + 'old spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier ' + + 'given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted ' + + 'key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing ' + + 'this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its ' + + 'MILLISECOND unit — the two timeouts on this schema were never the same unit and must ' + + 'not be migrated as if they were.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts new file mode 100644 index 0000000000..224dd2177e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-collaboration-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-collaboration-durations-unit-in-key', + surface: 'the two collaboration-session durations whose name carried no unit: ' + + 'CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval ' + + '(system/collaboration.zod.ts)', + replacement: 'idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the ' + + '300000 idle-timeout default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'idleTimeout is the collision that got this whole population ruled rather than merely ' + + 'noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout ' + + 'in SECONDS at the same time — so the identical bare name meant five minutes on one ' + + 'shape and three and a half days on the other, a 1000x divergence no parse could catch ' + + 'because both readings are positive integers. The tenant half was already renamed ' + + '(#15626); this is the half that remained. snapshot.interval rides in the same entry ' + + 'because it is the same object graph and the same authoring session — leaving one bare ' + + 'beside the other would have preserved exactly the ambiguity the rename removes. Both ' + + 'are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip ' + + 'in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no ' + + 'collaboration collection, and a session config is a runtime call argument rather than ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see it. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot ' + + 'block spells intervalMs. Authoring either old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, ' + + 'an omitted key still defaults to 300000, and the positive-integer bounds ride along ' + + 'with the renamed keys so a zero or negative interval is still refused. The migration ' + + 'is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — ' + + 'the seconds-valued tenant twin is already gone, so a surviving bare spelling is now ' + + 'unambiguously a missed edit rather than the other key.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts new file mode 100644 index 0000000000..5e52bfc9fd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-failover-health-check-interval-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-failover-health-check-interval-unit-in-key', + surface: 'FailoverConfig.healthCheckInterval, the disaster-recovery health-check period ' + + 'whose name carried no unit (system/disaster-recovery.zod.ts)', + replacement: 'healthCheckIntervalSeconds — rename the key; the value and the 30 default ' + + 'are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because its file has exactly one offender left — and because the key ' + + 'directly beside it is the counter-example that shows where the line falls. ' + + 'FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: ' + + 'it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL ' + + 'field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is ' + + 'forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this ' + + 'repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is ' + + 'not strict, so a bare deletion would strip in silence. Why a semantic entry and not a ' + + 'D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover ' + + 'config is host configuration, never a stored sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every FailoverConfig author spells healthCheckIntervalSeconds; authoring ' + + 'healthCheckInterval fails to compile (input type `never`) and fails to parse with the ' + + 'rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes ' + + 'every thirty seconds exactly as before, and an omitted key still defaults to 30. The ' + + 'migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed ' + + 'it too has over-applied the rule and stripped a declared exemption.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts new file mode 100644 index 0000000000..ead461ae33 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-metrics-window-durations-unit-in-key.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-metrics-window-durations-unit-in-key', + surface: 'the three metrics window/period lengths whose name carried no unit: ' + + 'MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ' + + 'ServiceLevelObjective.period.duration (system/metrics.zod.ts)', + replacement: 'window.durationSeconds, window.durationSeconds and period.durationSeconds — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one measurement expressed three times on one ' + + 'file: how long a window or period is. The new name is deliberately NOT the mechanical ' + + 'sizeSeconds the gate prints. size means a byte or row count everywhere else in this ' + + 'spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this ' + + 'very file spells a batch row count size — so sizeSeconds would have kept the ' + + 'misleading half of the name and bolted a unit onto it, leaving a reader to decide ' + + 'whether a window is measured in bytes-per-second or in time. windowSeconds was ' + + 'rejected for a plainer reason: the parent key is already window, so it would read ' + + 'window.windowSeconds. durationSeconds names what the number IS, and the file itself ' + + 'supplied the precedent — ServiceLevelObjective.period already called its length a ' + + 'duration, so after the rename all three read alike instead of one borrowing byte ' + + 'vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare ' + + 'deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI ' + + 'or an SLO is a registered metadata kind stored as a sys_metadata row. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every aggregation window and SLI window spells durationSeconds, and every SLO period ' + + 'spells durationSeconds. Authoring window.size or period.duration fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as ' + + 'size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two ' + + 'keys on this same file deliberately do NOT move, and a sweep that renamed either has ' + + 'over-applied the rule: the error-budget burn-rate window, whose describe reads only ' + + '"Window size" and names no unit anywhere, is outside the gate population entirely; ' + + 'and the exporter batch size is a COUNT of records, not a duration, so it has no unit ' + + 'to carry. Both keep their names.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts new file mode 100644 index 0000000000..4190f1bb9d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-object-storage-durations-unit-in-key.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-object-storage-durations-unit-in-key', + surface: 'the two object-storage durations whose name carried no unit: ' + + 'AccessControlConfig.maxAge and StorageConnection.timeout ' + + '(system/object-storage.zod.ts)', + replacement: 'maxAgeSeconds and timeoutMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'AccessControlConfig.maxAge is the one key in this stack where the two structural ' + + 'exemptions and the rename look alike from a distance, so the reasoning is recorded ' + + 'rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on ' + + 'evidence: every bucket-CORS standard the value is forwarded to spells the field WITH ' + + 'its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it ' + + 'would have exempted a DEVIATION from the cited standard rather than a mirror of it, ' + + 'which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge ' + + 'DID get the marker and keeps its bare name, because the Fetch response header that one ' + + 'mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on ' + + 'opposite sides of the same line; the asymmetry is the point and must not be ' + + 'harmonised. StorageConnection.timeout rides along as the plain case on the same file. ' + + 'Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would ' + + 'strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'no objectStorage collection, and neither shape is a registered metadata kind stored as ' + + 'a sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every bucket access-control block spells maxAgeSeconds and every storage connection ' + + 'spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and ' + + 'the non-negative bounds ride along with the renamed keys. The migration is proved ' + + 'correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace ' + + 'that renamed both has destroyed a declared external-vocabulary mirror, and the gate ' + + 'will not catch it because the marker exempts the key either way.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts new file mode 100644 index 0000000000..12d980ceb3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-registry-config-durations-unit-in-key.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-registry-config-durations-unit-in-key', + surface: 'the three package-registry durations whose name carried no unit: ' + + 'RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl ' + + '(system/registry-config.zod.ts)', + replacement: 'syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every ' + + 'value, the 30000 timeout default and the 3600 TTL default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one file and, for the first two, one object: ' + + 'RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five ' + + 'lines apart, both bare. That pair carries the clearest demonstration in this card of ' + + 'why a bound is no substitute for a name — timeout is min(1000), which reads as one ' + + 'second under the right unit and as sixteen minutes under the wrong one, and both ' + + 'readings satisfy the validator. The cache TTL is the same defect one schema over, ' + + 'beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes ' + + 'are not strict, so a bare deletion would strip in silence. Why a semantic entry and ' + + 'not a D2 conversion: stack.zod.ts declares no registry collection, and a registry ' + + 'config is host configuration read at startup rather than a stored sys_metadata row, so ' + + 'the conversion chain has no seam that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry ' + + 'cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an ' + + 'omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to ' + + '3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a ' + + 'too-small interval or timeout is still refused. The pair on RegistryUpstream is the ' + + 'one to check by hand rather than by search-and-replace: after the migration a reader ' + + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts new file mode 100644 index 0000000000..a1d3cbba2f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-tracing-span-duration-unit-in-key.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-tracing-span-duration-unit-in-key', + surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' + + '(system/tracing.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file and the only one in this ' + + 'card that is a pure runtime-emitted measurement: a span is written by an exporter and ' + + 'read by a backend, never authored by hand. That is also why it is a rename and not an ' + + 'externalVocabulary mirror, which is the exemption a tracing shape would most plausibly ' + + 'claim: OpenTelemetry, whose model this schema follows, carries span length as a ' + + 'start/end nanosecond PAIR and declares no key named duration at all, so there is no ' + + 'external spelling for the marker to point at. The shape already spells its two ' + + 'instants startTime and endTime, so the bare duration was the one measurement on the ' + + 'span that did not say what it was. Tombstoned with retiredKey(); the shape is not ' + + 'strict, so a bare deletion would strip in silence and an exporter emitting the old ' + + 'spelling would lose the value without an error. Why a semantic entry and not a D2 ' + + 'conversion: an emitted span is never a stack collection member and never a stored ' + + 'sys_metadata row — the same disposition every runtime-emitted measurement in this ' + + 'stack has taken. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a ' + + 'span length reads durationMs. Authoring duration fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription rather than silently dropping the ' + + 'measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and ' + + 'the non-negative bound rides along with the renamed key so a negative span length is ' + + 'still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, ' + + 'not numbers, and are untouched by this rename.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts new file mode 100644 index 0000000000..2189309894 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.system-worker-queue-rate-limit-duration-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'system-worker-queue-rate-limit-duration-unit-in-key', + surface: 'QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried ' + + 'no unit (system/worker.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender left on its file, and the file itself ' + + 'is what makes it a drift rather than a convention: TaskResult.durationMs, declared ' + + 'ninety lines earlier in the SAME source, already spelled the identical measurement ' + + 'with its unit. One file, one unit, two spellings, and the correct one was already ' + + 'there — so this rename removes an internal inconsistency rather than imposing an ' + + 'external one. Tombstoned with retiredKey(); the shape is not strict, so a bare ' + + 'deletion would strip in silence and a queue would fall back to no rate limit at all ' + + 'without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'jobs, not queues, so a QueueConfig is worker host configuration rather than a stack ' + + 'collection member or a stored sys_metadata row, and the conversion chain has no seam ' + + 'that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than silently dropping the window and leaving the queue unthrottled. Behaviour is ' + + 'unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as ' + + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' + + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a55c380619..113667b626 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8715,6 +8715,255 @@ const step18: MigrationStep = { + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, + { + id: 'system-cache-durations-unit-in-key', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: 'the two cache durations whose name carried no unit: CacheTier.ttl and ' + + 'CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts)', + replacement: 'ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 ' + + 'TTL default and the 30 reset default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'These two are one entry because they are one file and one authoring session: a ' + + 'cache tier and the avalanche-prevention block that protects it. Each sat beside a ' + + 'number in a DIFFERENT unit with nothing at the authoring site to separate them — ' + + 'CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout ' + + '(seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That ' + + 'last pair is the sharpest case on this file: one shape already carried both ' + + 'conventions, and the suffixed one was the honest half. Both are retiredKey() ' + + 'tombstones; neither shape is strict, so a bare deletion would strip in silence and ' + + 'the unknown-key error could not carry the rename. Why a semantic entry and not a D2 ' + + 'conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor ' + + 'an avalanche-prevention block is a registered metadata kind stored as a sys_metadata ' + + 'row, so the conversion chain has no seam that would see one. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every author of a CacheTier spells ttlSeconds and every author of a ' + + 'CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either ' + + 'old spelling fails to compile (input type `never`) and fails to parse with the rename ' + + 'prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier ' + + 'given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted ' + + 'key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing ' + + 'this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its ' + + 'MILLISECOND unit — the two timeouts on this schema were never the same unit and must ' + + 'not be migrated as if they were.', + }, + { + id: 'system-collaboration-durations-unit-in-key', + surface: 'the two collaboration-session durations whose name carried no unit: ' + + 'CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval ' + + '(system/collaboration.zod.ts)', + replacement: 'idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the ' + + '300000 idle-timeout default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'idleTimeout is the collision that got this whole population ruled rather than merely ' + + 'noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout ' + + 'in SECONDS at the same time — so the identical bare name meant five minutes on one ' + + 'shape and three and a half days on the other, a 1000x divergence no parse could catch ' + + 'because both readings are positive integers. The tenant half was already renamed ' + + '(#15626); this is the half that remained. snapshot.interval rides in the same entry ' + + 'because it is the same object graph and the same authoring session — leaving one bare ' + + 'beside the other would have preserved exactly the ambiguity the rename removes. Both ' + + 'are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip ' + + 'in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no ' + + 'collaboration collection, and a session config is a runtime call argument rather than ' + + 'a stored sys_metadata row, so the conversion chain has no seam that would see it. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot ' + + 'block spells intervalMs. Authoring either old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, ' + + 'an omitted key still defaults to 300000, and the positive-integer bounds ride along ' + + 'with the renamed keys so a zero or negative interval is still refused. The migration ' + + 'is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — ' + + 'the seconds-valued tenant twin is already gone, so a surviving bare spelling is now ' + + 'unambiguously a missed edit rather than the other key.', + }, + { + id: 'system-failover-health-check-interval-unit-in-key', + surface: 'FailoverConfig.healthCheckInterval, the disaster-recovery health-check period ' + + 'whose name carried no unit (system/disaster-recovery.zod.ts)', + replacement: 'healthCheckIntervalSeconds — rename the key; the value and the 30 default ' + + 'are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because its file has exactly one offender left — and because the key ' + + 'directly beside it is the counter-example that shows where the line falls. ' + + 'FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: ' + + 'it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL ' + + 'field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is ' + + 'forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this ' + + 'repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is ' + + 'not strict, so a bare deletion would strip in silence. Why a semantic entry and not a ' + + 'D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover ' + + 'config is host configuration, never a stored sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every FailoverConfig author spells healthCheckIntervalSeconds; authoring ' + + 'healthCheckInterval fails to compile (input type `never`) and fails to parse with the ' + + 'rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes ' + + 'every thirty seconds exactly as before, and an omitted key still defaults to 30. The ' + + 'migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed ' + + 'it too has over-applied the rule and stripped a declared exemption.', + }, + { + id: 'system-metrics-window-durations-unit-in-key', + surface: 'the three metrics window/period lengths whose name carried no unit: ' + + 'MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ' + + 'ServiceLevelObjective.period.duration (system/metrics.zod.ts)', + replacement: 'window.durationSeconds, window.durationSeconds and period.durationSeconds — ' + + 'rename each key; every value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one measurement expressed three times on one ' + + 'file: how long a window or period is. The new name is deliberately NOT the mechanical ' + + 'sizeSeconds the gate prints. size means a byte or row count everywhere else in this ' + + 'spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this ' + + 'very file spells a batch row count size — so sizeSeconds would have kept the ' + + 'misleading half of the name and bolted a unit onto it, leaving a reader to decide ' + + 'whether a window is measured in bytes-per-second or in time. windowSeconds was ' + + 'rejected for a plainer reason: the parent key is already window, so it would read ' + + 'window.windowSeconds. durationSeconds names what the number IS, and the file itself ' + + 'supplied the precedent — ServiceLevelObjective.period already called its length a ' + + 'duration, so after the rename all three read alike instead of one borrowing byte ' + + 'vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare ' + + 'deletion would strip in silence. Why a semantic entry and not a D2 conversion: ' + + 'stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI ' + + 'or an SLO is a registered metadata kind stored as a sys_metadata row. ' + + '#15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every aggregation window and SLI window spells durationSeconds, and every SLO period ' + + 'spells durationSeconds. Authoring window.size or period.duration fails to compile ' + + '(input type `never`) and fails to parse with the rename prescription. Behaviour is ' + + 'unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as ' + + 'size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two ' + + 'keys on this same file deliberately do NOT move, and a sweep that renamed either has ' + + 'over-applied the rule: the error-budget burn-rate window, whose describe reads only ' + + '"Window size" and names no unit anywhere, is outside the gate population entirely; ' + + 'and the exporter batch size is a COUNT of records, not a duration, so it has no unit ' + + 'to carry. Both keep their names.', + }, + { + id: 'system-object-storage-durations-unit-in-key', + surface: 'the two object-storage durations whose name carried no unit: ' + + 'AccessControlConfig.maxAge and StorageConnection.timeout ' + + '(system/object-storage.zod.ts)', + replacement: 'maxAgeSeconds and timeoutMs — rename each key; both values are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'AccessControlConfig.maxAge is the one key in this stack where the two structural ' + + 'exemptions and the rename look alike from a distance, so the reasoning is recorded ' + + 'rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on ' + + 'evidence: every bucket-CORS standard the value is forwarded to spells the field WITH ' + + 'its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it ' + + 'would have exempted a DEVIATION from the cited standard rather than a mirror of it, ' + + 'which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge ' + + 'DID get the marker and keeps its bare name, because the Fetch response header that one ' + + 'mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on ' + + 'opposite sides of the same line; the asymmetry is the point and must not be ' + + 'harmonised. StorageConnection.timeout rides along as the plain case on the same file. ' + + 'Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would ' + + 'strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'no objectStorage collection, and neither shape is a registered metadata kind stored as ' + + 'a sys_metadata row. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every bucket access-control block spells maxAgeSeconds and every storage connection ' + + 'spells timeoutMs. Authoring either old spelling fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and ' + + 'the non-negative bounds ride along with the renamed keys. The migration is proved ' + + 'correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace ' + + 'that renamed both has destroyed a declared external-vocabulary mirror, and the gate ' + + 'will not catch it because the marker exempts the key either way.', + }, + { + id: 'system-registry-config-durations-unit-in-key', + surface: 'the three package-registry durations whose name carried no unit: ' + + 'RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl ' + + '(system/registry-config.zod.ts)', + replacement: 'syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every ' + + 'value, the 30000 timeout default and the 3600 TTL default are unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'The three are one entry because they are one file and, for the first two, one object: ' + + 'RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five ' + + 'lines apart, both bare. That pair carries the clearest demonstration in this card of ' + + 'why a bound is no substitute for a name — timeout is min(1000), which reads as one ' + + 'second under the right unit and as sixteen minutes under the wrong one, and both ' + + 'readings satisfy the validator. The cache TTL is the same defect one schema over, ' + + 'beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes ' + + 'are not strict, so a bare deletion would strip in silence. Why a semantic entry and ' + + 'not a D2 conversion: stack.zod.ts declares no registry collection, and a registry ' + + 'config is host configuration read at startup rather than a stored sys_metadata row, so ' + + 'the conversion chain has no seam that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry ' + + 'cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type ' + + '`never`) and fails to parse with the rename prescription. Behaviour is unchanged: ' + + 'syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an ' + + 'omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to ' + + '3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a ' + + 'too-small interval or timeout is still refused. The pair on RegistryUpstream is the ' + + 'one to check by hand rather than by search-and-replace: after the migration a reader ' + + 'can tell at the authoring site that 300 and 30000 are not the same kind of number.', + }, + { + id: 'system-tracing-span-duration-unit-in-key', + surface: 'Span.duration, the emitted trace-span length whose name carried no unit ' + + '(system/tracing.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file and the only one in this ' + + 'card that is a pure runtime-emitted measurement: a span is written by an exporter and ' + + 'read by a backend, never authored by hand. That is also why it is a rename and not an ' + + 'externalVocabulary mirror, which is the exemption a tracing shape would most plausibly ' + + 'claim: OpenTelemetry, whose model this schema follows, carries span length as a ' + + 'start/end nanosecond PAIR and declares no key named duration at all, so there is no ' + + 'external spelling for the marker to point at. The shape already spells its two ' + + 'instants startTime and endTime, so the bare duration was the one measurement on the ' + + 'span that did not say what it was. Tombstoned with retiredKey(); the shape is not ' + + 'strict, so a bare deletion would strip in silence and an exporter emitting the old ' + + 'spelling would lose the value without an error. Why a semantic entry and not a D2 ' + + 'conversion: an emitted span is never a stack collection member and never a stored ' + + 'sys_metadata row — the same disposition every runtime-emitted measurement in this ' + + 'stack has taken. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a ' + + 'span length reads durationMs. Authoring duration fails to compile (input type `never`) ' + + 'and fails to parse with the rename prescription rather than silently dropping the ' + + 'measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and ' + + 'the non-negative bound rides along with the renamed key so a negative span length is ' + + 'still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, ' + + 'not numbers, and are untouched by this rename.', + }, + { + id: 'system-worker-queue-rate-limit-duration-unit-in-key', + surface: 'QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried ' + + 'no unit (system/worker.zod.ts)', + replacement: 'durationMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender left on its file, and the file itself ' + + 'is what makes it a drift rather than a convention: TaskResult.durationMs, declared ' + + 'ninety lines earlier in the SAME source, already spelled the identical measurement ' + + 'with its unit. One file, one unit, two spellings, and the correct one was already ' + + 'there — so this rename removes an internal inconsistency rather than imposing an ' + + 'external one. Tombstoned with retiredKey(); the shape is not strict, so a bare ' + + 'deletion would strip in silence and a queue would fall back to no rate limit at all ' + + 'without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares ' + + 'jobs, not queues, so a QueueConfig is worker host configuration rather than a stack ' + + 'collection member or a stored sys_metadata row, and the conversion chain has no seam ' + + 'that would see it. #15679, #14478, ADR-0087.', + acceptanceCriteria: + 'Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than silently dropping the window and leaving the queue unthrottled. Behaviour is ' + + 'unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as ' + + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' + + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', + }, { id: 'tenant-timeouts-unit-in-key', surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' @@ -10728,6 +10977,38 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // D2 conversion `permission-allow-restore-purge-removed`, which strips the // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowRestore', + // #15679 (stack card 4/6 of #14478) — ruling B. `AccessControlConfig.maxAge` said + // "CORS preflight cache duration in seconds" in prose and nothing else. + // ⚠️ This key is deliberately a RENAME and not an `externalVocabulary` marker, + // and the asymmetry with its twin is load-bearing: every bucket-CORS standard + // this value is forwarded to spells the field WITH its unit (S3 `MaxAgeSeconds`, + // GCS `maxAgeSeconds`, Azure `MaxAgeInSeconds`), so marking it would have + // exempted a DEVIATION from the cited standard rather than a mirror of it. The + // twin `shared/CorsConfig.maxAge` DID get the marker, because the Fetch response + // header it mirrors — `Access-Control-Max-Age` — genuinely carries no unit token. + // Two `maxAge` keys, opposite sides of the line; do not harmonise them. Renamed + // to `maxAgeSeconds`; the value is unchanged. Tombstoned with `retiredKey()`. No + // D2 conversion: not a stack collection member, not a stored row. + // See `system-object-storage-durations-unit-in-key`. + 'system/AccessControlConfig:maxAge', + // #15679 (stack card 4/6 of #14478) — ruling B. `circuitBreaker.resetTimeout` + // said "Seconds before half-open state" in prose only, while the `lockout` block + // three lines down on the SAME schema already spelled `lockTimeoutMs`. One shape, + // two conventions, and the two are not even the same unit. Renamed to + // `resetTimeoutSeconds`; the value and the 30 default are unchanged. Tombstoned + // with `retiredKey()`. No D2 conversion: not a stack collection member, not a + // stored row. See `system-cache-durations-unit-in-key`. + 'system/CacheAvalanchePrevention:circuitBreaker.resetTimeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `CacheTier.ttl` said "Default TTL + // in seconds" in prose and nothing else, on a tier whose sibling `maxSize` is a + // size in MB: two bare numbers side by side, neither naming its unit at the + // authoring site. Renamed to `ttlSeconds`; the value and the 300 default are + // unchanged. Tombstoned with `retiredKey()` — `CacheTierSchema` is a plain + // `z.object()`, so a bare deletion would strip the old key in silence. No D2 + // conversion: `stack.zod.ts` declares no `cache` collection and a cache tier is + // never a stored metadata row, so the conversion chain has no seam that sees it. + // See `system-cache-durations-unit-in-key`. + 'system/CacheTier:ttl', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10784,6 +11065,35 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/BatchEndpointsConfig:operations.upsertMany` are. // D3 semantic entry: `change-management-duration-keys-retired`. 'system/ChangeRequest:implementation.steps.estimatedMinutes', + // #15679 (stack card 4/6 of #14478) — ruling B. This is the live 1000x collision + // that got the whole population ruled: `CollaborationSessionConfig.idleTimeout` + // is MILLISECONDS while the tenant surface carried its own `idleTimeout` in + // SECONDS, so `idleTimeout: 300000` meant five minutes here and three and a half + // days there, with nothing at either authoring site to tell them apart. Renamed + // to `idleTimeoutMs`; the value and the 300000 default are unchanged. Tombstoned + // with `retiredKey()`. No D2 conversion: `stack.zod.ts` declares no + // `collaboration` collection and a session config is a runtime call argument, + // not a stored metadata row. See `system-collaboration-durations-unit-in-key`. + 'system/CollaborationSessionConfig:idleTimeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `snapshot.interval` said + // "Snapshot interval in milliseconds" in prose and nothing else. It moves in the + // same stroke as its parent's `idleTimeout`: both are session-lifetime durations + // on one config object, and leaving one bare would have kept exactly the + // ambiguity the rename removes. Renamed to `intervalMs`; the value is unchanged. + // Tombstoned with `retiredKey()`. No D2 conversion, for its parent's reason. + // See `system-collaboration-durations-unit-in-key`. + 'system/CollaborationSessionConfig:snapshot.interval', + // #15679 (stack card 4/6 of #14478) — ruling B. `FailoverConfig.healthCheckInterval` + // said "Health check interval in seconds" in prose and nothing else. Renamed to + // `healthCheckIntervalSeconds`; the value and the 30 default are unchanged. + // ⚠️ Its neighbour `FailoverConfig.dns.ttl` on this same schema keeps its bare + // name and is NOT part of this rename — that key carries an `externalVocabulary` + // marker because it mirrors the DNS resource-record TTL field (RFC 1035 §4.1.3), + // spelled `ttl` by every provider API it is forwarded to. This one mirrors + // nothing outside the repo. Tombstoned with `retiredKey()`. No D2 conversion: + // not a stack collection member, not a stored row. + // See `system-failover-health-check-interval-unit-in-key`. + 'system/FailoverConfig:healthCheckInterval', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10926,6 +11236,54 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (launch-window convention) and the prescription lives at the major boundary // where `migrate meta` users look. 'system/Job:timeout', + // #15679 (stack card 4/6 of #14478) — ruling B. `MetricAggregationConfig.window.size` + // said "Window size in seconds" in prose and nothing else. Renamed to + // `durationSeconds`, NOT to the gate's mechanical `sizeSeconds`: `size` is + // byte/row-count vocabulary everywhere else in this spec (`CacheTier.maxSize` is + // MB, `RegistryConfig.cache.maxSize` is bytes, this file's own `batch.size` is a + // row count), so `sizeSeconds` would have preserved the misleading half of the + // name and bolted a unit onto it. `windowSeconds` was rejected too — the parent + // key is already `window`, so it would read `window.windowSeconds`. The value is + // unchanged. Tombstoned with `retiredKey()`. No D2 conversion: `stack.zod.ts` + // declares no `metrics` collection and an aggregation config is not a stored + // metadata row. See `system-metrics-window-durations-unit-in-key`. + 'system/MetricAggregationConfig:window.size', + // #15679 (stack card 4/6 of #14478) — ruling B. `QueueConfig.rateLimit.duration` + // said "Duration in milliseconds" in prose and nothing else — while + // `TaskResult.durationMs`, ninety lines earlier in the SAME file, already spelled + // the identical measurement correctly. The counter-example was in the file, which + // is what makes this one a drift rather than a convention. Renamed to + // `durationMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 + // conversion: `stack.zod.ts` declares `jobs`, not `queues`, and a queue config is + // worker host configuration rather than a stored metadata row. + // See `system-worker-queue-rate-limit-duration-unit-in-key`. + 'system/QueueConfig:rateLimit.duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryConfig.cache.ttl` said + // "Cache TTL in seconds" in prose and nothing else, next to a `maxSize` in the + // same cache block measured in BYTES. Renamed to `ttlSeconds`; the value and the + // 3600 default are unchanged. Tombstoned with `retiredKey()`. No D2 conversion: + // not a stack collection member, not a stored row. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryConfig:cache.ttl', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.syncInterval` + // said "Auto-sync interval in seconds" in prose and nothing else, on a block + // whose `timeout` two keys down was MILLISECONDS: one upstream declaration, two + // units, neither spelled at the authoring site. Renamed to `syncIntervalSeconds`; + // the value and the min-60 bound are unchanged. Tombstoned with `retiredKey()`. + // No D2 conversion: `stack.zod.ts` declares no `registry` collection and a + // registry config is host configuration, not a stored metadata row. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryUpstream:syncInterval', + // #15679 (stack card 4/6 of #14478) — ruling B. `RegistryUpstream.timeout` said + // "Request timeout in milliseconds" in prose and nothing else, beside a + // seconds-valued `syncInterval` on the same block. Its `min(1000)` bound is the + // sharpest reading of why the rule exists: under the wrong unit that floor reads + // as sixteen minutes rather than one second, and no parse can catch the mistake + // because both readings are in range. Renamed to `timeoutMs`; the value, the + // 30000 default and the min-1000 bound are unchanged. Tombstoned with + // `retiredKey()`. No D2 conversion, for its sibling's reason. + // See `system-registry-config-durations-unit-in-key`. + 'system/RegistryUpstream:timeout', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the @@ -10954,6 +11312,42 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/BatchEndpointsConfig:operations.upsertMany` are. // D3 semantic entry: `change-management-duration-keys-retired`. 'system/RollbackPlan:steps.estimatedMinutes', + // #15679 (stack card 4/6 of #14478) — ruling B. The second of the two + // byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the + // same prose and takes the same new name, `durationSeconds`, for the reason + // recorded on its twin (`system/MetricAggregationConfig:window.size`). Registered + // as its own row because the authorable surface is per DEF, not per source line: + // an author migrating an SLI never reads the aggregation-config entry. The value + // is unchanged. Tombstoned with `retiredKey()`; no D2 conversion. + // See `system-metrics-window-durations-unit-in-key`. + 'system/ServiceLevelIndicator:window.size', + // #15679 (stack card 4/6 of #14478) — ruling B. `ServiceLevelObjective.period.duration` + // said "Duration in seconds" in prose and nothing else. Renamed to + // `durationSeconds`; the value is unchanged. This key is why the two `window.size` + // keys above land on `durationSeconds` rather than `sizeSeconds`: the file already + // spelled a window length as a `duration` one schema down, so the three + // measurements now read alike instead of one of them borrowing byte vocabulary. + // Tombstoned with `retiredKey()`. No D2 conversion: an SLO is not a stack + // collection member and not a stored metadata row. + // See `system-metrics-window-durations-unit-in-key`. + 'system/ServiceLevelObjective:period.duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `Span.duration` said "Duration in + // milliseconds" in prose and nothing else, on a shape that already spells its two + // instants `startTime` / `endTime`. Renamed to `durationMs`; the value is + // unchanged. Not an `externalVocabulary` mirror: OpenTelemetry, which this shape + // follows, carries span length as a start/end nanosecond PAIR and declares no key + // named `duration` at all, so there is no external spelling to mirror here. + // Tombstoned with `retiredKey()`. No D2 conversion: a span is a runtime-emitted + // measurement, never authored metadata and never a stored `sys_metadata` row. + // See `system-tracing-span-duration-unit-in-key`. + 'system/Span:duration', + // #15679 (stack card 4/6 of #14478) — ruling B. `StorageConnection.timeout` said + // "Connection timeout in milliseconds" in prose and nothing else. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`. No D2 + // conversion: `stack.zod.ts` declares no `objectStorage` collection and a storage + // connection is host configuration, not a stored metadata row. + // See `system-object-storage-durations-unit-in-key`. + 'system/StorageConnection:timeout', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the diff --git a/packages/spec/src/system/cache.test.ts b/packages/spec/src/system/cache.test.ts index 840fd1c941..82f22587cb 100644 --- a/packages/spec/src/system/cache.test.ts +++ b/packages/spec/src/system/cache.test.ts @@ -41,7 +41,7 @@ describe('CacheTierSchema', () => { expect(tier.name).toBe('memory_cache'); expect(tier.type).toBe('memory'); - expect(tier.ttl).toBe(300); + expect(tier.ttlSeconds).toBe(300); expect(tier.strategy).toBe('lru'); expect(tier.warmup).toBe(false); }); @@ -59,13 +59,13 @@ describe('CacheTierSchema', () => { name: 'redis_tier', type: 'redis', maxSize: 512, - ttl: 600, + ttlSeconds: 600, strategy: 'lfu', warmup: true, }); expect(tier.maxSize).toBe(512); - expect(tier.ttl).toBe(600); + expect(tier.ttlSeconds).toBe(600); expect(tier.strategy).toBe('lfu'); expect(tier.warmup).toBe(true); }); @@ -145,7 +145,7 @@ describe('CacheConfigSchema', () => { enabled: true, tiers: [ { name: 'l1', type: 'memory', maxSize: 128 }, - { name: 'l2', type: 'redis', maxSize: 1024, ttl: 600 }, + { name: 'l2', type: 'redis', maxSize: 1024, ttlSeconds: 600 }, ], invalidation: [ { trigger: 'update', scope: 'pattern', pattern: '*' }, @@ -198,7 +198,7 @@ describe('CacheAvalanchePreventionSchema', () => { it('should accept circuit breaker config', () => { const result = CacheAvalanchePreventionSchema.parse({ - circuitBreaker: { enabled: true, failureThreshold: 10, resetTimeout: 60 }, + circuitBreaker: { enabled: true, failureThreshold: 10, resetTimeoutSeconds: 60 }, }); expect(result.circuitBreaker?.failureThreshold).toBe(10); }); @@ -267,8 +267,8 @@ describe('DistributedCacheConfigSchema', () => { const config = DistributedCacheConfigSchema.parse({ enabled: true, tiers: [ - { name: 'l1', type: 'memory', maxSize: 100, ttl: 60, strategy: 'lru' }, - { name: 'l2', type: 'redis', maxSize: 1000, ttl: 300, strategy: 'lru' }, + { name: 'l1', type: 'memory', maxSize: 100, ttlSeconds: 60, strategy: 'lru' }, + { name: 'l2', type: 'redis', maxSize: 1000, ttlSeconds: 300, strategy: 'lru' }, ], invalidation: [{ trigger: 'update', scope: 'key' }], consistency: 'write_behind', @@ -303,3 +303,52 @@ describe('DistributedCacheConfigSchema', () => { expect(config.compression).toBe(true); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B: the unit of a duration-shaped +// number lives in the key NAME. Both old spellings are `retiredKey()` tombstones, +// so each refusal carries the RENAME rather than a bare unrecognized-key error. +// Asserted on the issue CODE and the prescription text, never on "it threw": +// a bare `toThrow()` would stay green against a schema that rejected for any +// other reason, which is the failure this pin exists to catch. +describe('cache duration keys carry their unit (#15679)', () => { + it('REFUSES the retired `CacheTier.ttl` with the rename in the message', () => { + const result = CacheTierSchema.safeParse({ name: 'l1', type: 'memory', ttl: 600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'ttl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`CacheTier.ttl` was renamed to `ttlSeconds`'); + }); + + it('accepts `ttlSeconds` at the same magnitude and keeps the 300 default', () => { + expect(CacheTierSchema.parse({ name: 'l1', type: 'memory', ttlSeconds: 600 }).ttlSeconds).toBe(600); + expect(CacheTierSchema.parse({ name: 'l1', type: 'memory' }).ttlSeconds).toBe(300); + }); + + it('REFUSES the retired `circuitBreaker.resetTimeout` with the rename in the message', () => { + const result = CacheAvalanchePreventionSchema.safeParse({ + circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeout: 30 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'circuitBreaker.resetTimeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to', + ); + }); + + it('accepts `resetTimeoutSeconds` and keeps the 30 default', () => { + const parsed = CacheAvalanchePreventionSchema.parse({ + circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutSeconds: 60 }, + }); + expect(parsed.circuitBreaker?.resetTimeoutSeconds).toBe(60); + expect(CacheAvalanchePreventionSchema.parse({ circuitBreaker: { enabled: true } }) + .circuitBreaker?.resetTimeoutSeconds).toBe(30); + }); + + it('leaves `lockout.lockTimeoutMs` alone — it was already correct, and it is a DIFFERENT unit', () => { + const parsed = CacheAvalanchePreventionSchema.parse({ lockout: { enabled: true } }); + expect(parsed.lockout?.lockTimeoutMs).toBe(5000); + }); +}); diff --git a/packages/spec/src/system/collaboration.test.ts b/packages/spec/src/system/collaboration.test.ts index 2bcc25bf0e..dd02ec023d 100644 --- a/packages/spec/src/system/collaboration.test.ts +++ b/packages/spec/src/system/collaboration.test.ts @@ -904,12 +904,12 @@ describe('CollaborationSessionConfigSchema', () => { enablePresence: true, enableAwareness: false, maxUsers: 50, - idleTimeout: 600000, + idleTimeoutMs: 600000, conflictResolution: 'crdt', persistence: true, snapshot: { enabled: true, - interval: 60000, + intervalMs: 60000, }, }; @@ -925,7 +925,7 @@ describe('CollaborationSessionConfigSchema', () => { expect(parsed.enableCursorSharing).toBe(true); expect(parsed.enablePresence).toBe(true); expect(parsed.enableAwareness).toBe(true); - expect(parsed.idleTimeout).toBe(300000); + expect(parsed.idleTimeoutMs).toBe(300000); expect(parsed.conflictResolution).toBe('ot'); expect(parsed.persistence).toBe(true); }); @@ -997,3 +997,45 @@ describe('CollaborationSessionSchema', () => { }); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. `idleTimeout` was the live 1000x +// collision that got this population ruled: milliseconds here, seconds on the +// tenant surface. Both old spellings are `retiredKey()` tombstones; asserted on +// the issue CODE and the prescription, never on a bare `toThrow()`. +describe('collaboration session durations carry their unit (#15679)', () => { + it('REFUSES the retired `idleTimeout` with the rename in the message', () => { + const result = CollaborationSessionConfigSchema.safeParse({ mode: 'ot', idleTimeout: 600000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'idleTimeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs`', + ); + }); + + it('REFUSES the retired `snapshot.interval` with the rename in the message', () => { + const result = CollaborationSessionConfigSchema.safeParse({ + mode: 'ot', + snapshot: { enabled: true, interval: 60000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'snapshot.interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs`', + ); + }); + + it('accepts the renamed keys at the same magnitude and keeps the 300000 default', () => { + const parsed = CollaborationSessionConfigSchema.parse({ + mode: 'crdt', + idleTimeoutMs: 600000, + snapshot: { enabled: true, intervalMs: 60000 }, + }); + expect(parsed.idleTimeoutMs).toBe(600000); + expect(parsed.snapshot?.intervalMs).toBe(60000); + expect(CollaborationSessionConfigSchema.parse({ mode: 'ot' }).idleTimeoutMs).toBe(300000); + }); +}); diff --git a/packages/spec/src/system/disaster-recovery.test.ts b/packages/spec/src/system/disaster-recovery.test.ts index f644b5a606..f099fd2c69 100644 --- a/packages/spec/src/system/disaster-recovery.test.ts +++ b/packages/spec/src/system/disaster-recovery.test.ts @@ -174,7 +174,7 @@ describe('DisasterRecoveryPlanSchema', () => { failover: { mode: 'active_passive', autoFailover: true, - healthCheckInterval: 30, + healthCheckIntervalSeconds: 30, failureThreshold: 3, regions: [ { name: 'us-east-1', role: 'primary', endpoint: 'https://primary.example.com' }, @@ -226,3 +226,41 @@ describe('DisasterRecoveryPlanSchema', () => { // Need z import for z.input type usage in tests import { z } from 'zod'; + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The second case is the load-bearing half: the +// neighbouring `dns.ttl` is a DECLARED `externalVocabulary` mirror and must +// survive this rename untouched, and no gate can catch its loss (the marker +// exempts the key either way), so the pin is the only guard. +describe('FailoverConfig.healthCheckInterval carries its unit (#15679)', () => { + const regions = [ + { name: 'us-east-1', role: 'primary' as const }, + { name: 'eu-west-1', role: 'secondary' as const }, + ]; + + it('REFUSES the retired `healthCheckInterval` with the rename in the message', () => { + const result = FailoverConfigSchema.safeParse({ regions, healthCheckInterval: 30 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'healthCheckInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds`', + ); + }); + + it('accepts `healthCheckIntervalSeconds` and keeps the 30 default', () => { + expect(FailoverConfigSchema.parse({ regions, healthCheckIntervalSeconds: 15 }) + .healthCheckIntervalSeconds).toBe(15); + expect(FailoverConfigSchema.parse({ regions }).healthCheckIntervalSeconds).toBe(30); + }); + + it('leaves the sibling `dns.ttl` bare — it is a declared externalVocabulary mirror', () => { + const parsed = FailoverConfigSchema.parse({ + regions, + dns: { ttl: 60, provider: 'route53' }, + }); + expect(parsed.dns?.ttl).toBe(60); + }); +}); diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index e4098dc878..daeb7d69ce 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -239,7 +239,7 @@ describe('MetricAggregationConfigSchema', () => { const config = MetricAggregationConfigSchema.parse({ type: 'avg', window: { - size: 300, + durationSeconds: 300, sliding: true, slideInterval: 60, }, @@ -247,7 +247,7 @@ describe('MetricAggregationConfigSchema', () => { }); expect(config.type).toBe('avg'); - expect(config.window?.size).toBe(300); + expect(config.window?.durationSeconds).toBe(300); }); }); @@ -263,7 +263,7 @@ describe('ServiceLevelIndicatorSchema', () => { operator: 'gte', }, window: { - size: 2592000, // 30 days + durationSeconds: 2592000, // 30 days }, }; @@ -282,7 +282,7 @@ describe('ServiceLevelIndicatorSchema', () => { percentile: 0.99, }, window: { - size: 86400, // 1 day + durationSeconds: 86400, // 1 day rolling: true, }, }; @@ -301,7 +301,7 @@ describe('ServiceLevelIndicatorSchema', () => { operator: 'gte', }, window: { - size: 3600, + durationSeconds: 3600, }, }); @@ -318,7 +318,7 @@ describe('ServiceLevelObjectiveSchema', () => { target: 99.9, period: { type: 'rolling', - duration: 2592000, // 30 days + durationSeconds: 2592000, // 30 days }, }; @@ -346,7 +346,7 @@ describe('ServiceLevelObjectiveSchema', () => { label: 'Error Budget SLO', sli: 'test_sli', target: 99.9, - period: { type: 'rolling', duration: 2592000 }, + period: { type: 'rolling', durationSeconds: 2592000 }, errorBudget: { enabled: true, alertThreshold: 75, @@ -366,7 +366,7 @@ describe('ServiceLevelObjectiveSchema', () => { label: 'Test SLO', sli: 'test_sli', target: 99, - period: { type: 'rolling', duration: 86400 }, + period: { type: 'rolling', durationSeconds: 86400 }, }); expect(slo.enabled).toBe(true); @@ -449,7 +449,7 @@ describe('MetricsConfigSchema', () => { threshold: 99.9, operator: 'gte', }, - window: { size: 2592000 }, + window: { durationSeconds: 2592000 }, }, ], slos: [ @@ -458,7 +458,7 @@ describe('MetricsConfigSchema', () => { label: 'API SLO', sli: 'api_availability', target: 99.9, - period: { type: 'rolling', duration: 2592000 }, + period: { type: 'rolling', durationSeconds: 2592000 }, }, ], exports: [ @@ -490,3 +490,79 @@ describe('MetricsConfigSchema', () => { })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. All three old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The new name is `durationSeconds` and NOT the +// gate's mechanical `sizeSeconds`: `size` is byte/row-count vocabulary elsewhere +// in this spec, and the parent key is already `window`, so `windowSeconds` would +// read `window.windowSeconds`. +describe('metrics window and period lengths carry their unit (#15679)', () => { + const sliBase = { + name: 'api_availability', + metric: 'http_requests_total', + type: 'availability' as const, + successCriteria: { threshold: 99.9, operator: 'gte' as const }, + }; + const sloBase = { name: 'api_uptime_slo', sli: 'api_availability', target: 99.9 }; + + it('REFUSES the retired `MetricAggregationConfig.window.size`', () => { + const result = MetricAggregationConfigSchema.safeParse({ + type: 'avg', + window: { size: 300 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'window.size'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('renamed to `durationSeconds`'); + expect(issue!.message).not.toContain('sizeSeconds'); + }); + + it('REFUSES the retired `ServiceLevelIndicator.window.size`', () => { + const result = ServiceLevelIndicatorSchema.safeParse({ ...sliBase, window: { size: 2592000 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'window.size'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('renamed to `durationSeconds`'); + }); + + it('REFUSES the retired `ServiceLevelObjective.period.duration`', () => { + const result = ServiceLevelObjectiveSchema.safeParse({ + ...sloBase, + period: { type: 'rolling', duration: 2592000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'period.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain( + '`ServiceLevelObjective.period.duration` was renamed to `durationSeconds`', + ); + }); + + it('accepts every renamed key at the same magnitude', () => { + expect(MetricAggregationConfigSchema.parse({ type: 'avg', window: { durationSeconds: 300 } }) + .window.durationSeconds).toBe(300); + expect(ServiceLevelIndicatorSchema.parse({ ...sliBase, window: { durationSeconds: 2592000 } }) + .window.durationSeconds).toBe(2592000); + expect(ServiceLevelObjectiveSchema.parse({ + ...sloBase, period: { type: 'rolling', durationSeconds: 2592000 }, + }).period.durationSeconds).toBe(2592000); + }); + + it('leaves the two non-duration keys on this file alone', () => { + // The exporter batch `size` is a COUNT of records, not a duration. + expect(MetricExportConfigSchema.parse({ type: 'prometheus', batch: { size: 500 } }) + .batch?.size).toBe(500); + // The error-budget burn-rate `window` names no unit in its describe, so it is + // outside the gate population entirely and keeps its bare name. + const slo = ServiceLevelObjectiveSchema.parse({ + ...sloBase, + period: { type: 'rolling', durationSeconds: 2592000 }, + errorBudget: { burnRateWindows: [{ window: 3600, threshold: 14.4 }] }, + }); + expect(slo.errorBudget?.burnRateWindows?.[0]?.window).toBe(3600); + }); +}); diff --git a/packages/spec/src/system/object-storage.test.ts b/packages/spec/src/system/object-storage.test.ts index 3d3216e9d4..8f3d016ee6 100644 --- a/packages/spec/src/system/object-storage.test.ts +++ b/packages/spec/src/system/object-storage.test.ts @@ -295,12 +295,12 @@ describe('AccessControlConfigSchema', () => { allowedMethods: ['GET', 'PUT', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'], exposeHeaders: ['ETag', 'Content-Length'], - maxAge: 3600, + maxAgeSeconds: 3600, }); expect(config.corsEnabled).toBe(true); expect(config.allowedOrigins).toHaveLength(2); - expect(config.maxAge).toBe(3600); + expect(config.maxAgeSeconds).toBe(3600); }); it('should accept public access configuration', () => { @@ -629,11 +629,11 @@ describe('StorageConnectionSchema', () => { const connection = StorageConnectionSchema.parse({ endpoint: 'https://custom.storage.example.com', useSSL: true, - timeout: 30000, + timeoutMs: 30000, }); expect(connection.useSSL).toBe(true); - expect(connection.timeout).toBe(30000); + expect(connection.timeoutMs).toBe(30000); }); it('should default useSSL to true', () => { @@ -848,3 +848,52 @@ describe('ObjectStorageConfigSchema', () => { expect(storage.enabled).toBe(false); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. +// +// ⚠️ `AccessControlConfig.maxAge` is a RENAME and its twin +// `shared/CorsConfig.maxAge` is a DECLARED `externalVocabulary` mirror that keeps +// its bare name. The asymmetry is deliberate: every bucket-CORS standard spells +// the field with its unit (S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, Azure +// `MaxAgeInSeconds`), while the Fetch header `Access-Control-Max-Age` that the +// twin mirrors carries no unit token. No gate can catch the twin being renamed +// along with this one — the marker exempts it either way — so a find-and-replace +// that harmonised the two would land silently. The pin below is the only guard. +describe('object-storage durations carry their unit (#15679)', () => { + it('REFUSES the retired `AccessControlConfig.maxAge` with the rename in the message', () => { + const result = AccessControlConfigSchema.safeParse({ corsEnabled: true, maxAge: 3600 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'maxAge'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`AccessControlConfig.maxAge` was renamed to `maxAgeSeconds`'); + }); + + it('REFUSES the retired `StorageConnection.timeout` with the rename in the message', () => { + const result = StorageConnectionSchema.safeParse({ endpoint: 'https://s.example.com', timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`StorageConnection.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts both renamed keys at the same magnitude', () => { + expect(AccessControlConfigSchema.parse({ corsEnabled: true, maxAgeSeconds: 3600 }).maxAgeSeconds).toBe(3600); + expect(StorageConnectionSchema.parse({ endpoint: 'https://s.example.com', timeoutMs: 30000 }).timeoutMs).toBe(30000); + }); + + it('leaves `FileMetadata.size` alone — it is bytes, not a duration', () => { + const parsed = FileMetadataSchema.parse({ + path: '/uploads/file.txt', + name: 'file.txt', + size: 1024, + mimeType: 'text/plain', + lastModified: '2024-01-15T10:30:00.000Z', + created: '2024-01-15T10:00:00.000Z', + }); + expect(parsed.size).toBe(1024); + }); +}); diff --git a/packages/spec/src/system/registry-config.test.ts b/packages/spec/src/system/registry-config.test.ts index e5b00e73e8..0b0ecdf287 100644 --- a/packages/spec/src/system/registry-config.test.ts +++ b/packages/spec/src/system/registry-config.test.ts @@ -27,14 +27,14 @@ describe('RegistryUpstreamSchema', () => { expect(upstream.url).toBe('https://registry.objectstack.com'); expect(upstream.syncPolicy).toBe('auto'); - expect(upstream.timeout).toBe(30000); + expect(upstream.timeoutMs).toBe(30000); }); it('should accept full upstream configuration', () => { const upstream = RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', syncPolicy: 'manual', - syncInterval: 300, + syncIntervalSeconds: 300, auth: { type: 'bearer', token: 'my-token', @@ -43,7 +43,7 @@ describe('RegistryUpstreamSchema', () => { enabled: true, verifyCertificate: false, }, - timeout: 60000, + timeoutMs: 60000, retry: { maxAttempts: 5, backoff: 'linear', @@ -51,10 +51,10 @@ describe('RegistryUpstreamSchema', () => { }); expect(upstream.syncPolicy).toBe('manual'); - expect(upstream.syncInterval).toBe(300); + expect(upstream.syncIntervalSeconds).toBe(300); expect(upstream.auth?.type).toBe('bearer'); expect(upstream.tls?.verifyCertificate).toBe(false); - expect(upstream.timeout).toBe(60000); + expect(upstream.timeoutMs).toBe(60000); expect(upstream.retry?.maxAttempts).toBe(5); }); @@ -86,20 +86,20 @@ describe('RegistryUpstreamSchema', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'not-a-url' })).toThrow(); }); - it('should reject syncInterval below minimum', () => { + it('should reject syncIntervalSeconds below minimum', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', - syncInterval: 10, + syncIntervalSeconds: 10, }), ).toThrow(); }); - it('should reject timeout below minimum', () => { + it('should reject timeoutMs below minimum', () => { expect(() => RegistryUpstreamSchema.parse({ url: 'https://registry.example.com', - timeout: 500, + timeoutMs: 500, }), ).toThrow(); }); @@ -147,7 +147,7 @@ describe('RegistryConfigSchema', () => { }, cache: { enabled: true, - ttl: 7200, + ttlSeconds: 7200, maxSize: 1073741824, }, mirrors: [ @@ -161,7 +161,7 @@ describe('RegistryConfigSchema', () => { expect(config.storage?.backend).toBe('s3'); expect(config.visibility).toBe('internal'); expect(config.accessControl?.requireAuthForRead).toBe(true); - expect(config.cache?.ttl).toBe(7200); + expect(config.cache?.ttlSeconds).toBe(7200); expect(config.mirrors).toHaveLength(2); }); @@ -204,3 +204,51 @@ describe('RegistryConfigSchema', () => { expect(() => RegistryConfigSchema.parse({ type: 'distributed' })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. All three old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. `RegistryUpstream` is the sharpest case in this +// card: it declared a SECONDS interval and a MILLISECONDS timeout twenty-five +// lines apart, both bare, and the `min(1000)` bound on the timeout reads as one +// second under the right unit and sixteen minutes under the wrong one — both in +// range, so no parse could have caught the mistake. +describe('registry duration keys carry their unit (#15679)', () => { + const url = 'https://registry.example.com'; + + it('REFUSES the retired `syncInterval` with the rename in the message', () => { + const result = RegistryUpstreamSchema.safeParse({ url, syncInterval: 300 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'syncInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds`'); + }); + + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = RegistryUpstreamSchema.safeParse({ url, timeout: 60000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryUpstream.timeout` was renamed to `timeoutMs`'); + }); + + it('REFUSES the retired `cache.ttl` with the rename in the message', () => { + const result = RegistryConfigSchema.safeParse({ type: 'private', cache: { enabled: true, ttl: 7200 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cache.ttl'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`RegistryConfig.cache.ttl` was renamed to `ttlSeconds`'); + }); + + it('accepts the renamed keys and keeps every default and bound', () => { + const upstream = RegistryUpstreamSchema.parse({ url, syncIntervalSeconds: 300, timeoutMs: 60000 }); + expect(upstream.syncIntervalSeconds).toBe(300); + expect(upstream.timeoutMs).toBe(60000); + expect(RegistryUpstreamSchema.parse({ url }).timeoutMs).toBe(30000); + expect(RegistryUpstreamSchema.safeParse({ url, syncIntervalSeconds: 10 }).success).toBe(false); + expect(RegistryUpstreamSchema.safeParse({ url, timeoutMs: 500 }).success).toBe(false); + expect(RegistryConfigSchema.parse({ type: 'private', cache: { enabled: true } }).cache?.ttlSeconds).toBe(3600); + }); +}); diff --git a/packages/spec/src/system/tracing.test.ts b/packages/spec/src/system/tracing.test.ts index 91edc71295..fcaef85d34 100644 --- a/packages/spec/src/system/tracing.test.ts +++ b/packages/spec/src/system/tracing.test.ts @@ -183,7 +183,7 @@ describe('SpanSchema', () => { kind: 'server', startTime: '2024-01-15T10:30:00.000Z', endTime: '2024-01-15T10:30:00.150Z', - duration: 150, + durationMs: 150, status: { code: 'ok', }, @@ -515,3 +515,39 @@ describe('TracingConfigSchema', () => { })).toThrow(); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. A span is runtime-emitted, so the silent-strip +// alternative is the real hazard here: an exporter still writing `duration` +// would have lost the measurement without any error at all. +describe('Span.duration carries its unit (#15679)', () => { + const base = { + context: { traceId: '0123456789abcdef0123456789abcdef', spanId: '0123456789abcdef' }, + name: 'GET /api/users', + startTime: '2024-01-15T10:30:00.000Z', + }; + + it('REFUSES the retired `duration` with the rename in the message', () => { + const result = SpanSchema.safeParse({ ...base, duration: 150 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`Span.duration` was renamed to `durationMs`'); + }); + + it('accepts `durationMs` at the same magnitude and still refuses a negative one', () => { + expect(SpanSchema.parse({ ...base, durationMs: 150 }).durationMs).toBe(150); + expect(SpanSchema.safeParse({ ...base, durationMs: -1 }).success).toBe(false); + }); + + it('leaves the exporter `timeout` alone — its describe names no unit, so it is outside the population', () => { + const config = TracingConfigSchema.parse({ + serviceName: 'test', + exporter: { type: 'console' }, + resource: { serviceName: 'test' }, + }); + expect(config.exporter.timeout).toBe(10000); + }); +}); diff --git a/packages/spec/src/system/worker.test.ts b/packages/spec/src/system/worker.test.ts index 3f2513fd59..dff2a12551 100644 --- a/packages/spec/src/system/worker.test.ts +++ b/packages/spec/src/system/worker.test.ts @@ -266,13 +266,13 @@ describe('QueueConfigSchema', () => { concurrency: 10, rateLimit: { max: 100, - duration: 60000, + durationMs: 60000, }, }; const parsed = QueueConfigSchema.parse(config); expect(parsed.rateLimit?.max).toBe(100); - expect(parsed.rateLimit?.duration).toBe(60000); + expect(parsed.rateLimit?.durationMs).toBe(60000); }); it('should accept queue with auto-scaling', () => { @@ -550,3 +550,33 @@ describe('Worker Integration', () => { expect(sorted[4].priority).toBe('background'); }); }); + +// #15679 (stack card 4/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The counter-example was already in this file: +// `TaskExecutionResult.durationMs` spelled the identical measurement correctly +// ninety lines up, which is what made the bare `rateLimit.duration` a drift +// rather than a convention. A silent strip here would have left a queue +// unthrottled with no error. +describe('QueueConfig.rateLimit.duration carries its unit (#15679)', () => { + it('REFUSES the retired `rateLimit.duration` with the rename in the message', () => { + const result = QueueConfigSchema.safeParse({ + name: 'rate_limited_queue', + rateLimit: { max: 100, duration: 60000 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'rateLimit.duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`QueueConfig.rateLimit.duration` was renamed to `durationMs`'); + }); + + it('accepts `rateLimit.durationMs` at the same magnitude; `max` stays a bare COUNT', () => { + const parsed = QueueConfigSchema.parse({ + name: 'rate_limited_queue', + rateLimit: { max: 100, durationMs: 60000 }, + }); + expect(parsed.rateLimit?.durationMs).toBe(60000); + expect(parsed.rateLimit?.max).toBe(100); + }); +}); From dc67a943eb436fe0d3f417b82fbd0f4cbab07792 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:48:05 +0000 Subject: [PATCH 20/33] wip(spec): regenerated artifacts and reference pages for the system/ renames (#15679) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/system/cache.mdx | 20 +++++++++------- .../docs/references/system/collaboration.mdx | 13 ++++++---- .../references/system/disaster-recovery.mdx | 8 ++++--- content/docs/references/system/metrics.mdx | 21 +++++++++------- .../docs/references/system/object-storage.mdx | 12 ++++++---- .../references/system/registry-config.mdx | 19 +++++++++------ content/docs/references/system/tracing.mdx | 3 ++- content/docs/references/system/worker.mdx | 5 ++-- packages/spec/authorable-defaults/system.json | 8 +++---- packages/spec/authorable-surface/system.json | 24 ++++++++++++------- packages/spec/src/system/metrics.test.ts | 2 +- packages/spec/src/system/tracing.test.ts | 5 ++-- 12 files changed, 85 insertions(+), 55 deletions(-) diff --git a/content/docs/references/system/cache.mdx b/content/docs/references/system/cache.mdx index f2ae75b68b..942758dceb 100644 --- a/content/docs/references/system/cache.mdx +++ b/content/docs/references/system/cache.mdx @@ -53,7 +53,7 @@ Cache avalanche/stampede prevention configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **jitterTtl** | `{ enabled: boolean; maxJitterSeconds: number }` | optional | TTL jitter to prevent simultaneous expiration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeout: number }` | optional | Circuit breaker for backend protection | +| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutSeconds: number }` | optional | Circuit breaker for backend protection | | **lockout** | `{ enabled: boolean; lockTimeoutMs: number }` | optional | Lock-based stampede prevention | ### Nested Shape: `CacheAvalanchePrevention.jitterTtl` @@ -69,7 +69,8 @@ Cache avalanche/stampede prevention configuration | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable circuit breaker for backend protection | | **failureThreshold** | `number` | optional (default: `5`) | Failures before circuit opens | -| **resetTimeout** | `number` | optional (default: `30`) | Seconds before half-open state | +| **resetTimeoutSeconds** | `number` | optional (default: `30`) | Seconds before half-open state | +| **resetTimeout** | `never` | optional | [REMOVED] `CacheAvalanchePrevention.circuitBreaker.resetTimeout` was renamed to `resetTimeoutSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `lockout` block on this same schema already spells `lockTimeoutMs`. Rename the key to `resetTimeoutSeconds`; the value (seconds) and the 30 default are unchanged. Note the two are NOT the same unit: this one is seconds, `lockTimeoutMs` is milliseconds. | ### Nested Shape: `CacheAvalanchePrevention.lockout` @@ -90,7 +91,7 @@ Top-level application cache configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable application-level caching | -| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttl: number; … }[]` | ✅ | Ordered cache tier hierarchy | +| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds: number; … }[]` | ✅ | Ordered cache tier hierarchy | | **invalidation** | `{ trigger: Enum<'create' \| 'update' \| 'delete' \| 'manual'>; scope: Enum<'key' \| 'pattern' \| 'tag' \| 'all'>; pattern?: string; tags?: string[] }[]` | ✅ | Cache invalidation rules | | **prefetch** | `boolean` | optional (default: `false`) | Enable cache prefetching | | **compression** | `boolean` | optional (default: `false`) | Enable data compression in cache | @@ -105,7 +106,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -178,7 +180,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -211,7 +214,7 @@ Distributed cache configuration with consistency and avalanche prevention | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `false`) | Enable application-level caching | -| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttl?: number; … }[]` | ✅ | Ordered cache tier hierarchy | +| **tiers** | `{ name: string; type: Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>; maxSize?: number; ttlSeconds?: number; … }[]` | ✅ | Ordered cache tier hierarchy | | **invalidation** | `{ trigger: Enum<'create' \| 'update' \| 'delete' \| 'manual'>; scope: Enum<'key' \| 'pattern' \| 'tag' \| 'all'>; pattern?: string; tags?: string[] }[]` | ✅ | Cache invalidation rules | | **prefetch** | `boolean` | optional (default: `false`) | Enable cache prefetching | | **compression** | `boolean` | optional (default: `false`) | Enable data compression in cache | @@ -229,7 +232,8 @@ Configuration for a single cache tier in the hierarchy | **name** | `string` | ✅ | Unique cache tier name | | **type** | `Enum<'memory' \| 'redis' \| 'memcached' \| 'cdn'>` | ✅ | Cache backend type | | **maxSize** | `number` | optional | Max size in MB | -| **ttl** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttlSeconds** | `number` | optional (default: `300`) | Default TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `CacheTier.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` on this same tier is a size in MB. Rename the key to `ttlSeconds`; the value (seconds) and the 300 default are unchanged. | | **strategy** | `Enum<'lru' \| 'lfu' \| 'fifo' \| 'ttl'>` | optional (default: `"lru"`) | Eviction strategy | | **warmup** | `boolean` | optional (default: `false`) | Pre-populate cache on startup | @@ -249,7 +253,7 @@ Rule defining when and how cached entries are invalidated | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **jitterTtl** | `{ enabled?: boolean; maxJitterSeconds?: number }` | optional | TTL jitter to prevent simultaneous expiration | -| **circuitBreaker** | `{ enabled?: boolean; failureThreshold?: number; resetTimeout?: number }` | optional | Circuit breaker for backend protection | +| **circuitBreaker** | `{ enabled?: boolean; failureThreshold?: number; resetTimeoutSeconds?: number }` | optional | Circuit breaker for backend protection | | **lockout** | `{ enabled?: boolean; lockTimeoutMs?: number }` | optional | Lock-based stampede prevention | ### Nested Shape: `DistributedCacheConfig.warmup` diff --git a/content/docs/references/system/collaboration.mdx b/content/docs/references/system/collaboration.mdx index bb2292e4aa..bc72ef2ddf 100644 --- a/content/docs/references/system/collaboration.mdx +++ b/content/docs/references/system/collaboration.mdx @@ -346,10 +346,11 @@ This schema accepts one of the following structures: | **enablePresence** | `boolean` | optional (default: `true`) | Enable presence tracking | | **enableAwareness** | `boolean` | optional (default: `true`) | Enable awareness state | | **maxUsers** | `integer` | optional | Maximum concurrent users | -| **idleTimeout** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeoutMs** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeout** | `never` | optional | [REMOVED] `CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one was the live 1000x collision the rule exists to remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare name meant five minutes here and three and a half days there. Rename the key to `idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged. | | **conflictResolution** | `Enum<'ot' \| 'crdt' \| 'manual'>` | optional (default: `"ot"`) | Conflict resolution strategy | | **persistence** | `boolean` | optional (default: `true`) | Enable operation persistence | -| **snapshot** | `{ enabled: boolean; interval: integer }` | optional | Snapshot configuration | +| **snapshot** | `{ enabled: boolean; intervalMs: integer }` | optional | Snapshot configuration | ### Nested Shape: `CollaborationSession.users[number]` @@ -421,17 +422,19 @@ This schema accepts one of the following structures: | **enablePresence** | `boolean` | optional (default: `true`) | Enable presence tracking | | **enableAwareness** | `boolean` | optional (default: `true`) | Enable awareness state | | **maxUsers** | `integer` | optional | Maximum concurrent users | -| **idleTimeout** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeoutMs** | `integer` | optional (default: `300000`) | Idle timeout in milliseconds | +| **idleTimeout** | `never` | optional | [REMOVED] `CollaborationSessionConfig.idleTimeout` was renamed to `idleTimeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. This one was the live 1000x collision the rule exists to remove: the tenant surface carried its own `idleTimeout` in SECONDS, so the same bare name meant five minutes here and three and a half days there. Rename the key to `idleTimeoutMs`; the value (milliseconds) and the 300000 default are unchanged. | | **conflictResolution** | `Enum<'ot' \| 'crdt' \| 'manual'>` | optional (default: `"ot"`) | Conflict resolution strategy | | **persistence** | `boolean` | optional (default: `true`) | Enable operation persistence | -| **snapshot** | `{ enabled: boolean; interval: integer }` | optional | Snapshot configuration | +| **snapshot** | `{ enabled: boolean; intervalMs: integer }` | optional | Snapshot configuration | ### Nested Shape: `CollaborationSessionConfig.snapshot` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable periodic snapshots | -| **interval** | `integer` | ✅ | Snapshot interval in milliseconds | +| **intervalMs** | `integer` | ✅ | Snapshot interval in milliseconds | +| **interval** | `never` | optional | [REMOVED] `CollaborationSessionConfig.snapshot.interval` was renamed to `intervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalMs`; the value (milliseconds) is unchanged. | --- diff --git a/content/docs/references/system/disaster-recovery.mdx b/content/docs/references/system/disaster-recovery.mdx index c3799794d0..188e7ba3bc 100644 --- a/content/docs/references/system/disaster-recovery.mdx +++ b/content/docs/references/system/disaster-recovery.mdx @@ -112,7 +112,7 @@ Complete disaster recovery plan configuration | **rpo** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Point Objective | | **rto** | `{ value: number; unit?: Enum<'seconds' \| 'minutes' \| 'hours'> }` | ✅ | Recovery Time Objective | | **backup** | `{ strategy?: Enum<'full' \| 'incremental' \| 'differential'>; schedule?: string \| object; retention: object; destination: object; … }` | ✅ | Backup configuration | -| **failover** | `{ mode?: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover?: boolean; healthCheckInterval?: number; failureThreshold?: number; … }` | optional | Multi-region failover configuration | +| **failover** | `{ mode?: Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>; autoFailover?: boolean; healthCheckIntervalSeconds?: number; failureThreshold?: number; … }` | optional | Multi-region failover configuration | | **replication** | `{ mode?: Enum<'synchronous' \| 'asynchronous' \| 'semi_synchronous'>; maxLagSeconds?: number; includeObjects?: string[]; excludeObjects?: string[] }` | optional | Data replication settings | | **testing** | `{ enabled?: boolean; schedule?: string \| object; notificationChannel?: string }` | optional | Automated disaster recovery testing | | **runbookUrl** | `string` | optional | URL to disaster recovery runbook/playbook | @@ -150,7 +150,8 @@ Complete disaster recovery plan configuration | :--- | :--- | :--- | :--- | | **mode** | `Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>` | optional (default: `"active_passive"`) | Failover mode | | **autoFailover** | `boolean` | optional (default: `true`) | Enable automatic failover | -| **healthCheckInterval** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckIntervalSeconds** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckInterval** | `never` | optional | [REMOVED] `FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the value (seconds) and the 30 default are unchanged. | | **failureThreshold** | `number` | optional (default: `3`) | Consecutive failures before failover | | **regions** | `{ name: string; role: Enum<'primary' \| 'secondary' \| 'witness'>; endpoint?: string; priority?: number }[]` | ✅ | Multi-region configuration (minimum 2 regions) | | **dns** | `{ ttl?: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | @@ -194,7 +195,8 @@ Failover configuration | :--- | :--- | :--- | :--- | | **mode** | `Enum<'active_passive' \| 'active_active' \| 'pilot_light' \| 'warm_standby'>` | optional (default: `"active_passive"`) | Failover mode | | **autoFailover** | `boolean` | optional (default: `true`) | Enable automatic failover | -| **healthCheckInterval** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckIntervalSeconds** | `number` | optional (default: `30`) | Health check interval in seconds | +| **healthCheckInterval** | `never` | optional | [REMOVED] `FailoverConfig.healthCheckInterval` was renamed to `healthCheckIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `healthCheckIntervalSeconds`; the value (seconds) and the 30 default are unchanged. | | **failureThreshold** | `number` | optional (default: `3`) | Consecutive failures before failover | | **regions** | `{ name: string; role: Enum<'primary' \| 'secondary' \| 'witness'>; endpoint?: string; priority?: number }[]` | ✅ | Multi-region configuration (minimum 2 regions) | | **dns** | `{ ttl: number; provider?: Enum<'route53' \| 'cloudflare' \| 'azure_dns' \| 'custom'> }` | optional | DNS failover settings | diff --git a/content/docs/references/system/metrics.mdx b/content/docs/references/system/metrics.mdx index 7632ab036e..d1b63b6d4c 100644 --- a/content/docs/references/system/metrics.mdx +++ b/content/docs/references/system/metrics.mdx @@ -77,7 +77,7 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| 'p999' \| 'rate' \| 'stddev'>` | ✅ | Aggregation type | -| **window** | `{ size: integer; sliding: boolean; slideInterval?: integer }` | optional | | +| **window** | `{ durationSeconds: integer; sliding: boolean; slideInterval?: integer }` | optional | | | **groupBy** | `string[]` | optional | Group by label names | | **filters** | `Record` | optional | Filter criteria | @@ -85,7 +85,8 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **size** | `integer` | ✅ | Window size in seconds | +| **durationSeconds** | `integer` | ✅ | Window duration in seconds | +| **size** | `never` | optional | [REMOVED] The aggregation/SLI window key `size` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or row count everywhere else in this spec, so the rename drops it rather than bolting a unit onto it. Rename `window.size` to `window.durationSeconds` on both MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged. | | **sliding** | `boolean` | optional (default: `false`) | | | **slideInterval** | `integer` | optional | | @@ -328,7 +329,7 @@ Metric aggregation configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'sum' \| 'avg' \| 'min' \| 'max' \| 'count' \| 'p50' \| 'p75' \| 'p90' \| 'p95' \| 'p99' \| …>` | ✅ | Aggregation type | -| **window** | `{ size: integer; sliding?: boolean; slideInterval?: integer }` | optional | | +| **window** | `{ durationSeconds: integer; sliding?: boolean; slideInterval?: integer }` | optional | | | **groupBy** | `string[]` | optional | Group by label names | | **filters** | `Record` | optional | Filter criteria | @@ -344,7 +345,7 @@ Service Level Indicator | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | | **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ size: integer; rolling?: boolean }` | ✅ | Measurement window | +| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `MetricsConfig.slos[number]` @@ -358,7 +359,7 @@ Service Level Objective | **description** | `string` | optional | SLO description | | **sli** | `string` | ✅ | SLI name | | **target** | `number` | ✅ | Target percentage | -| **period** | `{ type: Enum<'rolling' \| 'calendar'>; duration?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | +| **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | | **errorBudget** | `{ enabled?: boolean; alertThreshold?: number; burnRateWindows?: object[] }` | optional | | | **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | | | **enabled** | `boolean` | optional (default: `true`) | | @@ -393,7 +394,7 @@ Service Level Indicator | **metric** | `string` | ✅ | Base metric name | | **type** | `Enum<'availability' \| 'latency' \| 'throughput' \| 'error_rate' \| 'saturation' \| 'custom'>` | ✅ | SLI type | | **successCriteria** | `{ threshold: number; operator: Enum<'lt' \| 'lte' \| 'gt' \| 'gte' \| 'eq'>; percentile?: number } \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Success criteria — structured or CEL predicate | -| **window** | `{ size: integer; rolling?: boolean }` | ✅ | Measurement window | +| **window** | `{ durationSeconds: integer; rolling?: boolean }` | ✅ | Measurement window | | **enabled** | `boolean` | optional (default: `true`) | | ### Nested Shape: `ServiceLevelIndicator.successCriteria[option 1]` @@ -408,7 +409,8 @@ Service Level Indicator | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **size** | `integer` | ✅ | Window size in seconds | +| **durationSeconds** | `integer` | ✅ | Window duration in seconds | +| **size** | `never` | optional | [REMOVED] The aggregation/SLI window key `size` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. The new name is not `sizeSeconds`: `size` means a byte or row count everywhere else in this spec, so the rename drops it rather than bolting a unit onto it. Rename `window.size` to `window.durationSeconds` on both MetricAggregationConfig and ServiceLevelIndicator; the value (seconds) is unchanged. | | **rolling** | `boolean` | optional (default: `true`) | | @@ -427,7 +429,7 @@ Service Level Objective | **description** | `string` | optional | SLO description | | **sli** | `string` | ✅ | SLI name | | **target** | `number` | ✅ | Target percentage | -| **period** | `{ type: Enum<'rolling' \| 'calendar'>; duration?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | +| **period** | `{ type: Enum<'rolling' \| 'calendar'>; durationSeconds?: integer; calendar?: Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'> }` | ✅ | Time period | | **errorBudget** | `{ enabled: boolean; alertThreshold: number; burnRateWindows?: object[] }` | optional | | | **alerts** | `{ name: string; severity: Enum<'info' \| 'warning' \| 'critical'>; condition: object }[]` | optional (default: `[]`) | | | **enabled** | `boolean` | optional (default: `true`) | | @@ -437,7 +439,8 @@ Service Level Objective | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'rolling' \| 'calendar'>` | ✅ | Period type | -| **duration** | `integer` | optional | Duration in seconds | +| **durationSeconds** | `integer` | optional | Duration in seconds | +| **duration** | `never` | optional | [REMOVED] `ServiceLevelObjective.period.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged. | | **calendar** | `Enum<'daily' \| 'weekly' \| 'monthly' \| 'quarterly' \| 'yearly'>` | optional | | ### Nested Shape: `ServiceLevelObjective.alerts[number]` diff --git a/content/docs/references/system/object-storage.mdx b/content/docs/references/system/object-storage.mdx index 9e67cf3e5d..bacdc8e6c4 100644 --- a/content/docs/references/system/object-storage.mdx +++ b/content/docs/references/system/object-storage.mdx @@ -44,7 +44,8 @@ const result = AccessControlConfigSchema.parse(data); | **allowedMethods** | `Enum<'GET' \| 'PUT' \| 'POST' \| 'DELETE' \| 'HEAD'>[]` | optional | CORS allowed HTTP methods | | **allowedHeaders** | `string[]` | optional | CORS allowed headers | | **exposeHeaders** | `string[]` | optional | CORS exposed headers | -| **maxAge** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAgeSeconds** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAge** | `never` | optional | [REMOVED] `AccessControlConfig.maxAge` was renamed to `maxAgeSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Every bucket-CORS standard this value is forwarded to already spells the unit (S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds), so the bare name was a deviation from them rather than a mirror of them. Rename the key to `maxAgeSeconds`; the value (seconds) is unchanged. The unrelated `CorsConfig.maxAge` on the shared HTTP surface keeps its name — that one mirrors the Access-Control-Max-Age response header, which carries no unit token. | | **corsEnabled** | `boolean` | optional (default: `false`) | Enable CORS configuration | | **publicAccess** | `{ allowPublicRead: boolean; allowPublicWrite: boolean; allowPublicList: boolean }` | optional | Public access control | | **allowedIps** | `string[]` | optional | Allowed IP addresses/CIDR blocks | @@ -100,7 +101,8 @@ const result = AccessControlConfigSchema.parse(data); | **allowedMethods** | `Enum<'GET' \| 'PUT' \| 'POST' \| 'DELETE' \| 'HEAD'>[]` | optional | CORS allowed HTTP methods | | **allowedHeaders** | `string[]` | optional | CORS allowed headers | | **exposeHeaders** | `string[]` | optional | CORS exposed headers | -| **maxAge** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAgeSeconds** | `number` | optional | CORS preflight cache duration in seconds | +| **maxAge** | `never` | optional | [REMOVED] `AccessControlConfig.maxAge` was renamed to `maxAgeSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Every bucket-CORS standard this value is forwarded to already spells the unit (S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds), so the bare name was a deviation from them rather than a mirror of them. Rename the key to `maxAgeSeconds`; the value (seconds) is unchanged. The unrelated `CorsConfig.maxAge` on the shared HTTP surface keeps its name — that one mirrors the Access-Control-Max-Age response header, which carries no unit token. | | **corsEnabled** | `boolean` | optional (default: `false`) | Enable CORS configuration | | **publicAccess** | `{ allowPublicRead: boolean; allowPublicWrite: boolean; allowPublicList: boolean }` | optional | Public access control | | **allowedIps** | `string[]` | optional | Allowed IP addresses/CIDR blocks | @@ -280,7 +282,8 @@ Lifecycle policy action type | **endpoint** | `string` | optional | Custom endpoint URL | | **region** | `string` | optional | Default region | | **useSSL** | `boolean` | optional (default: `true`) | Use SSL/TLS for connections | -| **timeout** | `number` | optional | Connection timeout in milliseconds | +| **timeoutMs** | `number` | optional | Connection timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `StorageConnection.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `ObjectStorageConfig.buckets[number]` @@ -369,7 +372,8 @@ Storage class/tier for cost optimization | **endpoint** | `string` | optional | Custom endpoint URL | | **region** | `string` | optional | Default region | | **useSSL** | `boolean` | optional (default: `true`) | Use SSL/TLS for connections | -| **timeout** | `number` | optional | Connection timeout in milliseconds | +| **timeoutMs** | `number` | optional | Connection timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `StorageConnection.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | --- diff --git a/content/docs/references/system/registry-config.mdx b/content/docs/references/system/registry-config.mdx index 6d1d28bc3b..5168650457 100644 --- a/content/docs/references/system/registry-config.mdx +++ b/content/docs/references/system/registry-config.mdx @@ -33,13 +33,13 @@ const result = RegistryConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `Enum<'public' \| 'private' \| 'hybrid'>` | ✅ | Registry deployment type | -| **upstream** | `{ url: string; syncPolicy: Enum<'manual' \| 'auto' \| 'proxy'>; syncInterval?: integer; auth?: object; … }[]` | optional | Upstream registries to sync from or proxy to | +| **upstream** | `{ url: string; syncPolicy: Enum<'manual' \| 'auto' \| 'proxy'>; syncIntervalSeconds?: integer; auth?: object; … }[]` | optional | Upstream registries to sync from or proxy to | | **scope** | `string[]` | optional | npm-style scopes managed by this registry (e.g., @my-corp, @enterprise) | | **defaultScope** | `string` | optional | Default scope prefix for new plugins | | **storage** | `{ backend: Enum<'local' \| 's3' \| 'gcs' \| 'azure-blob' \| 'oss'>; path?: string; credentials?: Record }` | optional | | | **visibility** | `Enum<'public' \| 'private' \| 'internal'>` | optional (default: `"private"`) | Who can access this registry | | **accessControl** | `{ requireAuthForRead: boolean; requireAuthForWrite: boolean; allowedPrincipals?: string[] }` | optional | | -| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer }` | optional | | +| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer }` | optional | | | **mirrors** | `{ url: string; priority: integer }[]` | optional | Mirror registries for redundancy | ### Nested Shape: `RegistryConfig.upstream[number]` @@ -48,10 +48,12 @@ const result = RegistryConfigSchema.parse(data); | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | Upstream registry endpoint | | **syncPolicy** | `Enum<'manual' \| 'auto' \| 'proxy'>` | optional (default: `"auto"`) | Registry synchronization strategy | -| **syncInterval** | `integer` | optional | Auto-sync interval in seconds | +| **syncIntervalSeconds** | `integer` | optional | Auto-sync interval in seconds | +| **syncInterval** | `never` | optional | [REMOVED] `RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the `timeout` beside it on this same block is milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the min-60 bound are unchanged. | | **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api-key' \| 'oauth2'>; username?: string; password?: string; token?: string; … }` | optional | | | **tls** | `{ enabled: boolean; verifyCertificate: boolean; certificate?: string; privateKey?: string }` | optional | | -| **timeout** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `syncIntervalSeconds` on this same block is seconds. Rename the key to `timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are unchanged. | | **retry** | `{ maxAttempts: integer; backoff: Enum<'fixed' \| 'linear' \| 'exponential'> }` | optional | | ### Nested Shape: `RegistryConfig.cache` @@ -59,7 +61,8 @@ const result = RegistryConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | | -| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `RegistryConfig.cache.ttl` was renamed to `ttlSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the sibling `maxSize` in this same cache block is bytes. Rename the key to `ttlSeconds`; the value (seconds) and the 3600 default are unchanged. | | **maxSize** | `integer` | optional | Maximum cache size in bytes | @@ -86,10 +89,12 @@ Registry synchronization strategy | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | Upstream registry endpoint | | **syncPolicy** | `Enum<'manual' \| 'auto' \| 'proxy'>` | optional (default: `"auto"`) | Registry synchronization strategy | -| **syncInterval** | `integer` | optional | Auto-sync interval in seconds | +| **syncIntervalSeconds** | `integer` | optional | Auto-sync interval in seconds | +| **syncInterval** | `never` | optional | [REMOVED] `RegistryUpstream.syncInterval` was renamed to `syncIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and the `timeout` beside it on this same block is milliseconds. Rename the key to `syncIntervalSeconds`; the value (seconds) and the min-60 bound are unchanged. | | **auth** | `{ type: Enum<'none' \| 'basic' \| 'bearer' \| 'api-key' \| 'oauth2'>; username?: string; password?: string; token?: string; … }` | optional | | | **tls** | `{ enabled: boolean; verifyCertificate: boolean; certificate?: string; privateKey?: string }` | optional | | -| **timeout** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `RegistryUpstream.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `syncIntervalSeconds` on this same block is seconds. Rename the key to `timeoutMs`; the value (milliseconds), the 30000 default and the min-1000 bound are unchanged. | | **retry** | `{ maxAttempts: integer; backoff: Enum<'fixed' \| 'linear' \| 'exponential'> }` | optional | | diff --git a/content/docs/references/system/tracing.mdx b/content/docs/references/system/tracing.mdx index bcbae79b68..7061571d30 100644 --- a/content/docs/references/system/tracing.mdx +++ b/content/docs/references/system/tracing.mdx @@ -142,7 +142,8 @@ OpenTelemetry span | **kind** | `Enum<'internal' \| 'server' \| 'client' \| 'producer' \| 'consumer'>` | optional (default: `"internal"`) | Span kind | | **startTime** | `string` | ✅ | Span start time | | **endTime** | `string` | optional | Span end time | -| **duration** | `number` | optional | Duration in milliseconds | +| **durationMs** | `number` | optional | Duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `Span.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | | **status** | `{ code: Enum<'unset' \| 'ok' \| 'error'>; message?: string }` | optional | | | **attributes** | `Record` | optional (default: `{}`) | Span attributes | | **events** | `{ name: string; timestamp: string; attributes?: Record }[]` | optional (default: `[]`) | | diff --git a/content/docs/references/system/worker.mdx b/content/docs/references/system/worker.mdx index 1bf7cd1c0d..796b25cfad 100644 --- a/content/docs/references/system/worker.mdx +++ b/content/docs/references/system/worker.mdx @@ -75,7 +75,7 @@ const result = BatchProgressSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Queue name (snake_case) | | **concurrency** | `integer` | optional (default: `5`) | Max concurrent task executions | -| **rateLimit** | `{ max: integer; duration: integer }` | optional | Rate limit configuration | +| **rateLimit** | `{ max: integer; durationMs: integer }` | optional | Rate limit configuration | | **defaultRetryPolicy** | `{ maxRetries: integer; backoffStrategy: Enum<'fixed' \| 'linear' \| 'exponential'>; initialDelayMs: integer; maxDelayMs: integer; … }` | optional | Default retry policy for tasks | | **deadLetterQueue** | `string` | optional | Dead letter queue name | | **priority** | `integer` | optional (default: `0`) | Queue priority (lower = higher priority) | @@ -86,7 +86,8 @@ const result = BatchProgressSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **max** | `integer` | ✅ | Maximum tasks per duration | -| **duration** | `integer` | ✅ | Duration in milliseconds | +| **durationMs** | `integer` | ✅ | Duration in milliseconds | +| **duration** | `never` | optional | [REMOVED] `QueueConfig.rateLimit.duration` was renamed to `durationMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose, and `TaskResult.durationMs` on this same file already spelled it that way. Rename the key to `durationMs`; the value (milliseconds) is unchanged. | ### Nested Shape: `QueueConfig.defaultRetryPolicy` diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 5ab5ff1c11..e1140c81d4 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -39,7 +39,7 @@ "system/CacheConfig:encryption = false", "system/CacheConfig:prefetch = false", "system/CacheTier:strategy = \"lru\"", - "system/CacheTier:ttl = 300", + "system/CacheTier:ttlSeconds = 300", "system/CacheTier:warmup = false", "system/CacheWarmup:concurrency = 10", "system/CacheWarmup:enabled = false", @@ -48,7 +48,7 @@ "system/CollaborationSessionConfig:enableAwareness = true", "system/CollaborationSessionConfig:enableCursorSharing = true", "system/CollaborationSessionConfig:enablePresence = true", - "system/CollaborationSessionConfig:idleTimeout = 300000", + "system/CollaborationSessionConfig:idleTimeoutMs = 300000", "system/CollaborationSessionConfig:persistence = true", "system/CollaborativeCursor:isTyping = false", "system/ComplianceAuditRequirement:alertOnMissing = true", @@ -100,7 +100,7 @@ "system/FacetConfig:sort = \"count\"", "system/FailoverConfig:autoFailover = true", "system/FailoverConfig:failureThreshold = 3", - "system/FailoverConfig:healthCheckInterval = 30", + "system/FailoverConfig:healthCheckIntervalSeconds = 30", "system/FailoverConfig:mode = \"active_passive\"", "system/Feature:type = \"boolean\"", "system/FieldEncryption:indexable = false", @@ -187,7 +187,7 @@ "system/RTO:unit = \"minutes\"", "system/RegistryConfig:visibility = \"private\"", "system/RegistryUpstream:syncPolicy = \"auto\"", - "system/RegistryUpstream:timeout = 30000", + "system/RegistryUpstream:timeoutMs = 30000", "system/RetryPolicy:backoffMs = 1000", "system/RetryPolicy:backoffMultiplier = 1", "system/RetryPolicy:jitter = false", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 82dd537b92..963f4e83f6 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -10,7 +10,8 @@ "system/AccessControlConfig:blockedIps", "system/AccessControlConfig:corsEnabled", "system/AccessControlConfig:exposeHeaders", - "system/AccessControlConfig:maxAge", + "system/AccessControlConfig:maxAge [RETIRED]", + "system/AccessControlConfig:maxAgeSeconds", "system/AccessControlConfig:publicAccess", "system/ActionResultDialogTranslation:acknowledge", "system/ActionResultDialogTranslation:description", @@ -190,7 +191,8 @@ "system/CacheTier:maxSize", "system/CacheTier:name", "system/CacheTier:strategy", - "system/CacheTier:ttl", + "system/CacheTier:ttl [RETIRED]", + "system/CacheTier:ttlSeconds", "system/CacheTier:type", "system/CacheTier:warmup", "system/CacheWarmup:concurrency", @@ -240,7 +242,8 @@ "system/CollaborationSessionConfig:enableAwareness", "system/CollaborationSessionConfig:enableCursorSharing", "system/CollaborationSessionConfig:enablePresence", - "system/CollaborationSessionConfig:idleTimeout", + "system/CollaborationSessionConfig:idleTimeout [RETIRED]", + "system/CollaborationSessionConfig:idleTimeoutMs", "system/CollaborationSessionConfig:maxUsers", "system/CollaborationSessionConfig:mode", "system/CollaborationSessionConfig:persistence", @@ -438,7 +441,8 @@ "system/FailoverConfig:autoFailover", "system/FailoverConfig:dns", "system/FailoverConfig:failureThreshold", - "system/FailoverConfig:healthCheckInterval", + "system/FailoverConfig:healthCheckInterval [RETIRED]", + "system/FailoverConfig:healthCheckIntervalSeconds", "system/FailoverConfig:mode", "system/FailoverConfig:regions", "system/Feature:code", @@ -963,9 +967,11 @@ "system/RegistryConfig:visibility", "system/RegistryUpstream:auth", "system/RegistryUpstream:retry", - "system/RegistryUpstream:syncInterval", + "system/RegistryUpstream:syncInterval [RETIRED]", + "system/RegistryUpstream:syncIntervalSeconds", "system/RegistryUpstream:syncPolicy", - "system/RegistryUpstream:timeout", + "system/RegistryUpstream:timeout [RETIRED]", + "system/RegistryUpstream:timeoutMs", "system/RegistryUpstream:tls", "system/RegistryUpstream:url", "system/RemoveFieldOperation:fieldName", @@ -1096,7 +1102,8 @@ "system/SettingsNamespacePayload:values", "system/Span:attributes", "system/Span:context", - "system/Span:duration", + "system/Span:duration [RETIRED]", + "system/Span:durationMs", "system/Span:endTime", "system/Span:events", "system/Span:instrumentationLibrary", @@ -1158,7 +1165,8 @@ "system/StorageConnection:sasToken", "system/StorageConnection:secretAccessKey", "system/StorageConnection:sessionToken", - "system/StorageConnection:timeout", + "system/StorageConnection:timeout [RETIRED]", + "system/StorageConnection:timeoutMs", "system/StorageConnection:useSSL", "system/StructuredLogEntry:context", "system/StructuredLogEntry:environment", diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index daeb7d69ce..05fc48d41a 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -544,7 +544,7 @@ describe('metrics window and period lengths carry their unit (#15679)', () => { it('accepts every renamed key at the same magnitude', () => { expect(MetricAggregationConfigSchema.parse({ type: 'avg', window: { durationSeconds: 300 } }) - .window.durationSeconds).toBe(300); + .window?.durationSeconds).toBe(300); expect(ServiceLevelIndicatorSchema.parse({ ...sliBase, window: { durationSeconds: 2592000 } }) .window.durationSeconds).toBe(2592000); expect(ServiceLevelObjectiveSchema.parse({ diff --git a/packages/spec/src/system/tracing.test.ts b/packages/spec/src/system/tracing.test.ts index fcaef85d34..0b1c814c78 100644 --- a/packages/spec/src/system/tracing.test.ts +++ b/packages/spec/src/system/tracing.test.ts @@ -542,9 +542,8 @@ describe('Span.duration carries its unit (#15679)', () => { expect(SpanSchema.safeParse({ ...base, durationMs: -1 }).success).toBe(false); }); - it('leaves the exporter `timeout` alone — its describe names no unit, so it is outside the population', () => { - const config = TracingConfigSchema.parse({ - serviceName: 'test', + it('leaves the OTel exporter `timeout` alone — its describe names no unit, so it is outside the population', () => { + const config = OpenTelemetryCompatibilitySchema.parse({ exporter: { type: 'console' }, resource: { serviceName: 'test' }, }); From d1d63f3a4d3e75cbc7c953c2199e1e4428c7131e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 14:56:07 +0000 Subject: [PATCH 21/33] =?UTF-8?q?test(spec):=20fix=20the=20three=20new=20m?= =?UTF-8?q?etrics=20pins=20=E2=80=94=20required=20label,=20and=20assert=20?= =?UTF-8?q?the=20prescription=20explains=20the=20non-mechanical=20name=20(?= =?UTF-8?q?#15679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/system/metrics.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/system/metrics.test.ts b/packages/spec/src/system/metrics.test.ts index 05fc48d41a..3e12157f4f 100644 --- a/packages/spec/src/system/metrics.test.ts +++ b/packages/spec/src/system/metrics.test.ts @@ -500,11 +500,14 @@ describe('MetricsConfigSchema', () => { describe('metrics window and period lengths carry their unit (#15679)', () => { const sliBase = { name: 'api_availability', + label: 'API Availability', metric: 'http_requests_total', type: 'availability' as const, successCriteria: { threshold: 99.9, operator: 'gte' as const }, }; - const sloBase = { name: 'api_uptime_slo', sli: 'api_availability', target: 99.9 }; + const sloBase = { + name: 'api_uptime_slo', label: 'API Uptime SLO', sli: 'api_availability', target: 99.9, + }; it('REFUSES the retired `MetricAggregationConfig.window.size`', () => { const result = MetricAggregationConfigSchema.safeParse({ @@ -516,7 +519,9 @@ describe('metrics window and period lengths carry their unit (#15679)', () => { expect(issue).toBeDefined(); expect(issue!.code).not.toBe('unrecognized_keys'); expect(issue!.message).toContain('renamed to `durationSeconds`'); - expect(issue!.message).not.toContain('sizeSeconds'); + // The prescription must EXPLAIN the departure from the mechanical name, or the + // next author reads `durationSeconds` as a slip and "corrects" it back. + expect(issue!.message).toContain('The new name is not `sizeSeconds`'); }); it('REFUSES the retired `ServiceLevelIndicator.window.size`', () => { From 45a35896c8ae9f93aeecce3ab604f4a0b43f3f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:10:17 +0000 Subject: [PATCH 22/33] docs(changeset): the fifteen system/ duration renames (#15679) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../system-duration-keys-unit-in-key-name.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .changeset/system-duration-keys-unit-in-key-name.md diff --git a/.changeset/system-duration-keys-unit-in-key-name.md b/.changeset/system-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c77b197fab --- /dev/null +++ b/.changeset/system-duration-keys-unit-in-key-name.md @@ -0,0 +1,110 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the fifteen `system/` duration keys carry their unit in the key name (#15679, ruling B on #14478) + + + +**BREAKING** — fifteen published `system/` duration keys are renamed and +tombstoned. Shipped as `minor` under the repo's launch-window convention for +breaking changes; the hand-migration prescriptions are registered under protocol +major 18. Maintainer ruling B on #14478 (2026-09-02, decision batch #43, +「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit +in the key NAME, never only in its `.describe()` prose, and grandfathers no +existing offender. Stack card 1/6 (#15676) landed the rule's two structural +exemptions, card 2/6 (#15677) cleared `api/` and card 3/6 (#15678) cleared +`kernel/`; this card clears `system/`. Measured with the gate itself: +`src/system/**` goes from 15 offenders to **0**, and the whole-tree count falls +**22 → 7**. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `CacheTier.ttl` | `ttlSeconds` | seconds | +| `CacheAvalanchePrevention.circuitBreaker.resetTimeout` | `resetTimeoutSeconds` | seconds | +| `CollaborationSessionConfig.idleTimeout` | `idleTimeoutMs` | milliseconds | +| `CollaborationSessionConfig.snapshot.interval` | `intervalMs` | milliseconds | +| `FailoverConfig.healthCheckInterval` | `healthCheckIntervalSeconds` | seconds | +| `MetricAggregationConfig.window.size` | `durationSeconds` | seconds | +| `ServiceLevelIndicator.window.size` | `durationSeconds` | seconds | +| `ServiceLevelObjective.period.duration` | `durationSeconds` | seconds | +| `AccessControlConfig.maxAge` | `maxAgeSeconds` | seconds | +| `StorageConnection.timeout` | `timeoutMs` | milliseconds | +| `RegistryUpstream.syncInterval` | `syncIntervalSeconds` | seconds | +| `RegistryUpstream.timeout` | `timeoutMs` | milliseconds | +| `RegistryConfig.cache.ttl` | `ttlSeconds` | seconds | +| `Span.duration` | `durationMs` | milliseconds | +| `QueueConfig.rateLimit.duration` | `durationMs` | milliseconds | + +**Every value is unchanged** — only key names move, and every default moves with +its key (`CacheTier` still defaults to 300, `CollaborationSessionConfig` to +300000, `FailoverConfig` to 30, `RegistryUpstream.timeoutMs` to 30000, +`RegistryConfig.cache.ttlSeconds` to 3600). Bounds move with their keys too, so +`syncIntervalSeconds` still refuses anything under 60 and `timeoutMs` anything +under 1000. Every old spelling is a `retiredKey()` tombstone, so it fails `tsc` +at the authoring site (input type `never`) and fails the parse with the rename +prescription rather than a bare unrecognized-key error. + +## ⚠️ Two `maxAge` keys, opposite sides of the line — do not harmonise them + +`AccessControlConfig.maxAge` (bucket CORS) is **renamed** to `maxAgeSeconds`. +Its twin `shared/CorsConfig.maxAge` (HTTP CORS) is **not**, and keeps its bare +name under an `externalVocabulary` marker. + +The asymmetry is the whole point. Every bucket-CORS standard the first value is +forwarded to already spells the unit — S3 `MaxAgeSeconds`, GCS `maxAgeSeconds`, +Azure `MaxAgeInSeconds` — so marking that key would have exempted a *deviation +from* the cited standard rather than a mirror of it. The Fetch response header +the second mirrors, `Access-Control-Max-Age`, genuinely carries no unit token. +A find-and-replace across both leaves no gate red: the marker exempts the twin +either way. A pin test in `object-storage.test.ts` is the only guard. + +## ⚠️ `window.size` becomes `durationSeconds`, not the mechanical `sizeSeconds` + +The gate prints `sizeSeconds` for the two `window.size` keys, and that name is +wrong on its face. `size` means a byte or row count everywhere else in this spec +— `CacheTier.maxSize` is megabytes, `RegistryConfig.cache.maxSize` is bytes, and +`MetricExportConfig.batch.size` on the very same file is a record count — so +`sizeSeconds` would have kept the misleading half of the name and bolted a unit +onto it. `windowSeconds` was rejected for a plainer reason: the parent key is +already `window`, so it would read `window.windowSeconds`. + +`durationSeconds` names what the number is, and the file supplied its own +precedent: `ServiceLevelObjective.period.duration` already called a period +length a duration. After the rename all three read alike. The prescription says +so explicitly, so the next author does not read the departure as a slip and +"correct" it back to the mechanical name. + +## Dispositions — eight semantic entries, no D2 conversion + +Justified per key rather than defaulted, and this card's answer is uniform: +**none of the fifteen gets an ADR-0087 D2 conversion.** A D2 conversion runs +over a stack document, and `stack.zod.ts` declares no `cache`, `collaboration`, +`disasterRecovery`, `metrics`, `objectStorage`, `registry`, `tracing` or +`worker` root — none of these twelve defs is a stack collection member or a +registered metadata kind stored as a `sys_metadata` row, so the conversion chain +has no seam that would see one. They are host configuration (`CacheTier`, +`FailoverConfig`, `StorageConnection`, `RegistryUpstream`, `RegistryConfig`, +`QueueConfig`), call arguments (`CollaborationSessionConfig`) and +runtime-emitted measurements (`Span`). Each therefore carries a **semantic** +entry, which is what ruling B prescribes for a key that is not authorable stack +metadata. All fifteen are registered by exact key in `RETIRED_KEYS_BY_MAJOR`, +nested spellings included. + +## Keys deliberately left alone + +`FailoverConfig.dns.ttl` is a declared `externalVocabulary` mirror of the DNS +resource-record TTL field (RFC 1035 §4.1.3) and keeps its bare name. +`CacheAvalanchePrevention.lockout.lockTimeoutMs` was already correct — and it is +milliseconds where its `resetTimeoutSeconds` sibling is seconds, so the two must +not be migrated as if they were one unit. `MetricExportConfig.batch.size` is a +record count and `QueueConfig.rateLimit.max` is a task count: neither is a +duration, so neither has a unit to carry. `ServiceLevelObjective.errorBudget`'s +burn-rate `window` and the OpenTelemetry exporter `timeout` name no unit +anywhere in their prose, so both are outside the gate's population entirely. +Pin tests assert each of these, so a later sweep cannot read this card as +"every duration-shaped number on these files". From 18526c9a7e00797437c5109126f1b4868457ed58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:01:58 +0000 Subject: [PATCH 23/33] wip(spec): rename the 7 data/ ui/ ai/ integration/ duration keys, tombstones on the old spellings (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/ai/conversation.zod.ts | 15 +++++++++- packages/spec/src/data/driver-nosql.zod.ts | 15 +++++++++- packages/spec/src/data/driver/memory.zod.ts | 22 ++++++++++++-- packages/spec/src/data/driver/turso.zod.ts | 21 ++++++++++++-- .../spec/src/integration/connector.zod.ts | 29 +++++++++++++++++-- packages/spec/src/ui/dashboard.zod.ts | 23 +++++++++++++-- 6 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/spec/src/ai/conversation.zod.ts b/packages/spec/src/ai/conversation.zod.ts index e3c3bf2520..ec02509479 100644 --- a/packages/spec/src/ai/conversation.zod.ts +++ b/packages/spec/src/ai/conversation.zod.ts @@ -14,6 +14,7 @@ import { TokenUsageSchema } from './usage.zod'; * Message Role */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const MessageRoleSchema = lazySchema(() => z.enum([ 'system', 'user', @@ -305,7 +306,19 @@ export const ConversationAnalyticsSchema = lazySchema(() => z.object({ tokensSavedBySummarization: z.number().int().nonnegative().default(0), /** Duration */ - duration: z.number().nonnegative().optional().describe('Session duration in seconds'), + // Renamed from `duration` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, on a shape whose two neighbouring instants already spell + // themselves `firstMessageAt` / `lastMessageAt`. Every other number on this + // shape is a COUNT (messages, tokens, events), so the one measurement carrying + // a unit was the one key that named none. + durationSeconds: z.number().nonnegative().optional().describe('Session duration in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + duration: retiredKey( + '`ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged.', + ), firstMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), lastMessageAt: z.string().datetime().optional().describe('ISO 8601 timestamp'), })); diff --git a/packages/spec/src/data/driver-nosql.zod.ts b/packages/spec/src/data/driver-nosql.zod.ts index b5d81c7254..b95b445ac7 100644 --- a/packages/spec/src/data/driver-nosql.zod.ts +++ b/packages/spec/src/data/driver-nosql.zod.ts @@ -8,6 +8,7 @@ import { DriverConfigSchema } from './driver.zod'; * Supported NoSQL database types */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const NoSQLDatabaseTypeSchema = lazySchema(() => z.enum([ 'mongodb', 'couchdb', @@ -306,8 +307,20 @@ export const NoSQLQueryOptionsSchema = lazySchema(() => z.object({ /** * Query timeout in milliseconds + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only in + * the describe prose. It sits beside `batchSize`, a plain row COUNT, with + * nothing at the call site to tell a reader which of the two numbers carries + * a unit. */ - timeout: z.number().int().positive().optional().describe('Query timeout (ms)'), + timeoutMs: z.number().int().positive().optional().describe('Query timeout (ms)'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Use cursor for large result sets diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index f5a2a21311..16b43116e0 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -60,6 +60,7 @@ const PERSISTENCE_HISTORY = * via `PersistenceAdapterInterface` in the driver implementation. */ import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; export const PersistenceAdapterSchema = lazySchema(() => strictObject( { surface: "this memory datasource's custom persistence adapter", @@ -108,8 +109,25 @@ export const FilePersistenceConfigSchema = lazySchema(() => strictObject( * config-material, not data. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path to persist data'), - /** Auto-save interval in milliseconds. Default: 2000ms. */ - autoSaveInterval: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + /** + * Auto-save interval in milliseconds. Default: 2000ms. + * + * Renamed from `autoSaveInterval` (#15680, ruling B on #14478): the unit + * lived only in the describe prose. The `min(100)` bound is the tell that + * made the bare name dangerous — 100 reads as a plausible number of + * SECONDS, so an author who guessed the unit wrong was refused nowhere and + * saved 1000x more often than intended. + */ + autoSaveIntervalMs: z.number().min(100).default(2000).describe('Auto-save interval in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) and the 2000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), }, ).describe('File-system persistence configuration')); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 4375edd1bd..9a394a8ad5 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; +import { retiredKey } from '../../shared/retired-key'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { @@ -211,11 +212,27 @@ export const TursoConfigSchema = lazySchema(() => strictObject( onConnect: z.boolean().optional().describe('Sync immediately on connect'), }).optional().describe('Embedded-replica sync configuration (requires `syncUrl`)'), - /** Operation timeout in ms for remote operations (replica/remote modes). */ - timeout: z.number().int().positive().optional() + /** + * Operation timeout in ms for remote operations (replica/remote modes). + * + * Renamed from `timeout` (#15680, ruling B on #14478): the unit lived only + * in the describe prose and in a `.meta({ title })` no parse reads. It sat + * two keys below `sync.intervalSeconds`, which already spelled ITS unit — + * one shape carrying both conventions, and the suffixed one was the honest + * half. + */ + timeoutMs: z.number().int().positive().optional() .describe('Operation timeout in milliseconds for remote operations') .meta({ title: 'Timeout (ms)' }), + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + timeout: retiredKey( + '`turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename the ' + + 'key to `timeoutMs`; the value (milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), + /** Pin the transport instead of inferring it from `url`. */ mode: TursoTransportModeSchema.optional().meta({ title: 'Transport mode' }), }) diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 988ef8cbeb..6e83add4c0 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -538,7 +538,20 @@ export const CircuitBreakerConfigSchema = lazySchema(() => z.object({ failureThreshold: z.number().optional().default(5).describe('Failures before opening circuit'), resetTimeoutMs: z.number().optional().default(30000).describe('Time in open state before half-open'), halfOpenMaxRequests: z.number().optional().default(1).describe('Requests allowed in half-open state'), - monitoringWindow: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + // Renamed from `monitoringWindow` (#15680, ruling B on #14478): the unit lived + // only in the describe prose, one key below `resetTimeoutMs`, which already + // spelled ITS unit. One shape carrying both conventions — the suffixed one was + // the honest half. + monitoringWindowMs: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + monitoringWindow: retiredKey( + '`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `monitoringWindowMs`; the value ' + + '(milliseconds) and the 60000 default are unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), fallbackStrategy: z.enum(['cache', 'default_value', 'error', 'queue']).optional().describe('Fallback strategy when circuit is open'), }).describe('Circuit breaker configuration')); @@ -672,7 +685,19 @@ export const ConnectorTriggerSchema = lazySchema(() => z.object({ label: z.string().describe('Trigger label'), description: z.string().optional(), type: z.enum(['polling', 'webhook']).describe('Trigger type'), - interval: z.number().optional().describe('Polling interval in seconds'), + // Renamed from `interval` (#15680, ruling B on #14478): the unit lived only in + // the describe prose, and a polling cadence is exactly the number a reader + // guesses at — the same bare `interval` means MILLISECONDS elsewhere in this + // spec, so the identical name carried two units a thousandfold apart. + intervalSeconds: z.number().optional().describe('Polling interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + interval: retiredKey( + '`ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' + + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), })); export type ConnectorTrigger = z.input; diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 758a16a238..81ad8eadce 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -871,7 +871,7 @@ export const DashboardSchema = lazySchema(() => strictObject({ filters: 'globalFilters', globalFilter: 'globalFilters', grid: 'columns', columnCount: 'columns', spacing: 'gap', - refresh: 'refreshInterval', autoRefresh: 'refreshInterval', pollInterval: 'refreshInterval', + refresh: 'refreshIntervalSeconds', autoRefresh: 'refreshIntervalSeconds', pollInterval: 'refreshIntervalSeconds', dateFilter: 'dateRange', timeRange: 'dateRange', }, guidance: { @@ -902,8 +902,25 @@ export const DashboardSchema = lazySchema(() => strictObject({ /** Space between widgets, in steps of 0.25rem (4 = 1rem) */ gap: z.number().int().min(0).optional().describe('Space between widgets, in steps of 0.25rem (4 = 1rem)'), - /** Auto-refresh */ - refreshInterval: z.number().optional().describe('Auto-refresh interval in seconds'), + /** + * Auto-refresh + * + * Renamed from `refreshInterval` (#15680, ruling B on #14478): the unit lived + * only in the describe prose. The three rename-hint aliases beside it + * (`refresh` / `autoRefresh` / `pollInterval`) are the measure of how many + * spellings authors reach for, and not one of them names a unit either — so + * every door into this key left the cadence ambiguous until the canonical + * spelling carried it. + */ + refreshIntervalSeconds: z.number().optional().describe('Auto-refresh interval in seconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + refreshInterval: retiredKey( + '`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 ' + + '— the unit of a duration-shaped number lives in the key name, not only in the describe ' + + 'prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** Dashboard Date Range (Global time filter) */ dateRange: strictObject({ From 1db01292825ee5e0fe4f06460822d200a4d3c52d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:22:05 +0000 Subject: [PATCH 24/33] wip(spec): readers, ADR-0087 registrations and regenerated artifacts for the 7 renames (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/ai/conversation.mdx | 3 +- .../docs/references/data/driver-memory.mdx | 6 +- content/docs/references/data/driver-nosql.mdx | 8 +- content/docs/references/data/driver-turso.mdx | 3 +- .../docs/references/integration/connector.mdx | 15 +- content/docs/references/ui/dashboard.mdx | 3 +- content/docs/ui/dashboards.mdx | 6 +- packages/cli/src/commands/explain.ts | 2 +- .../driver-memory/src/memory-driver.ts | 10 +- .../src/persistence/file-adapter.ts | 8 +- .../src/persistence/persistence.test.ts | 20 +- packages/spec/authorable-defaults/data.json | 2 +- .../spec/authorable-defaults/integration.json | 2 +- packages/spec/authorable-surface/ai.json | 3 +- packages/spec/authorable-surface/data.json | 12 +- .../spec/authorable-surface/integration.json | 6 +- packages/spec/authorable-surface/ui.json | 3 +- packages/spec/liveness/dashboard.json | 9 +- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/ai/conversation.test.ts | 2 +- packages/spec/src/conversions/registry.ts | 315 ++++++++++++++++++ packages/spec/src/data/driver-nosql.test.ts | 4 +- packages/spec/src/data/driver/memory.test.ts | 46 +-- packages/spec/src/data/driver/memory.zod.ts | 24 +- packages/spec/src/data/driver/turso.test.ts | 2 +- .../spec/src/integration/connector.test.ts | 4 +- .../18.ai__ConversationAnalytics__duration.ts | 16 + ...AutoPersistenceConfig__autoSaveInterval.ts | 16 + ...FilePersistenceConfig__autoSaveInterval.ts | 16 + .../18.data__NoSQLQueryOptions__timeout.ts | 13 + .../18.data__TursoConfig__timeout.ts | 17 + ..._CircuitBreakerConfig__monitoringWindow.ts | 16 + ...integration__ConnectorTrigger__interval.ts | 15 + .../18.ui__Dashboard__refreshInterval.ts | 21 ++ ...ersation-analytics-duration-unit-in-key.ts | 36 ++ ...nosql-query-options-timeout-unit-in-key.ts | 31 ++ packages/spec/src/migrations/registry.ts | 192 ++++++++++- packages/spec/src/ui/dashboard.form.ts | 2 +- skills/objectstack-ui/rules/dashboards.md | 2 +- 39 files changed, 831 insertions(+), 84 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts diff --git a/content/docs/references/ai/conversation.mdx b/content/docs/references/ai/conversation.mdx index 06c181bcf0..cb39926f0b 100644 --- a/content/docs/references/ai/conversation.mdx +++ b/content/docs/references/ai/conversation.mdx @@ -58,7 +58,8 @@ const result = CodeContentSchema.parse(data); | **summarizationEvents** | `integer` | optional (default: `0`) | | | **tokensSavedByPruning** | `integer` | optional (default: `0`) | | | **tokensSavedBySummarization** | `integer` | optional (default: `0`) | | -| **duration** | `number` | optional | Session duration in seconds | +| **durationSeconds** | `number` | optional | Session duration in seconds | +| **duration** | `never` | optional | [REMOVED] `ConversationAnalytics.duration` was renamed to `durationSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `durationSeconds`; the value (seconds) is unchanged. | | **firstMessageAt** | `string` | optional | ISO 8601 timestamp | | **lastMessageAt** | `string` | optional | ISO 8601 timestamp | diff --git a/content/docs/references/data/driver-memory.mdx b/content/docs/references/data/driver-memory.mdx index 03e63e948c..2c40acb3b2 100644 --- a/content/docs/references/data/driver-memory.mdx +++ b/content/docs/references/data/driver-memory.mdx @@ -44,7 +44,8 @@ Auto-detect persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'auto'` | ✅ | | | **path** | `string` | optional | File path override for Node.js environments | -| **autoSaveInterval** | `number` | optional | Auto-save interval override for Node.js environments | +| **autoSaveIntervalMs** | `number` | optional | Auto-save interval override for Node.js environments, in milliseconds | +| **autoSaveInterval** | `never` | optional | [REMOVED] `AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **key** | `string` | optional | localStorage key override for browser environments | @@ -60,7 +61,8 @@ File-system persistence configuration | :--- | :--- | :--- | :--- | | **type** | `'file'` | ✅ | | | **path** | `string` | optional | File path to persist data | -| **autoSaveInterval** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveIntervalMs** | `number` | optional (default: `2000`) | Auto-save interval in ms | +| **autoSaveInterval** | `never` | optional | [REMOVED] `FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value (milliseconds) and the 2000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- diff --git a/content/docs/references/data/driver-nosql.mdx b/content/docs/references/data/driver-nosql.mdx index 5e79c0aa0e..b31d7ba7e9 100644 --- a/content/docs/references/data/driver-nosql.mdx +++ b/content/docs/references/data/driver-nosql.mdx @@ -29,7 +29,7 @@ const result = AggregationPipelineSchema.parse(data); | :--- | :--- | :--- | :--- | | **collection** | `string` | ✅ | Collection/table name | | **stages** | `{ operator: string; options: Record }[]` | ✅ | Aggregation pipeline stages | -| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeout?: integer; … }` | optional | Query options | +| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeoutMs?: integer; … }` | optional | Query options | ### Nested Shape: `AggregationPipeline.stages[number]` @@ -45,7 +45,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | @@ -319,7 +320,8 @@ const result = AggregationPipelineSchema.parse(data); | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | | **projection** | `Record` | optional | Field projection | -| **timeout** | `integer` | optional | Query timeout (ms) | +| **timeoutMs** | `integer` | optional | Query timeout (ms) | +| **timeout** | `never` | optional | [REMOVED] `NoSQLQueryOptions.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | | **profile** | `boolean` | optional | Enable query profiling | diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 19ca0d1567..c8233200b7 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -71,7 +71,8 @@ Turso / libSQL Connection Configuration | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | | **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | -| **timeout** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeoutMs** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **timeout** | `never` | optional | [REMOVED] `turso config.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **mode** | `Enum<'local' \| 'replica' \| 'remote'>` | optional | Force a transport mode instead of inferring it from `url` | ### Nested Shape: `TursoConfig.sync` diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index f9f079593a..96c7ed31bf 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -148,7 +148,8 @@ Circuit breaker configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -272,7 +273,8 @@ Circuit breaker configuration | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `Connector.syncConfig` @@ -440,7 +442,8 @@ Connector health configuration | **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | | **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | | **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindow** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | +| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | @@ -597,7 +600,8 @@ Connector status | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | --- @@ -755,7 +759,8 @@ Connector type | **label** | `string` | ✅ | Trigger label | | **description** | `string` | optional | | | **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **interval** | `number` | optional | Polling interval in seconds | +| **intervalSeconds** | `number` | optional | Polling interval in seconds | +| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | ### Nested Shape: `DeclarativeConnectorEntry.syncConfig` diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index a43e73ba20..d78c62471b 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -34,7 +34,8 @@ const result = DashboardSchema.parse(data); | **widgets** | `{ id: string; title?: string \| Record; description?: string \| Record; type: Enum<'bar' \| 'horizontal-bar' \| 'column' \| 'line' \| 'area' \| 'pie' \| 'donut' \| …>; … }[]` | ✅ | Widgets to display | | **columns** | `integer` | optional | Number of grid columns (default 12) | | **gap** | `integer` | optional | Space between widgets, in steps of 0.25rem (4 = 1rem) | -| **refreshInterval** | `number` | optional | Auto-refresh interval in seconds | +| **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds | +| **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **dateRange** | `{ field?: string; defaultRange: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange: boolean }` | optional | Global dashboard date range filter configuration | | **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | | **aria** | `never` | optional | [REMOVED] `dashboard.aria` was removed in @objectstack/spec 17.0.0 (audit close-out) — no dashboard renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index e8942c5745..57481f53ba 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -18,7 +18,7 @@ const salesDashboard = { name: 'sales_overview', label: 'Sales Overview', description: 'Key sales metrics and pipeline analysis', - refreshInterval: 300, // Refresh every 5 minutes + refreshIntervalSeconds: 300, // Refresh every 5 minutes dateRange: { field: 'close_date', @@ -65,7 +65,7 @@ const salesDashboard = { | `label` | `string` | ✅ | Display label | | `description` | `string` | optional | Dashboard description | | `widgets` | `DashboardWidget[]` | ✅ | Chart and metric widgets | -| `refreshInterval` | `number` | optional | Auto-refresh interval (seconds) | +| `refreshIntervalSeconds` | `number` | optional | Auto-refresh interval (seconds) | | `dateRange` | `object` | optional | Global date range filter | | `globalFilters` | `GlobalFilter[]` | optional | Global filter controls | @@ -449,7 +449,7 @@ const projectDashboard = { name: 'project_overview', label: 'Project Overview', description: 'Real-time project health metrics', - refreshInterval: 60, + refreshIntervalSeconds: 60, dateRange: { field: 'created_at', diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 32c9efe2ab..59ea4b52eb 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -238,7 +238,7 @@ export const SCHEMAS: Record = { { name: 'label', type: 'string', description: 'Display name' }, { name: 'widgets', type: 'Widget[]', description: 'Dashboard widget definitions' }, { name: 'layout', type: 'GridLayout', description: 'Widget positioning' }, - { name: 'refreshInterval', type: 'number', description: 'Auto-refresh interval in seconds' }, + { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh interval in seconds' }, ], example: `{ name: 'project_overview', diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 24f354c0bb..1f2dc5ce39 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -201,9 +201,9 @@ export interface InMemoryDriverConfig { * - `'auto'` — Auto-detect environment (browser → localStorage, Node.js → file, serverless → disabled) * - `'file'` — File-system persistence with defaults (Node.js only) * - `'local'` — localStorage persistence with defaults (Browser only) - * - `{ type: 'file', path?: string, autoSaveInterval?: number }` — File-system with options + * - `{ type: 'file', path?: string, autoSaveIntervalMs?: number }` — File-system with options * - `{ type: 'local', key?: string }` — localStorage with options - * - `{ type: 'auto', path?: string, key?: string, autoSaveInterval?: number }` — Auto-detect with options + * - `{ type: 'auto', path?: string, key?: string, autoSaveIntervalMs?: number }` — Auto-detect with options * - `{ adapter: PersistenceAdapterInterface }` — Custom adapter * * Durability is **opt-in**, as #815 specified ("默认情况下不启用持久化(纯内存,行为不变)", @@ -222,7 +222,7 @@ export interface InMemoryDriverConfig { type?: 'file' | 'local' | 'auto'; path?: string; key?: string; - autoSaveInterval?: number; + autoSaveIntervalMs?: number; adapter?: PersistenceAdapterInterface; }; } @@ -2293,7 +2293,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); this.logger.debug('Auto-detected Node.js environment, using file persistence'); } @@ -2301,7 +2301,7 @@ export class InMemoryDriver implements IDataDriver { const { FileSystemPersistenceAdapter } = await import('./persistence/file-adapter.js'); this.persistenceAdapter = new FileSystemPersistenceAdapter({ path: persistence.path, - autoSaveInterval: persistence.autoSaveInterval, + autoSaveIntervalMs: persistence.autoSaveIntervalMs, }); } else if (persistence.type === 'local') { const { LocalStoragePersistenceAdapter } = await import('./persistence/local-storage-adapter.js'); diff --git a/packages/drivers/driver-memory/src/persistence/file-adapter.ts b/packages/drivers/driver-memory/src/persistence/file-adapter.ts index 391e4debd4..1a5a4104a9 100644 --- a/packages/drivers/driver-memory/src/persistence/file-adapter.ts +++ b/packages/drivers/driver-memory/src/persistence/file-adapter.ts @@ -13,14 +13,14 @@ import * as path from 'node:path'; */ export class FileSystemPersistenceAdapter { private readonly filePath: string; - private readonly autoSaveInterval: number; + private readonly autoSaveIntervalMs: number; private dirty = false; private timer: ReturnType | null = null; private currentDb: Record | null = null; - constructor(options?: { path?: string; autoSaveInterval?: number }) { + constructor(options?: { path?: string; autoSaveIntervalMs?: number }) { this.filePath = options?.path || path.join('.objectstack', 'data', 'memory-driver.json'); - this.autoSaveInterval = options?.autoSaveInterval ?? 2000; + this.autoSaveIntervalMs = options?.autoSaveIntervalMs ?? 2000; } /** @@ -67,7 +67,7 @@ export class FileSystemPersistenceAdapter { await this.writeToDisk(this.currentDb); this.dirty = false; } - }, this.autoSaveInterval); + }, this.autoSaveIntervalMs); // Allow process to exit even if timer is running if (this.timer) { diff --git a/packages/drivers/driver-memory/src/persistence/persistence.test.ts b/packages/drivers/driver-memory/src/persistence/persistence.test.ts index f4c5433cb9..8da79c90ef 100644 --- a/packages/drivers/driver-memory/src/persistence/persistence.test.ts +++ b/packages/drivers/driver-memory/src/persistence/persistence.test.ts @@ -44,7 +44,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist and restore data via file adapter', async () => { // Create and populate driver with file persistence const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -59,7 +59,7 @@ describe('InMemoryDriver Persistence', () => { // Create a new driver and verify data is restored const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -84,7 +84,7 @@ describe('InMemoryDriver Persistence', () => { it('should persist updates and deletes', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); @@ -99,7 +99,7 @@ describe('InMemoryDriver Persistence', () => { // Restore const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); @@ -194,7 +194,7 @@ describe('InMemoryDriver Persistence', () => { it('should auto-detect Node.js environment and use file persistence with object config', async () => { const filePath = path.join(TEST_DATA_DIR, 'auto-test-db.json'); const driver1 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.create('users', { id: '1', name: 'Alice' }); @@ -206,7 +206,7 @@ describe('InMemoryDriver Persistence', () => { // Restore from file const driver2 = new InMemoryDriver({ - persistence: { type: 'auto', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'auto', path: filePath, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const users = await driver2.find('users', {}); @@ -219,7 +219,7 @@ describe('InMemoryDriver Persistence', () => { describe('Bulk Operations with Persistence', () => { it('should persist bulk creates', async () => { const driver1 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver1.connect(); await driver1.bulkCreate('items', [ @@ -231,7 +231,7 @@ describe('InMemoryDriver Persistence', () => { await driver1.disconnect(); const driver2 = new InMemoryDriver({ - persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveInterval: 100 }, + persistence: { type: 'file', path: TEST_FILE_PATH, autoSaveIntervalMs: 100 }, }); await driver2.connect(); const items = await driver2.find('items', {}); @@ -278,7 +278,7 @@ describe('InMemoryDriver Persistence', () => { it('still persists when a host opts in explicitly', async () => { const filePath = path.join(TEST_DATA_DIR, 'opt-in.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: 'a', name: 'Widget' }); @@ -338,7 +338,7 @@ describe('InMemoryDriver Persistence', () => { process.env.VERCEL = '1'; const filePath = path.join(TEST_DATA_DIR, 'explicit-file-serverless.json'); const driver = new InMemoryDriver({ - persistence: { type: 'file', path: filePath, autoSaveInterval: 100 }, + persistence: { type: 'file', path: filePath, autoSaveIntervalMs: 100 }, }); await driver.connect(); await driver.create('items', { id: '1', name: 'Widget' }); diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json index 21ccd54454..7897628cc2 100644 --- a/packages/spec/authorable-defaults/data.json +++ b/packages/spec/authorable-defaults/data.json @@ -45,7 +45,7 @@ "data/Field:required = false", "data/Field:searchable = false", "data/Field:sortable = true", - "data/FilePersistenceConfig:autoSaveInterval = 2000", + "data/FilePersistenceConfig:autoSaveIntervalMs = 2000", "data/FormatValidation:active = true", "data/FormatValidation:events = [\"insert\",\"update\"]", "data/FormatValidation:priority = 100", diff --git a/packages/spec/authorable-defaults/integration.json b/packages/spec/authorable-defaults/integration.json index 40ab04a917..2c74177ff7 100644 --- a/packages/spec/authorable-defaults/integration.json +++ b/packages/spec/authorable-defaults/integration.json @@ -4,7 +4,7 @@ "defaults": [ "integration/CircuitBreakerConfig:failureThreshold = 5", "integration/CircuitBreakerConfig:halfOpenMaxRequests = 1", - "integration/CircuitBreakerConfig:monitoringWindow = 60000", + "integration/CircuitBreakerConfig:monitoringWindowMs = 60000", "integration/CircuitBreakerConfig:resetTimeoutMs = 30000", "integration/Connector:authentication = {\"type\":\"none\"}", "integration/Connector:connectionTimeoutMs = 30000", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index 323933ab95..aad48547be 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -89,7 +89,8 @@ "ai/CodeContent:type", "ai/ConversationAnalytics:assistantMessages", "ai/ConversationAnalytics:averageTokensPerMessage", - "ai/ConversationAnalytics:duration", + "ai/ConversationAnalytics:duration [RETIRED]", + "ai/ConversationAnalytics:durationSeconds", "ai/ConversationAnalytics:firstMessageAt", "ai/ConversationAnalytics:lastMessageAt", "ai/ConversationAnalytics:peakTokenUsage", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 6a2c4222ea..4efcf67767 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -35,7 +35,8 @@ "data/AnalyticsQuery:timeDimensions", "data/AnalyticsQuery:timezone", "data/AnalyticsQuery:where", - "data/AutoPersistenceConfig:autoSaveInterval", + "data/AutoPersistenceConfig:autoSaveInterval [RETIRED]", + "data/AutoPersistenceConfig:autoSaveIntervalMs", "data/AutoPersistenceConfig:key", "data/AutoPersistenceConfig:path", "data/AutoPersistenceConfig:type", @@ -416,7 +417,8 @@ "data/FieldMaskingKeep:keepTail", "data/FieldReference:$field", "data/FieldReference:addDays", - "data/FilePersistenceConfig:autoSaveInterval", + "data/FilePersistenceConfig:autoSaveInterval [RETIRED]", + "data/FilePersistenceConfig:autoSaveIntervalMs", "data/FilePersistenceConfig:path", "data/FilePersistenceConfig:type", "data/FileValue:alt", @@ -609,7 +611,8 @@ "data/NoSQLQueryOptions:profile", "data/NoSQLQueryOptions:projection", "data/NoSQLQueryOptions:readFromSecondary", - "data/NoSQLQueryOptions:timeout", + "data/NoSQLQueryOptions:timeout [RETIRED]", + "data/NoSQLQueryOptions:timeoutMs", "data/NoSQLQueryOptions:useCursor", "data/NoSQLTransactionOptions:maxCommitTimeMS", "data/NoSQLTransactionOptions:readConcern", @@ -888,7 +891,8 @@ "data/TursoConfig:mode", "data/TursoConfig:sync", "data/TursoConfig:syncUrl", - "data/TursoConfig:timeout", + "data/TursoConfig:timeout [RETIRED]", + "data/TursoConfig:timeoutMs", "data/TursoConfig:url" ] } diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 7a9cbfe364..04e2dcceb3 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -6,7 +6,8 @@ "integration/CircuitBreakerConfig:failureThreshold", "integration/CircuitBreakerConfig:fallbackStrategy", "integration/CircuitBreakerConfig:halfOpenMaxRequests", - "integration/CircuitBreakerConfig:monitoringWindow", + "integration/CircuitBreakerConfig:monitoringWindow [RETIRED]", + "integration/CircuitBreakerConfig:monitoringWindowMs", "integration/CircuitBreakerConfig:resetTimeoutMs", "integration/Connector:_lock", "integration/Connector:_lockDocsUrl", @@ -64,7 +65,8 @@ "integration/ConnectorInstanceBearerAuth:type", "integration/ConnectorInstanceNoAuth:type", "integration/ConnectorTrigger:description", - "integration/ConnectorTrigger:interval", + "integration/ConnectorTrigger:interval [RETIRED]", + "integration/ConnectorTrigger:intervalSeconds", "integration/ConnectorTrigger:key", "integration/ConnectorTrigger:label", "integration/ConnectorTrigger:type", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7b90554c15..c17200d3b5 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -256,7 +256,8 @@ "ui/Dashboard:name", "ui/Dashboard:performance [RETIRED]", "ui/Dashboard:protection", - "ui/Dashboard:refreshInterval", + "ui/Dashboard:refreshInterval [RETIRED]", + "ui/Dashboard:refreshIntervalSeconds", "ui/Dashboard:widgets", "ui/DashboardHeader:actions", "ui/DashboardHeader:showDescription", diff --git a/packages/spec/liveness/dashboard.json b/packages/spec/liveness/dashboard.json index e0cedc8009..af8580e934 100644 --- a/packages/spec/liveness/dashboard.json +++ b/packages/spec/liveness/dashboard.json @@ -160,9 +160,14 @@ "status": "live", "note": "objectui: DashboardRenderer.tsx:262 — grid gap." }, - "refreshInterval": { + "refreshIntervalSeconds": { "status": "live", - "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer." + "note": "objectui: DashboardRenderer.tsx:385-386 — sets a setInterval(onRefresh) every N seconds. Caveat: only fires when the host passes an `onRefresh` handler; the renderer is the consumer. RENAMED 2026-09-05 (#15680, #14478 ruling B) from `refreshInterval`: the unit lived only in the describe prose. The consumer is in ANOTHER REPOSITORY, so unlike every other rename in this stack its reader could not move in the same PR — objectui at pin a472b07167a3 reads `schema.refreshInterval` at packages/plugin-dashboard/src/DashboardRenderer.tsx:448-453 and DashboardGridLayout.tsx:146-151, multiplies by 1000, and publishes the input name at packages/plugin-dashboard/src/index.tsx:94 (which is what sdui.manifest.json:805 in this repo records). Those sites move in a follow-up objectui card sequenced BEHIND a release that ships this rename; until then the renderer sees an absent key and simply does not start its timer." + }, + "refreshInterval": { + "status": "dead", + "verifiedAt": "2026-09-05", + "note": "REMOVED 2026-09-05 (#15680, #14478 ruling B) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of stored sources by the protocol-18 conversion `dashboard-refresh-interval-to-refresh-interval-seconds`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `refreshIntervalSeconds` — rename the key, the value (seconds) is unchanged, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/ui/dashboard.zod.ts#refreshInterval. The three rename-hint aliases `refresh` / `autoRefresh` / `pollInterval` were repointed to the new spelling in the same edit." }, "globalFilters": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 207b7becff..41cec46c46 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -41,7 +41,7 @@ for both corollaries. | `page` | 23 | 0 | 0 | 0 | 1 | 24 | | `view` | 79 | 0 | 0 | 9 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 34 | 0 | 0 | 7 | 0 | 41 | +| `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **845** | **5** | **1** | **93** | **12** | **956** | +| **total** | **845** | **5** | **1** | **94** | **12** | **957** | diff --git a/packages/spec/src/ai/conversation.test.ts b/packages/spec/src/ai/conversation.test.ts index e39bc7d5ba..8be1935b12 100644 --- a/packages/spec/src/ai/conversation.test.ts +++ b/packages/spec/src/ai/conversation.test.ts @@ -427,7 +427,7 @@ describe('ConversationAnalyticsSchema', () => { summarizationEvents: 1, tokensSavedByPruning: 500, tokensSavedBySummarization: 2000, - duration: 1800, + durationSeconds: 1800, firstMessageAt: '2024-01-15T10:00:00Z', lastMessageAt: '2024-01-15T10:30:00Z', }; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 5dad0b918c..07e13ffe12 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8683,6 +8683,317 @@ const apiEndpointCacheTtlToCacheTtlSeconds: MetadataConversion = { }, }; +/** + * `dashboards[].refreshInterval` → `refreshIntervalSeconds` (protocol 18, + * #15680 for #14478) — the `ui` half of the same rename + * {@link hookTimeoutToTimeoutMs} documents, and the one key in this whole stack + * whose CONSUMER lives in another repository. + * + * Three rename-hint aliases already pointed at the old spelling — `refresh`, + * `autoRefresh`, `pollInterval` — which is the measure of how many spellings + * authors reach for; none of them named a unit either, so every door into the + * key left the cadence ambiguous. All three were repointed at the schema in the + * same edit, so an author arriving through any of them is now prescribed the + * unit-carrying name. + * + * ⚠️ objectui's dashboard renderer reads this key and multiplies by 1000, and + * republishes it as a registry input the console offers to authors. That reader + * could not move in this PR, so unlike every other rename in this card the + * consumer lags by a release: this conversion is what keeps stored dashboards + * and `os migrate meta` correct in the meantime. + * + * Same posture as its siblings: retired from the load path, tombstoned at the + * schema, replayable here. + */ +const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { + id: 'dashboard-refresh-interval-to-refresh-interval-seconds', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'dashboard.refreshInterval', + summary: "dashboard key 'refreshInterval' → 'refreshIntervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'dashboards', (dashboard, path) => { + const renamed = renameKey(dashboard, 'refreshInterval', 'refreshIntervalSeconds'); + if (!renamed) return dashboard; + emit({ from: 'refreshInterval', to: 'refreshIntervalSeconds', path: `${path}.refreshIntervalSeconds` }); + return renamed; + }); + }, + fixture: { + before: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshInterval: 300 }, + // A dashboard that never authored the key keeps its identity (copy-on-write). + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + after: { + dashboards: [ + { name: 'sales_overview', label: 'Sales Overview', widgets: [], refreshIntervalSeconds: 300 }, + { name: 'ops_overview', label: 'Ops Overview', widgets: [] }, + ], + }, + expectedNotices: 1, + }, +}; + +/** + * The two connector duration keys whose name carried no unit → suffixed + * (protocol 18, #15680 for #14478): `health.circuitBreaker.monitoringWindow` → + * `monitoringWindowMs`, and `triggers[].interval` → `intervalSeconds`. + * + * One entry because they are one authored document and one authoring session — + * a connector and the resilience block that guards it. The circuit-breaker case + * is the sharpest in this card: `monitoringWindow` (ms) sat ONE key below + * `resetTimeoutMs`, which already spelled its unit, so a single six-key shape + * carried both conventions and a reader had no rule to apply, only two examples + * that disagreed. The trigger case is the widest: the bare token `interval` + * means MILLISECONDS elsewhere in this same spec, so the identical spelling + * carried two units a thousandfold apart. + * + * A published connector row lands whole in `sys_metadata` (`ConnectorSchema`'s + * own docblock says so, which is why #7990 forbids inline secrets on it), so + * the chain has a seam that sees both keys — hence a conversion rather than the + * semantic entries this card's two runtime-emitted keys took. + * + * The two are walked in one pass but emit SEPARATELY: a connector may author + * either, both, or neither, and an operator reading the notice list needs to see + * which of its own keys moved. Retired from the load path, tombstoned at the + * schema, replayable here. + */ +const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { + id: 'connector-health-and-trigger-durations-unit-in-key', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.health.circuitBreaker.monitoringWindow, connector.triggers[].interval', + summary: "connector keys 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs' and 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; both values are unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'connectors', (connector, path) => { + let next = connector; + + const health = next.health; + if (isDict(health)) { + const breaker = health.circuitBreaker; + if (isDict(breaker)) { + const renamedBreaker = renameKey(breaker, 'monitoringWindow', 'monitoringWindowMs'); + if (renamedBreaker) { + emit({ + from: 'monitoringWindow', + to: 'monitoringWindowMs', + path: `${path}.health.circuitBreaker.monitoringWindowMs`, + }); + next = { ...next, health: { ...health, circuitBreaker: renamedBreaker } }; + } + } + } + + const triggers = next.triggers; + if (Array.isArray(triggers)) { + let triggersChanged = false; + const nextTriggers = triggers.map((trigger, i) => { + if (!isDict(trigger)) return trigger; + const renamed = renameKey(trigger, 'interval', 'intervalSeconds'); + if (!renamed) return trigger; + emit({ + from: 'interval', + to: 'intervalSeconds', + path: `${path}.triggers[${i}].intervalSeconds`, + }); + triggersChanged = true; + return renamed; + }); + if (triggersChanged) next = { ...next, triggers: nextTriggers }; + } + + return next; + }); + }, + fixture: { + before: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindow: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }, + // A webhook trigger authors no interval and keeps its identity. + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + // A connector that authored neither key keeps its identity (copy-on-write). + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + after: { + connectors: [ + { + name: 'billing_api', + label: 'Billing API', + type: 'rest', + health: { + circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindowMs: 120000 }, + }, + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.persistence.autoSaveInterval` → `autoSaveIntervalMs` + * for the memory driver (protocol 18, #15680 for #14478). + * + * BOTH arms of the persistence union move, and that is the load-bearing detail. + * The gate listed only the `file` arm, because the `auto` arm's describe named + * no unit at all and the predicate judges prose against name. But `auto` + * resolves to the same Node.js file adapter and forwards the same value to the + * same `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound — so converting one arm and not the other would leave + * ONE value with TWO spellings across sibling arms of one union, and the driver + * reading both. That is the consumer-side dialect Prime Directive #12 forbids. + * + * Driver-awareness is load-bearing here for the reason + * {@link datasourceConfigDriverKeyAliases} states: `persistence` is a memory + * driver key, and a `persistence` block under some other driver is not this + * shape. `resolveDriverId` keeps the rewrite where it belongs. + * + * A string `persistence` (`'file'` / `'local'` / `'auto'`) and a custom-adapter + * block carry no interval and pass through untouched. + */ +const memoryPersistenceAutoSaveIntervalToMs: MetadataConversion = { + id: 'memory-persistence-auto-save-interval-to-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.persistence.autoSaveInterval', + summary: "memory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'memory') return ds; + const config = ds.config; + if (!isDict(config)) return ds; + const persistence = config.persistence; + if (!isDict(persistence)) return ds; + const renamed = renameKey(persistence, 'autoSaveInterval', 'autoSaveIntervalMs'); + if (!renamed) return ds; + emit({ + from: 'autoSaveInterval', + to: 'autoSaveIntervalMs', + path: `${path}.config.persistence.autoSaveIntervalMs`, + }); + return { ...ds, config: { ...config, persistence: renamed } }; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveInterval: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveInterval: 5000 } }, + }, + // A memory datasource with string persistence carries no interval. + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + // Another driver's config is not this shape and is never touched. + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + after: { + datasources: [ + { + name: 'local_cache', + driver: 'memory', + config: { persistence: { type: 'file', path: '/var/data/db.json', autoSaveIntervalMs: 5000 } }, + }, + { + name: 'auto_cache', + driver: 'memory', + config: { persistence: { type: 'auto', autoSaveIntervalMs: 5000 } }, + }, + { name: 'scratch', driver: 'memory', config: { persistence: 'file' } }, + { name: 'primary', driver: 'postgres', config: { url: 'postgres://db/app' } }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * `datasources[].config.timeout` → `timeoutMs` for the turso driver (protocol + * 18, #15680 for #14478). + * + * Driver-awareness is load-bearing exactly as it is for + * {@link datasourceConfigDriverKeyAliases}: a bare `config.timeout` under some + * OTHER driver is that driver's own key and must not be touched, so the rewrite + * is gated on `resolveDriverId(ds.driver) === 'turso'` (which also catches a + * stored `driver: 'libsql'` through the alias table). + * + * The neighbour is why this key was worth the rename: `sync.intervalSeconds`, + * two keys above, already spelled ITS unit. One shape, both conventions. + * + * ⚠️ This converts the SPEC's turso contract. The driver package ships its own + * parallel `turso.zod.ts` whose `timeout` is outside this card's declared + * population; it is renamed by the card that widens that population, and until + * then the two declarations disagree by design. + */ +const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { + id: 'turso-config-timeout-to-timeout-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'datasource.config.timeout (turso)', + summary: "turso datasource key 'config.timeout' → 'config.timeoutMs' (#14478 — the unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + if (resolveDriverId(ds.driver) !== 'turso') return ds; + const renamed = renameConfigKey(ds, 'timeout', 'timeoutMs'); + if (!renamed) return ds; + emit({ from: 'timeout', to: 'timeoutMs', path: `${path}.config.timeoutMs` }); + return renamed; + }); + }, + fixture: { + before: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeout: 30000 }, + }, + // A turso datasource that never authored the key keeps its identity. + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + // `timeout` under another driver is that driver's own key — untouched. + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + after: { + datasources: [ + { + name: 'edge_db', + driver: 'turso', + config: { url: 'libsql://app.turso.io', timeoutMs: 30000 }, + }, + { name: 'edge_replica', driver: 'turso', config: { url: 'libsql://replica.turso.io' } }, + { name: 'legacy', driver: 'mysql', config: { url: 'mysql://db/app', timeout: 1000 } }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8775,6 +9086,10 @@ export const CONVERSIONS_BY_MAJOR: Readonly { consistency: 'quorum' as const, readFromSecondary: true, projection: { name: 1, email: 1, _id: 0 }, - timeout: 5000, + timeoutMs: 5000, useCursor: true, batchSize: 100, profile: true, @@ -232,7 +232,7 @@ describe('NoSQL Driver Protocol', () => { }; const result = NoSQLQueryOptionsSchema.parse(options); - expect(result.timeout).toBe(5000); + expect(result.timeoutMs).toBe(5000); expect(result.batchSize).toBe(100); expect(result.hint).toBe('name_1_email_1'); }); diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index ca80929848..0172042886 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -96,18 +96,18 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }, }); expect(config.persistence).toBeDefined(); - const p = config.persistence as { type: 'file'; path?: string; autoSaveInterval: number }; + const p = config.persistence as { type: 'file'; path?: string; autoSaveIntervalMs: number }; expect(p.type).toBe('file'); expect(p.path).toBe('/tmp/data.json'); - expect(p.autoSaveInterval).toBe(10000); + expect(p.autoSaveIntervalMs).toBe(10000); }); - it('should apply file persistence autoSaveInterval default', () => { + it('should apply file persistence autoSaveIntervalMs default', () => { const config = MemoryConfigSchema.parse({ persistence: { type: 'file', @@ -115,8 +115,8 @@ describe('MemoryConfigSchema', () => { }, }); - const p = config.persistence as { type: 'file'; autoSaveInterval: number }; - expect(p.autoSaveInterval).toBe(2000); + const p = config.persistence as { type: 'file'; autoSaveIntervalMs: number }; + expect(p.autoSaveIntervalMs).toBe(2000); }); it('should accept persistence with local object config', () => { @@ -138,15 +138,15 @@ describe('MemoryConfigSchema', () => { type: 'auto', path: '/var/data/memory.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }, }); - const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveInterval?: number }; + const p = config.persistence as { type: 'auto'; path?: string; key?: string; autoSaveIntervalMs?: number }; expect(p.type).toBe('auto'); expect(p.path).toBe('/var/data/memory.json'); expect(p.key).toBe('myapp:db'); - expect(p.autoSaveInterval).toBe(5000); + expect(p.autoSaveIntervalMs).toBe(5000); }); it('should accept auto persistence without overrides', () => { @@ -208,7 +208,7 @@ describe('MemoryConfigSchema', () => { persistence: { type: 'file', path: '/var/data/memory.json', - autoSaveInterval: 3000, + autoSaveIntervalMs: 3000, }, }); @@ -218,12 +218,12 @@ describe('MemoryConfigSchema', () => { expect(p.path).toBe('/var/data/memory.json'); }); - it('should reject file persistence with invalid autoSaveInterval', () => { + it('should reject file persistence with invalid autoSaveIntervalMs', () => { expect(() => MemoryConfigSchema.parse({ persistence: { type: 'file', path: '/tmp/data.json', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 }, })).toThrow(); }); @@ -270,21 +270,21 @@ describe('FilePersistenceConfigSchema', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', - autoSaveInterval: 10000, + autoSaveIntervalMs: 10000, }); expect(config.type).toBe('file'); expect(config.path).toBe('/data/store.json'); - expect(config.autoSaveInterval).toBe(10000); + expect(config.autoSaveIntervalMs).toBe(10000); }); - it('should apply default autoSaveInterval', () => { + it('should apply default autoSaveIntervalMs', () => { const config = FilePersistenceConfigSchema.parse({ type: 'file', path: '/data/store.json', }); - expect(config.autoSaveInterval).toBe(2000); + expect(config.autoSaveIntervalMs).toBe(2000); }); it('should accept without path (uses default)', () => { @@ -426,7 +426,7 @@ describe('AutoPersistenceConfigSchema', () => { expect(config.type).toBe('auto'); expect(config.path).toBeUndefined(); expect(config.key).toBeUndefined(); - expect(config.autoSaveInterval).toBeUndefined(); + expect(config.autoSaveIntervalMs).toBeUndefined(); }); it('should accept auto config with all overrides', () => { @@ -434,19 +434,19 @@ describe('AutoPersistenceConfigSchema', () => { type: 'auto', path: '/data/store.json', key: 'myapp:db', - autoSaveInterval: 5000, + autoSaveIntervalMs: 5000, }); expect(config.type).toBe('auto'); expect(config.path).toBe('/data/store.json'); expect(config.key).toBe('myapp:db'); - expect(config.autoSaveInterval).toBe(5000); + expect(config.autoSaveIntervalMs).toBe(5000); }); - it('should reject auto config with invalid autoSaveInterval', () => { + it('should reject auto config with invalid autoSaveIntervalMs', () => { expect(() => AutoPersistenceConfigSchema.parse({ type: 'auto', - autoSaveInterval: 50, // Below minimum of 100 + autoSaveIntervalMs: 50, // Below minimum of 100 })).toThrow(); }); @@ -454,7 +454,7 @@ describe('AutoPersistenceConfigSchema', () => { it('rejects an unrecognised key instead of silently stripping it', () => { const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', - interval: 5000, // meant `autoSaveInterval`, not a real field + interval: 5000, // meant `autoSaveIntervalMs`, not a real field }); expect(result.success).toBe(false); @@ -486,7 +486,7 @@ describe('MemoryPersistenceConfigSchema', () => { type: 'file', path: '/tmp/data.json', }); - expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveInterval: 2000 }); + expect(config).toEqual({ type: 'file', path: '/tmp/data.json', autoSaveIntervalMs: 2000 }); }); it('should accept local object config', () => { diff --git a/packages/spec/src/data/driver/memory.zod.ts b/packages/spec/src/data/driver/memory.zod.ts index 16b43116e0..eb82214af9 100644 --- a/packages/spec/src/data/driver/memory.zod.ts +++ b/packages/spec/src/data/driver/memory.zod.ts @@ -203,8 +203,28 @@ export const AutoPersistenceConfigSchema = lazySchema(() => strictObject( * `file` branch's `path`; the auto-detected file adapter resolves nothing. */ path: placeholderFree(z.string(), 'persistence.path').optional().describe('File path override for Node.js environments'), - /** Auto-save interval override when running in Node.js. */ - autoSaveInterval: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments'), + /** + * Auto-save interval override when running in Node.js, in milliseconds. + * + * Renamed from `autoSaveInterval` alongside the `file` arm's key (#15680, + * ruling B on #14478). It is not a second key: `type: 'auto'` resolves to + * the same Node.js file adapter, and this value is forwarded to the same + * `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + * same `min(100)` bound. Renaming one arm and not the other would have left + * ONE value with TWO spellings across sibling arms of one union — and the + * driver reading both, which is the consumer-side dialect Prime Directive + * #12 forbids. + */ + autoSaveIntervalMs: z.number().min(100).optional().describe('Auto-save interval override for Node.js environments, in milliseconds'), + + /** Tombstone for the rename above (#15680, ruling B on #14478). */ + autoSaveInterval: retiredKey( + '`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs` in ' + + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' + + 'only in the describe prose. Rename the key to `autoSaveIntervalMs`; the value ' + + '(milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * localStorage key override when running in a browser. * `${…}` placeholder syntax is refused (#8495) — same judgment as the diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 7facb9df07..90b23883e0 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -27,7 +27,7 @@ describe('TursoConfigSchema', () => { { url: 'file:./data/objectstack.db' }, { url: ':memory:' }, { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, - { url: 'libsql://x.turso.io', concurrency: 10, timeout: 5000, mode: 'remote' }, + { url: 'libsql://x.turso.io', concurrency: 10, timeoutMs: 5000, mode: 'remote' }, ]) { const result = TursoConfigSchema.safeParse(config); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index cb1fb8ef0f..4113e83ca6 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -558,7 +558,7 @@ describe('CircuitBreakerConfigSchema', () => { expect(config.failureThreshold).toBe(5); expect(config.resetTimeoutMs).toBe(30000); expect(config.halfOpenMaxRequests).toBe(1); - expect(config.monitoringWindow).toBe(60000); + expect(config.monitoringWindowMs).toBe(60000); }); it('should accept full circuit breaker config', () => { @@ -567,7 +567,7 @@ describe('CircuitBreakerConfigSchema', () => { failureThreshold: 10, resetTimeoutMs: 60000, halfOpenMaxRequests: 3, - monitoringWindow: 120000, + monitoringWindowMs: 120000, fallbackStrategy: 'cache', }); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts new file mode 100644 index 0000000000..be10bd55db --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ai__ConversationAnalytics__duration.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): +// a duration-shaped `z.number()` key carries its unit in its NAME, and no +// existing offender is grandfathered. `ConversationAnalytics.duration` said +// "Session duration in seconds" in prose and nothing else, on a shape where +// every OTHER number is a count (messages, tokens, pruning events) and the two +// neighbouring instants already spell themselves `firstMessageAt` / +// `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. +// Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare +// deletion would strip the key in silence and the analytics row would lose the +// one measurement it carries, with no error anywhere. No D2 conversion: +// conversation analytics are computed and emitted at runtime, never authored +// and never a stored `sys_metadata` row, so the chain has no seam that sees +// one. See `ai-conversation-analytics-duration-unit-in-key`. +export const entry = 'ai/ConversationAnalytics:duration'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..cccb824566 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__AutoPersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card +// that the gate did NOT list. It is here because it is not a second key: the +// `auto` persistence arm resolves to the same Node.js file adapter as the +// `file` arm, and this value is forwarded to the same +// `FileSystemPersistenceAdapter` field, in the same milliseconds, under the +// same `min(100)` bound. Its describe named no unit at all, which is why the +// predicate skipped it — and precisely why renaming only the `file` arm would +// have left ONE value with TWO spellings across sibling arms of one union, with +// the driver reading both. That is the consumer-side dialect Prime Directive +// #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` +// and its describe now names the unit too. Tombstoned with `retiredKey()`; +// covered by `memory-persistence-auto-save-interval-to-ms`, which converts both +// arms in one pass. +export const entry = 'data/AutoPersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts new file mode 100644 index 0000000000..1b8cb1904b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__FilePersistenceConfig__autoSaveInterval.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in +// prose and nothing else. Its `min(100)` bound is what made the bare name +// dangerous rather than merely untidy: 100 reads as a plausible number of +// SECONDS, so an author who guessed the unit wrong cleared the bound, was +// refused nowhere, and saved a thousand times more often than intended. +// Renamed to `autoSaveIntervalMs`; the value and the 2000 default are +// unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so +// a bare deletion is not silent, but an unknown-key rejection cannot carry the +// FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 +// conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource +// is a `datasources[]` stack collection member whose `config` is stored whole in +// `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'data/FilePersistenceConfig:autoSaveInterval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts new file mode 100644 index 0000000000..7f191b9d52 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__NoSQLQueryOptions__timeout.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` +// said "Query timeout (ms)" in prose and nothing else, directly beside +// `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a +// unit and one not, with nothing at the call site to tell them apart. Renamed +// to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape is not `.strict()`, so a bare deletion would strip in silence and the +// query would run without the limit its author set. No D2 conversion: query +// options are a per-call driver argument reached only through +// `AggregationPipeline.options`, which no `stack.zod.ts` collection declares +// and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. +export const entry = 'data/NoSQLQueryOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts new file mode 100644 index 0000000000..181d258858 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__TursoConfig__timeout.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said +// "Operation timeout in milliseconds" in prose and carried a `.meta({ title: +// 'Timeout (ms)' })` no parse reads — and sat two keys below +// `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying +// both conventions, and the suffixed one was the honest half. Renamed to +// `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the +// shape IS `strictObject`, so the tombstone is here for the prescription an +// unknown-key rejection cannot carry. Covered by the D2 conversion +// `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` +// stack collection member whose `config` is stored whole in `sys_metadata`. +// ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). +// The driver package ships its own parallel `turso.zod.ts` whose `timeout` is +// outside this card's declared population and is renamed by the card that +// widens that population. +export const entry = 'data/TursoConfig:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts new file mode 100644 index 0000000000..4e6b33bcfa --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. +// `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure +// count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, +// which already spelled its unit, on the same six-key shape. That is the +// sharpest case in this card: a single schema already carried both +// conventions, so a reader had no rule to apply, only two examples that +// disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default +// are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, +// so a bare deletion would strip in silence and the breaker would fall back to +// its default window while the author believed they had widened it. Covered by +// the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: +// `connectors:` is a stack collection and a published connector row lands whole +// in `sys_metadata`, so the chain has a seam that sees it. +export const entry = 'integration/CircuitBreakerConfig:monitoringWindow'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts new file mode 100644 index 0000000000..724af5404b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` +// said "Polling interval in seconds" in prose and nothing else. A polling +// cadence is exactly the number a reader guesses at, and the bare name `interval` +// means MILLISECONDS elsewhere in this same spec — the identical spelling +// carrying two units a thousandfold apart is the collision that got this whole +// population ruled rather than merely noted. Renamed to `intervalSeconds`; the +// value is unchanged. Tombstoned with `retiredKey()`; the shape is not +// `.strict()`, so a bare deletion would strip in silence. Covered by the D2 +// conversion `connector-health-and-trigger-durations-unit-in-key`. +// ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven +// by it). The rename does not change that; it makes the declaration honest +// about its unit for whoever implements the loop. +export const entry = 'integration/ConnectorTrigger:interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts new file mode 100644 index 0000000000..e4bab96f3c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__Dashboard__refreshInterval.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` +// said "Auto-refresh interval in seconds" in prose and nothing else. The three +// rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — +// measure how many spellings authors actually reach for, and not one of them +// named a unit either, so every door into this key left the cadence ambiguous. +// All three were repointed to the new spelling in the same edit. Renamed to +// `refreshIntervalSeconds`; the value is unchanged. Tombstoned with +// `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the +// prescription an unknown-key rejection cannot carry. Covered by the D2 +// conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: +// `dashboards:` is a stack collection and a dashboard is a registered metadata +// kind stored as a row. +// ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's +// dashboard renderer reads this key and multiplies by 1000, and publishes it as +// a registry input, so its reader could not move in this PR the way every other +// reader in this card did. Sequenced as a follow-up card behind a release that +// actually ships the rename; until then the renderer sees an absent key and +// does not start its timer. +export const entry = 'ui/Dashboard:refreshInterval'; diff --git a/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts new file mode 100644 index 0000000000..df74927cb7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ai-conversation-analytics-duration-unit-in-key.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts new file mode 100644 index 0000000000..9ec8d4ebfc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.data-nosql-query-options-timeout-unit-in-key.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 113667b626..ac9c223ffb 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5347,7 +5347,20 @@ const step18: MigrationStep = { 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' + 'reference pages never published (#14519) — are retiredKey tombstones with a ' + - 'semantic entry each, naming the suffixed key.', + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' + + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' + + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' + + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds`, and the two ' + + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' + + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' + + 'same file adapter, so splitting them would have left one value with two spellings) ' + + 'and `turso config.timeout` → `timeoutMs` all convert, because a dashboard, a ' + + 'connector and a datasource are stack collection members stored as rows; the two with ' + + 'no seam — `ConversationAnalytics.duration`, computed at runtime and never authored, ' + + 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' + + 'tombstones with a semantic entry each. That remainder is what takes ' + + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' + + 'goes red again by design when its declared population widens beyond that subtree.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5368,6 +5381,10 @@ const step18: MigrationStep = { 'hook-timeout-to-timeout-ms', 'job-timeout-to-timeout-ms', 'api-endpoint-cache-ttl-to-cache-ttl-seconds', + 'dashboard-refresh-interval-to-refresh-interval-seconds', + 'connector-health-and-trigger-durations-unit-in-key', + 'memory-persistence-auto-save-interval-to-ms', + 'turso-config-timeout-to-timeout-ms', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -5527,6 +5544,38 @@ const step18: MigrationStep = { + 'tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ' + 'ever read the container, so removing it removes no behaviour.', }, + { + id: 'ai-conversation-analytics-duration-unit-in-key', + surface: 'ConversationAnalytics.duration, the emitted session length whose name carried no ' + + 'unit (ai/conversation.zod.ts)', + replacement: 'durationSeconds — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender in ai/ and the only one on its file. ' + + 'What makes the bare name worth a registry row rather than a quiet edit is the company ' + + 'it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, ' + + 'totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field ' + + 'that carried a unit was the one field that did not say so, sitting in a block of ' + + 'twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, ' + + 'already spelled themselves; the measurement between them did not. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'an emitter writing the old spelling would lose the value with no error anywhere. ' + + 'Why a semantic entry and not a D2 conversion: conversation analytics are computed at ' + + 'runtime and handed to a consumer, never authored by hand and never stored as a ' + + 'sys_metadata row, so the conversion chain has no seam that would ever see one — the ' + + 'same disposition every runtime-emitted measurement in this stack has taken. ' + + '#15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every ' + + 'consumer that reads a session length reads durationSeconds. Authoring duration fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription rather ' + + 'than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is ' + + 'the same half hour duration: 1800 was, the key stays optional, and the non-negative ' + + 'bound rides along with the renamed key so a negative session length is still refused. ' + + 'The migration is proved correct when no source in the tree spells a bare duration on ' + + 'this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of ' + + 'them has read a count as a duration and over-applied the rule.', + }, { id: 'analytics-authorable-unknown-keys-refused', // Same-major bookkeeping (#10414): batch D also closed the nested @@ -6101,6 +6150,33 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'data-nosql-query-options-timeout-unit-in-key', + surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' + + 'unit (data/driver-nosql.zod.ts)', + replacement: 'timeoutMs — rename the key; the value is unchanged', + reason: + 'Maintainer ruling B on #14478 (2026-09-02, decision batch #43): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. ' + + 'It stands alone because it is the only offender on its file. The neighbour is what ' + + 'makes it a real hazard rather than a naming preference: batchSize sits directly beside ' + + 'it, a plain row COUNT with the same z.number().int().positive() shape and the same ' + + 'order of magnitude, so two adjacent bare integers meant milliseconds and documents ' + + 'respectively with nothing at the call site to separate them. Tombstoned with ' + + 'retiredKey(); the shape is not strict, so a bare deletion would strip in silence and ' + + 'the query would run with no deadline at all while its author believed one was set — ' + + 'the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 ' + + 'conversion: these options are a per-call driver argument, reached only through ' + + 'AggregationPipeline.options, which no stack.zod.ts collection declares and no ' + + 'sys_metadata row stores, so the chain has no seam. #15680, #14478, ADR-0087.', + acceptanceCriteria: + 'Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails ' + + 'to compile (input type `never`) and fails to parse with the rename prescription. ' + + 'Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and ' + + 'the positive-integer bound rides along with the renamed key so a zero or negative ' + + 'deadline is still refused. Two neighbours on this same shape deliberately do NOT move, ' + + 'and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of ' + + 'documents, not a duration, and consistency / projection / hint are not numbers at all.', + }, { id: 'datasource-config-mongo-options-credential-refused', surface: 'datasource.config.options.auth.password (mongodb) — a login credential written ' + @@ -9653,6 +9729,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // entry id by `gen:migration-registry` (#7297). Add an entry by adding a // FILE — never by editing between the markers, which is generated. // + // #15680 (stack card 5/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, and no + // existing offender is grandfathered. `ConversationAnalytics.duration` said + // "Session duration in seconds" in prose and nothing else, on a shape where + // every OTHER number is a count (messages, tokens, pruning events) and the two + // neighbouring instants already spell themselves `firstMessageAt` / + // `lastMessageAt`. Renamed to `durationSeconds`; the value is unchanged. + // Tombstoned with `retiredKey()` — the shape is not `.strict()`, so a bare + // deletion would strip the key in silence and the analytics row would lose the + // one measurement it carries, with no error anywhere. No D2 conversion: + // conversation analytics are computed and emitted at runtime, never authored + // and never a stored `sys_metadata` row, so the chain has no seam that sees + // one. See `ai-conversation-analytics-duration-unit-in-key`. + 'ai/ConversationAnalytics:duration', // #15677 (stack card 2/6 of #14478) — maintainer ruling 2026-09-02 ("ruled B"): // a duration-shaped `z.number()` key carries its unit in its NAME, and no // existing offender is grandfathered. `ApiEndpoint.cacheTtl` said "Response @@ -10085,6 +10175,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // construction configuration, never a stored row; the semantic entry // `websocket-durations-unit-in-key` carries the prescription. 'api/WebSocketServerConfig:heartbeatInterval', + // #15680 (stack card 5/6 of #14478) — ruling B, and the one key in this card + // that the gate did NOT list. It is here because it is not a second key: the + // `auto` persistence arm resolves to the same Node.js file adapter as the + // `file` arm, and this value is forwarded to the same + // `FileSystemPersistenceAdapter` field, in the same milliseconds, under the + // same `min(100)` bound. Its describe named no unit at all, which is why the + // predicate skipped it — and precisely why renaming only the `file` arm would + // have left ONE value with TWO spellings across sibling arms of one union, with + // the driver reading both. That is the consumer-side dialect Prime Directive + // #12 forbids, so the two arms move together. Renamed to `autoSaveIntervalMs` + // and its describe now names the unit too. Tombstoned with `retiredKey()`; + // covered by `memory-persistence-auto-save-interval-to-ms`, which converts both + // arms in one pass. + 'data/AutoPersistenceConfig:autoSaveInterval', // #14478 — maintainer ruling 2026-09-02 ("ruled B"): the unit of a // duration-shaped `z.number()` key lives in the key name, and no existing // offender is grandfathered. `DriverOptions.timeout` said "Timeout in ms" in @@ -10097,6 +10201,20 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `driver-options-timeout-to-timeout-ms` carries the prescription. Registered // under 18 for the launch-window reason its neighbours state. 'data/DriverOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `FilePersistenceConfig.autoSaveInterval` said "Auto-save interval in ms" in + // prose and nothing else. Its `min(100)` bound is what made the bare name + // dangerous rather than merely untidy: 100 reads as a plausible number of + // SECONDS, so an author who guessed the unit wrong cleared the bound, was + // refused nowhere, and saved a thousand times more often than intended. + // Renamed to `autoSaveIntervalMs`; the value and the 2000 default are + // unchanged. Tombstoned with `retiredKey()` — this shape IS `strictObject`, so + // a bare deletion is not silent, but an unknown-key rejection cannot carry the + // FROM → TO mapping, which is the whole payload of a rename. Covered by the D2 + // conversion `memory-persistence-auto-save-interval-to-ms`: a memory datasource + // is a `datasources[]` stack collection member whose `config` is stored whole in + // `sys_metadata`, so the chain has a seam that sees it. + 'data/FilePersistenceConfig:autoSaveInterval', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a @@ -10124,6 +10242,46 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // conversion `metric-filters-removed`, which strips the key from every metric // in `analyticsCubes[].measures`. 'data/Metric:filters', + // #15680 (stack card 5/6 of #14478) — ruling B. `NoSQLQueryOptions.timeout` + // said "Query timeout (ms)" in prose and nothing else, directly beside + // `batchSize`, a plain row COUNT: two bare numbers side by side, one carrying a + // unit and one not, with nothing at the call site to tell them apart. Renamed + // to `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape is not `.strict()`, so a bare deletion would strip in silence and the + // query would run without the limit its author set. No D2 conversion: query + // options are a per-call driver argument reached only through + // `AggregationPipeline.options`, which no `stack.zod.ts` collection declares + // and no `sys_metadata` row stores. See `data-nosql-query-options-timeout-unit-in-key`. + 'data/NoSQLQueryOptions:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. `TursoConfig.timeout` said + // "Operation timeout in milliseconds" in prose and carried a `.meta({ title: + // 'Timeout (ms)' })` no parse reads — and sat two keys below + // `sync.intervalSeconds`, which already spelled ITS unit. One shape carrying + // both conventions, and the suffixed one was the honest half. Renamed to + // `timeoutMs`; the value is unchanged. Tombstoned with `retiredKey()`; the + // shape IS `strictObject`, so the tombstone is here for the prescription an + // unknown-key rejection cannot carry. Covered by the D2 conversion + // `turso-config-timeout-to-timeout-ms`: a turso datasource is a `datasources[]` + // stack collection member whose `config` is stored whole in `sys_metadata`. + // ⚠️ This is the SPEC's turso contract (`packages/spec/src/data/driver/turso.zod.ts`). + // The driver package ships its own parallel `turso.zod.ts` whose `timeout` is + // outside this card's declared population and is renamed by the card that + // widens that population. + 'data/TursoConfig:timeout', + // #15680 (stack card 5/6 of #14478) — ruling B. + // `CircuitBreakerConfig.monitoringWindow` said "Rolling window for failure + // count in ms" in prose and nothing else — ONE key below `resetTimeoutMs`, + // which already spelled its unit, on the same six-key shape. That is the + // sharpest case in this card: a single schema already carried both + // conventions, so a reader had no rule to apply, only two examples that + // disagreed. Renamed to `monitoringWindowMs`; the value and the 60000 default + // are unchanged. Tombstoned with `retiredKey()` — the shape is not `.strict()`, + // so a bare deletion would strip in silence and the breaker would fall back to + // its default window while the author believed they had widened it. Covered by + // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: + // `connectors:` is a stack collection and a published connector row lands whole + // in `sys_metadata`, so the chain has a seam that sees it. + 'integration/CircuitBreakerConfig:monitoringWindow', // #14676 — ADR-0049 enforce-or-remove on `ConnectorSchema.errorMapping` (triage // ruling 2026-09-02: removal via the `spec-property-retirement` playbook; the // split condition — a downstream consumer in objectui or a customer stack — @@ -10151,6 +10309,19 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #12497 / #13823 grading). 'integration/Connector:errorMapping', + // #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` + // said "Polling interval in seconds" in prose and nothing else. A polling + // cadence is exactly the number a reader guesses at, and the bare name `interval` + // means MILLISECONDS elsewhere in this same spec — the identical spelling + // carrying two units a thousandfold apart is the collision that got this whole + // population ruled rather than merely noted. Renamed to `intervalSeconds`; the + // value is unchanged. Tombstoned with `retiredKey()`; the shape is not + // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 + // conversion `connector-health-and-trigger-durations-unit-in-key`. + // ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven + // by it). The rename does not change that; it makes the declaration honest + // about its unit for whoever implements the loop. + 'integration/ConnectorTrigger:interval', // #14676 — the same tombstone seen through the second carrier. // `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the // `errorMapping` tombstone on the base is inherited by the shape that @@ -11453,6 +11624,25 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // parse) and the D3 semantic entry named below. // D3 semantic entry: `training-deadline-keys-retired`. 'system/TrainingPlan:reminderDaysBefore', + // #15680 (stack card 5/6 of #14478) — ruling B. `dashboard.refreshInterval` + // said "Auto-refresh interval in seconds" in prose and nothing else. The three + // rename-hint aliases beside it — `refresh`, `autoRefresh`, `pollInterval` — + // measure how many spellings authors actually reach for, and not one of them + // named a unit either, so every door into this key left the cadence ambiguous. + // All three were repointed to the new spelling in the same edit. Renamed to + // `refreshIntervalSeconds`; the value is unchanged. Tombstoned with + // `retiredKey()`; the shape IS `strictObject`, so the tombstone is here for the + // prescription an unknown-key rejection cannot carry. Covered by the D2 + // conversion `dashboard-refresh-interval-to-refresh-interval-seconds`: + // `dashboards:` is a stack collection and a dashboard is a registered metadata + // kind stored as a row. + // ⚠️ Unique in this stack: the consumer is in ANOTHER REPOSITORY. objectui's + // dashboard renderer reads this key and multiplies by 1000, and publishes it as + // a registry input, so its reader could not move in this PR the way every other + // reader in this card did. Sequenced as a follow-up card behind a release that + // actually ships the rename; until then the renderer sees an absent key and + // does not start its timer. + 'ui/Dashboard:refreshInterval', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its // renderers/basic/elements.tsx header deferred the element to "owning plugins" diff --git a/packages/spec/src/ui/dashboard.form.ts b/packages/spec/src/ui/dashboard.form.ts index be4041f89e..e116b29a98 100644 --- a/packages/spec/src/ui/dashboard.form.ts +++ b/packages/spec/src/ui/dashboard.form.ts @@ -23,7 +23,7 @@ export const dashboardForm = defineForm({ fields: [ { field: 'columns', type: 'number', colSpan: 1, helpText: 'Grid columns (default 12)' }, { field: 'gap', type: 'number', colSpan: 1, helpText: 'Space between widgets, in steps of 0.25rem (4 = 1rem)' }, - { field: 'refreshInterval', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, + { field: 'refreshIntervalSeconds', type: 'number', colSpan: 1, helpText: 'Auto-refresh (seconds)' }, { field: 'header', type: 'composite', colSpan: 3, helpText: 'Dashboard header config (title, subtitle, actions)' }, ], }, diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 44750af4f2..80236e9a52 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -145,7 +145,7 @@ export const SalesDashboard: Dashboard = { label: 'Sales Performance', columns: 12, gap: 4, - refreshInterval: 180, // seconds; auto-refresh + refreshIntervalSeconds: 180, // auto-refresh cadence header: { showTitle: true, From e6701a10db9fc61d43f532bc13b2f0afbf53fba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 16:33:57 +0000 Subject: [PATCH 25/33] test(spec): tombstone refusal pins for the 7 renames; narrow credential derivation so a retirement tombstone is not read as a secret (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/src/ai/conversation.test.ts | 42 ++++++++++++++++++ .../datasource-credential-redaction.test.ts | 39 ++++++++++++++++ .../data/datasource-credential-redaction.ts | 43 +++++++++++++++++- packages/spec/src/data/driver-nosql.test.ts | 27 +++++++++++- packages/spec/src/data/driver/memory.test.ts | 42 ++++++++++++++++++ packages/spec/src/data/driver/turso.test.ts | 32 ++++++++++++++ .../spec/src/integration/connector.test.ts | 44 +++++++++++++++++++ packages/spec/src/ui/dashboard.test.ts | 40 ++++++++++++++++- 8 files changed, 305 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ai/conversation.test.ts b/packages/spec/src/ai/conversation.test.ts index 8be1935b12..67a38a4ee0 100644 --- a/packages/spec/src/ai/conversation.test.ts +++ b/packages/spec/src/ai/conversation.test.ts @@ -582,3 +582,45 @@ describe('Real-World Conversation Examples', () => { expect(() => ConversationSessionSchema.parse(session)).not.toThrow(); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — a bare throw assertion passes just as happily +// on the unrecognized-key error the rename is meant to replace. +// `ConversationAnalytics` is runtime-emitted, so the silent-strip alternative +// is the real hazard: this shape is not strict, and a producer still writing +// `duration` would have lost the one measurement on the row with no error at all. +describe('ConversationAnalytics.duration carries its unit (#15680)', () => { + const base = { + sessionId: 'session-1', + totalMessages: 10, + userMessages: 5, + assistantMessages: 5, + systemMessages: 0, + totalTokens: 1000, + averageTokensPerMessage: 100, + peakTokenUsage: 1000, + }; + + it('REFUSES the retired `duration` with the rename in the message', () => { + const result = ConversationAnalyticsSchema.safeParse({ ...base, duration: 1800 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'duration'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConversationAnalytics.duration` was renamed to `durationSeconds`'); + }); + + it('accepts `durationSeconds` at the same magnitude and still refuses a negative one', () => { + expect(ConversationAnalyticsSchema.parse({ ...base, durationSeconds: 1800 }).durationSeconds).toBe(1800); + expect(ConversationAnalyticsSchema.safeParse({ ...base, durationSeconds: -1 }).success).toBe(false); + }); + + it('leaves the twelve sibling COUNTS alone — a count has no unit to carry', () => { + const parsed = ConversationAnalyticsSchema.parse({ ...base, pruningEvents: 3, tokensSavedByPruning: 500 }); + expect(parsed.totalMessages).toBe(10); + expect(parsed.totalTokens).toBe(1000); + expect(parsed.pruningEvents).toBe(3); + expect(parsed.tokensSavedByPruning).toBe(500); + }); +}); diff --git a/packages/spec/src/data/datasource-credential-redaction.test.ts b/packages/spec/src/data/datasource-credential-redaction.test.ts index 0c040c8467..cd59041001 100644 --- a/packages/spec/src/data/datasource-credential-redaction.test.ts +++ b/packages/spec/src/data/datasource-credential-redaction.test.ts @@ -91,6 +91,45 @@ describe('derivation pin: the moved module derives EXACTLY what the service-data expect(redactableConfigKeys(undefined)).toEqual(fallback); }); + // #15680 (#14478 ruling B): a `retiredKey()` tombstone is ALSO a `z.never()`, + // and it is not a credential. Until the duration renames landed, no driver + // contract carried one, so "never ⇒ credential" held by accident of population + // rather than by construction — and the first tombstone to arrive + // (`TursoConfig.timeout` → `timeoutMs`) made the derivation answer that a + // millisecond budget was a secret, which redacts a duration off the read path + // and drags a non-credential name into the fallback list every unknown driver + // is scrubbed by. Excluded by the `[REMOVED] ` prefix `retiredKey()` itself + // stamps, so nothing here is a maintained list. + it('a retiredKey() tombstone is NOT derived as a refused credential', () => { + const tursoShape: any = (getDriverConfigSchema('turso') as any).shape; + const shape = typeof tursoShape === 'function' ? tursoShape() : tursoShape; + // The tombstone really is in the shape and really is a `z.never()` — without + // this leg the assertion below would pass just as well on a key that had + // been deleted outright, which is the silent strip the tombstone prevents. + expect(Object.keys(shape)).toContain('timeout'); + expect(String(shape.timeout.description)).toMatch(/^\[REMOVED\] /); + expect(refusedCredentialKeys('turso')).not.toContain('timeout'); + expect(redactableConfigKeys('turso')).not.toContain('timeout'); + // The credential beside it is untouched — the narrowing must not cost a + // single real refusal, which is the only direction that could leak. + expect(refusedCredentialKeys('turso')).toContain('authToken'); + }); + + it('the exclusion is negative, so an UNMARKED z.never() is still a credential', () => { + // Fail-safe direction. A future credential slot whose author forgets + // `refusedInlineCredentialKey`'s marker must still be scrubbed; only a key + // that has explicitly declared itself retired may drop out. Constructed, + // because no builtin driver ships a bare `z.never()` today. + const unmarked = z.object({ apiSecret: z.never().optional(), url: z.string() }); + expect(refusedCredentialPathsOfSchema(unmarked)).toEqual([['apiSecret']]); + + const tombstoned = z.object({ + legacyKey: z.never().optional().describe('[REMOVED] `legacyKey` was renamed to `legacyKeyMs`.'), + url: z.string(), + }); + expect(refusedCredentialPathsOfSchema(tombstoned)).toEqual([]); + }); + it('every z.never() key across every builtin driver is covered by the unknown-driver fallback', () => { // Guards the one hand-written canonical list: if a driver refuses a NEW // credential key, the fallback used for contract-less drivers must learn diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index 553deaf554..0bbfc19db0 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -28,6 +28,24 @@ * refusal list rather than re-typing it. A driver that refuses a new * credential key tomorrow is covered here the day it lands, which a * hand-maintained list in a consumer package would not be. + * + * ⚠️ **One exception, and it is declared on the schema:** a `retiredKey()` + * RETIREMENT TOMBSTONE is also a `z.never()`, and it is not a credential + * — it is a key that used to exist under another name. Until #15680 no + * driver contract carried one, so "never ⇒ credential" held by accident of + * population rather than by construction; the duration renames of #14478 + * put the first one in (`TursoConfig.timeout` → `timeoutMs`) and the + * derivation answered that a millisecond budget was a secret. Tombstones + * are excluded by the `[REMOVED] ` prefix `retiredKey()` itself stamps on + * the description — the producer's own marker, the same one the reference + * pages and the authorable-surface ratchet already read. + * + * The exclusion is deliberately NEGATIVE (skip declared tombstones) rather + * than POSITIVE (keep only keys marked `format: 'password'`), even though + * every credential slot in every builtin contract does carry that marker + * today. Under-redacting is the dangerous direction: a future credential + * key whose author forgets the marker must still be scrubbed, and only a + * key that has explicitly declared itself retired may drop out. * 2. **Former alias spellings** ({@link FORMER_CREDENTIAL_ALIASES}) — `passwd` * / `pwd` / `token` / `jwt` / `auth_token` / `authtoken` used to be * `aliases` that the parse RENAMED onto the canonical key; #8078 moved them @@ -247,6 +265,25 @@ function baseTypeOf(schema: unknown): string | undefined { return def?.type; } +/** + * Is this shape member a `retiredKey()` tombstone rather than a refused + * credential slot? Both are `z.never()`; only the tombstone carries the + * `[REMOVED] ` prescription prefix `retiredKey()` stamps on the description. + * + * Read from the member AND from its unwrapped base node: `retiredKey()` calls + * `.describe()` last, so the prefix sits on the outer `.optional()` clone, + * while a caller that re-wraps a tombstone could leave it further in. + */ +function isRetirementTombstone(member: unknown, node: unknown): boolean { + const described = (m: unknown): string | undefined => { + const d = (m as any)?.description; + if (typeof d === 'string') return d; + const meta = (m as any)?.meta?.(); + return typeof meta?.description === 'string' ? meta.description : undefined; + }; + return (described(member) ?? described(node) ?? '').startsWith('[REMOVED] '); +} + /** The `shape` record of an object-typed schema node, or `undefined`. */ function shapeOf(schema: unknown): Record | undefined { const raw = (schema as any)?.shape; @@ -279,7 +316,8 @@ export function refusedCredentialPathsOfSchema(schema: unknown): (readonly strin const def = node?.def ?? node?._def; const type: string | undefined = def?.type; if (type === 'never') { - out.push([...prefix, key]); + // A retirement tombstone is a `z.never()` that is not a credential. + if (!isRetirementTombstone(member, node)) out.push([...prefix, key]); continue; } if (type === 'object') { @@ -323,7 +361,8 @@ export function refusedCredentialKeys(driver: unknown): string[] { } if (!shape) return []; return Object.entries(shape) - .filter(([, member]) => baseTypeOf(member) === 'never') + .filter(([, member]) => baseTypeOf(member) === 'never' + && !isRetirementTombstone(member, baseNodeOf(member))) .map(([key]) => key); } diff --git a/packages/spec/src/data/driver-nosql.test.ts b/packages/spec/src/data/driver-nosql.test.ts index 26764d9d42..e71eb985bf 100644 --- a/packages/spec/src/data/driver-nosql.test.ts +++ b/packages/spec/src/data/driver-nosql.test.ts @@ -266,7 +266,7 @@ describe('NoSQL Driver Protocol', () => { }, ], options: { - timeout: 10000, + timeoutMs: 10000, }, }; @@ -366,3 +366,28 @@ describe('NoSQL Driver Protocol', () => { }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The shape is not strict, so without the +// tombstone a query authored with `timeout` would have run with NO deadline at +// all — the failure a driver timeout exists to prevent — and reported nothing. +describe('NoSQLQueryOptions.timeout carries its unit (#15680)', () => { + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = NoSQLQueryOptionsSchema.safeParse({ timeout: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`NoSQLQueryOptions.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(NoSQLQueryOptionsSchema.parse({ timeoutMs: 5000 }).timeoutMs).toBe(5000); + expect(NoSQLQueryOptionsSchema.safeParse({ timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves the neighbouring `batchSize` alone — it is a COUNT of documents, not a duration', () => { + expect(NoSQLQueryOptionsSchema.parse({ batchSize: 100 }).batchSize).toBe(100); + }); +}); diff --git a/packages/spec/src/data/driver/memory.test.ts b/packages/spec/src/data/driver/memory.test.ts index 0172042886..f1c889fe94 100644 --- a/packages/spec/src/data/driver/memory.test.ts +++ b/packages/spec/src/data/driver/memory.test.ts @@ -539,3 +539,45 @@ describe('MemoryDriverSpec', () => { expect(MemoryDriverSpec.icon).toBe('memory'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — both shapes ARE `strictObject`, so a bare throw +// assertion would pass identically on the unrecognized-key error the tombstone +// exists to replace, which is exactly the case that cannot carry a rename. +describe('memory persistence auto-save interval carries its unit (#15680)', () => { + it('REFUSES the retired `autoSaveInterval` on the file arm, with the rename in the message', () => { + const result = FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`FilePersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + // The `auto` arm was NOT on the gate's list — its describe named no unit, so + // the predicate never judged it. It moves anyway because it is the same value: + // `auto` resolves to the same Node.js file adapter and forwards this number to + // the same `FileSystemPersistenceAdapter` field. Renaming one arm and not the + // other would leave one value with two spellings across sibling arms of one + // union — the dialect Prime Directive #12 forbids. This pin is what stops a + // later reader "restoring" the bare spelling on the arm the gate never listed. + it('REFUSES the retired `autoSaveInterval` on the auto arm too, with its own prescription', () => { + const result = AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveInterval: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'autoSaveInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`AutoPersistenceConfig.autoSaveInterval` was renamed to `autoSaveIntervalMs`'); + }); + + it('accepts `autoSaveIntervalMs` on both arms, keeps the 2000 default and the min(100) bound', () => { + expect(FilePersistenceConfigSchema.parse({ type: 'file', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + expect(FilePersistenceConfigSchema.parse({ type: 'file' }).autoSaveIntervalMs).toBe(2000); + expect(AutoPersistenceConfigSchema.parse({ type: 'auto', autoSaveIntervalMs: 5000 }).autoSaveIntervalMs).toBe(5000); + // 100 reads as a plausible number of SECONDS — the bound is the whole + // reason the bare name was dangerous rather than merely untidy. + expect(FilePersistenceConfigSchema.safeParse({ type: 'file', autoSaveIntervalMs: 50 }).success).toBe(false); + expect(AutoPersistenceConfigSchema.safeParse({ type: 'auto', autoSaveIntervalMs: 50 }).success).toBe(false); + }); +}); diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 90b23883e0..3125cd7893 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -112,3 +112,35 @@ describe('TursoDriverSpec', () => { expect(Object.keys(json.properties ?? {})).toContain('url'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — this shape IS `strictObject`, so a bare throw +// assertion passes identically on the unrecognized-key error, which is precisely +// the error that cannot carry a FROM → TO mapping. +describe('TursoConfig.timeout carries its unit (#15680)', () => { + const base = { url: 'libsql://app.turso.io' }; + + it('REFUSES the retired `timeout` with the rename in the message', () => { + const result = TursoConfigSchema.safeParse({ ...base, timeout: 30000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`turso config.timeout` was renamed to `timeoutMs`'); + }); + + it('accepts `timeoutMs` at the same magnitude and still refuses a non-positive one', () => { + expect(TursoConfigSchema.parse({ ...base, timeoutMs: 30000 }).timeoutMs).toBe(30000); + expect(TursoConfigSchema.safeParse({ ...base, timeoutMs: 0 }).success).toBe(false); + }); + + it('leaves `sync.intervalSeconds` alone — it already carried its unit, and is the neighbour that made the bare `timeout` a collision', () => { + const parsed = TursoConfigSchema.parse({ + ...base, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 60 }, + }); + expect(parsed.sync!.intervalSeconds).toBe(60); + }); +}); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 4113e83ca6..3a1289e46d 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -29,6 +29,10 @@ import { HealthCheckConfigSchema, CircuitBreakerConfigSchema, ConnectorHealthSchema, + + // Trigger (declared-but-unread, #3197 — the pin block at the bottom judges + // its unit-carrying key name, not a runtime it does not have) + ConnectorTriggerSchema, // Types type Connector, @@ -1473,3 +1477,43 @@ describe('[#14676] ADR-0087 registration', () => { expect(step.conversionIds).toContain('connector-error-mapping-removed'); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are +// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. Neither shape is strict, so without the +// tombstones the old keys would be STRIPPED in silence: a breaker would fall +// back to its 60-second default window while the author believed they had +// widened it, and a polling trigger would lose its cadence entirely. +describe('connector durations carry their unit (#15680)', () => { + it('REFUSES the retired `monitoringWindow` with the rename in the message', () => { + const result = CircuitBreakerConfigSchema.safeParse({ enabled: true, monitoringWindow: 120000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'monitoringWindow'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs`'); + }); + + it('REFUSES the retired trigger `interval` with the rename in the message', () => { + const result = ConnectorTriggerSchema.safeParse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`ConnectorTrigger.interval` was renamed to `intervalSeconds`'); + }); + + it('accepts both new spellings and keeps the 60000 breaker default', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).monitoringWindowMs).toBe(60000); + expect(CircuitBreakerConfigSchema.parse({ enabled: true, monitoringWindowMs: 120000 }).monitoringWindowMs).toBe(120000); + expect(ConnectorTriggerSchema.parse({ + key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60, + }).intervalSeconds).toBe(60); + }); + + it('leaves `resetTimeoutMs` alone — it already carried its unit, and is the neighbour that made the bare `monitoringWindow` a collision', () => { + expect(CircuitBreakerConfigSchema.parse({ enabled: true }).resetTimeoutMs).toBe(30000); + }); +}); diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index bea614f18f..c67e50f14e 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -292,7 +292,7 @@ describe('DashboardSchema', () => { it('supports columns/gap/refresh/dateRange/globalFilters', () => { const d = DashboardSchema.parse({ - name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshInterval: 60, + name: 'dash_x', label: 'D', columns: 12, gap: 4, refreshIntervalSeconds: 60, dateRange: { field: 'close_date', defaultRange: 'this_quarter' }, globalFilters: [{ field: 'owner', type: 'lookup' }], widgets: [{ id: 'wid_x', type: 'metric', dataset: 'sales', values: ['revenue'], layout: { x: 0, y: 0, w: 3, h: 2 } }], @@ -695,3 +695,41 @@ describe('[#5010] DashboardWidgetSchema — retired action trio + `aria`', () => expect(c.aria).toEqual({ ariaLabel: 'Sidebar' }); }); }); + +// #15680 (stack card 5/6 of #14478) — ruling B. The old spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()` — `DashboardSchema` IS `strictObject`, so a bare +// throw assertion passes identically on the unrecognized-key error, which is +// exactly the error that cannot carry the rename. +// +// The alias half is the part with no other guard: `refresh` / `autoRefresh` / +// `pollInterval` were rename hints pointing at the OLD spelling, and a hint +// left pointing at a tombstone would prescribe a key the shape refuses. +describe('dashboard.refreshInterval carries its unit (#15680)', () => { + const base = { name: 'dash_x', label: 'D', widgets: [] }; + + it('REFUSES the retired `refreshInterval` with the rename in the message', () => { + const result = DashboardSchema.safeParse({ ...base, refreshInterval: 60 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'refreshInterval'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toContain('`dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`'); + }); + + it('accepts `refreshIntervalSeconds` at the same magnitude', () => { + expect(DashboardSchema.parse({ ...base, refreshIntervalSeconds: 60 }).refreshIntervalSeconds).toBe(60); + }); + + it('prescribes the NEW spelling from all three rename-hint aliases', () => { + for (const alias of ['refresh', 'autoRefresh', 'pollInterval']) { + const result = DashboardSchema.safeParse({ ...base, [alias]: 60 }); + expect(result.success).toBe(false); + const message = result.error!.issues.map((i) => i.message).join('\n'); + expect(message).toContain('refreshIntervalSeconds'); + // A hint still naming the tombstone would send the author to a key the + // shape refuses — the one failure this rename could introduce silently. + expect(message).not.toMatch(/`refreshInterval`(?!Seconds)/); + } + }); +}); From 3a56ae0f3362b2eee35c1594650c90c8df9c9ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:05:59 +0000 Subject: [PATCH 26/33] chore(i18n): regenerate the metadata-form bundles for the dashboard key rename, translations carried over (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++-- .../src/apps/translations/es-ES.metadata-forms.generated.ts | 2 +- .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 2 +- .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 46d6a81468..946bb9f9fb 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -942,8 +942,8 @@ export const enMetadataForms: NonNullable = { label: "Gap", helpText: "Space between widgets, in steps of 0.25rem (4 = 1rem)" }, - refreshInterval: { - label: "Refresh Interval", + refreshIntervalSeconds: { + label: "Refresh Interval Seconds", helpText: "Auto-refresh (seconds)" }, header: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index efa2656a43..798ede93ef 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const esESMetadataForms: NonNullable = label: "Separación", helpText: "Separación de cuadrícula (unidades Tailwind)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "Intervalo de actualización", helpText: "Actualización automática (segundos)" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 55aa8e3e3c..84015fc32f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const jaJPMetadataForms: NonNullable = label: "間隔", helpText: "グリッド間隔(Tailwind 単位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "更新間隔", helpText: "自動更新(秒)" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 4a804896a8..3cb1ad259b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -942,7 +942,7 @@ export const zhCNMetadataForms: NonNullable = label: "间距", helpText: "栅格间距(Tailwind 单位)" }, - refreshInterval: { + refreshIntervalSeconds: { label: "刷新间隔", helpText: "自动刷新间隔(秒)" }, From e10e2c743bf15a756d124d262349c01cdd82ffe1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:15:24 +0000 Subject: [PATCH 27/33] docs(changeset): the seven data/ ui/ ai/ integration/ duration renames, plus the auto-arm sibling (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- ...lain-dashboard-refresh-interval-seconds.md | 11 ++ ...egration-duration-keys-unit-in-key-name.md | 138 ++++++++++++++++++ .../driver-memory-auto-save-interval-ms.md | 26 ++++ ...ects-dashboard-refresh-interval-seconds.md | 12 ++ 4 files changed, 187 insertions(+) create mode 100644 .changeset/cli-explain-dashboard-refresh-interval-seconds.md create mode 100644 .changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md create mode 100644 .changeset/driver-memory-auto-save-interval-ms.md create mode 100644 .changeset/platform-objects-dashboard-refresh-interval-seconds.md diff --git a/.changeset/cli-explain-dashboard-refresh-interval-seconds.md b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..148570312c --- /dev/null +++ b/.changeset/cli-explain-dashboard-refresh-interval-seconds.md @@ -0,0 +1,11 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `explain` names the renamed `dashboard.refreshIntervalSeconds` (#14478) + +The dashboard key catalogue `os explain` prints lists +`refreshIntervalSeconds` instead of `refreshInterval`, following the +`@objectstack/spec` rename of the authored key (the unit now lives in the key +name). Same key, same seconds; no other command output and no public surface of +this package changes. diff --git a/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md new file mode 100644 index 0000000000..c66ebc58f2 --- /dev/null +++ b/.changeset/data-ui-ai-integration-duration-keys-unit-in-key-name.md @@ -0,0 +1,138 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the last seven `data/` · `ui/` · `ai/` · `integration/` duration keys carry their unit in the key name (#15680, ruling B on #14478) + + + +**BREAKING** — eight published duration keys are renamed and tombstoned. Shipped +as `minor` under the repo's launch-window convention for breaking changes; the +hand-migration prescriptions are registered under protocol major 18. Maintainer +ruling B on #14478 (2026-09-02, decision batch #43, 「同意」). + +`check:duration-unit-keys` makes a duration-shaped `z.number()` carry its unit in +the key NAME, never only in its `.describe()` prose, and grandfathers no existing +offender. Card 1/6 (#15676) landed the rule's two structural exemptions, card 2/6 +(#15677) cleared `api/`, card 3/6 (#15678) cleared `kernel/` and card 4/6 +(#15679) cleared `system/`. This card clears the remainder, and is the first +where the gate itself reads **`zero offenders`** and exits `0`. + +⚠️ That is green **for the gate's currently declared population** +(`packages/spec/src/**`), not for the epic. Card 6/6 widens the population and has +already measured an offender outside this subtree, so the gate is expected to go +red again by design. This changeset does not claim #14478 is finished. + +## FROM → TO + +| key | replacement | unit | +|:--|:--|:--| +| `dashboard.refreshInterval` | `refreshIntervalSeconds` | seconds | +| `CircuitBreakerConfig.monitoringWindow` | `monitoringWindowMs` | milliseconds | +| `ConnectorTrigger.interval` | `intervalSeconds` | seconds | +| `FilePersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `AutoPersistenceConfig.autoSaveInterval` | `autoSaveIntervalMs` | milliseconds | +| `TursoConfig.timeout` | `timeoutMs` | milliseconds | +| `NoSQLQueryOptions.timeout` | `timeoutMs` | milliseconds | +| `ConversationAnalytics.duration` | `durationSeconds` | seconds | + +**Every value is unchanged** — only key names move. The two keys that carried a +default keep it (`CircuitBreakerConfig.monitoringWindowMs` still defaults to +60000, `FilePersistenceConfig.autoSaveIntervalMs` to 2000); the other six declare +none. Bounds move with their keys, so `autoSaveIntervalMs` still refuses anything +under 100 on both persistence arms, `NoSQLQueryOptions.timeoutMs` and +`TursoConfig.timeoutMs` still refuse a zero or negative integer, and +`ConversationAnalytics.durationSeconds` still refuses a negative length. Every old +spelling is a `retiredKey()` tombstone, so it fails `tsc` at the authoring site +(input type `never`) and fails the parse with the rename prescription rather than +a bare unrecognized-key error. + +`dashboard`'s three rename-hint aliases — `refresh`, `autoRefresh`, `pollInterval` +— were repointed to `refreshIntervalSeconds` in the same edit. A hint left naming +the tombstone would have prescribed a key the shape refuses, which is the one +failure this rename could have introduced silently; a pin asserts all three. + +## ⚠️ `dashboard.refreshInterval` crosses a repository boundary + +This is the only rename in the whole stack whose consumer is in **another +repository**, so its reader could not move in this PR the way every other reader +in this card did. objectui's dashboard renderer reads the key, multiplies by +1000 to drive a `setInterval`, and republishes it as an authoring input the +console offers. Those sites move in a follow-up objectui card, sequenced behind +a release that actually ships this rename. + +Until that lands the renderer sees an absent key and simply does not start its +refresh timer — a dashboard still renders, and still refreshes when the user +asks. The ADR-0087 conversion in this changeset is what keeps stored dashboards +and `os migrate meta` correct in the meantime. + +## ⚠️ An eighth key moves that the gate did not list + +`AutoPersistenceConfig.autoSaveInterval` is not a gate offender: its `.describe()` +named no unit at all, and the predicate judges prose against name. + +It moves anyway because it is not a second key. `persistence: { type: 'auto' }` +resolves to the same Node.js file adapter as `type: 'file'`, and this value is +forwarded to the same `FileSystemPersistenceAdapter` field, in the same +milliseconds, under the same `min(100)` bound. Renaming one arm and not the other +would have left one value with two spellings across sibling arms of one union, +and the driver reading both — the consumer-side dialect Prime Directive #12 +forbids. Its describe now names the unit too, and a pin asserts the refusal on +the arm the gate never listed, so a later reader cannot "restore" the bare +spelling as an over-application of the rule. + +## Dispositions — four D2 conversions, two semantic entries + +Judged per key from `stack.zod.ts`'s collection roots rather than defaulted, and +unlike card 4/6 this card's answer is split. + +**D2 conversions** (six keys). `dashboards:`, `connectors:` and `datasources:` +are each a stack collection whose members are stored whole as `sys_metadata` +rows, so the conversion chain has a seam that sees them: +`dashboard-refresh-interval-to-refresh-interval-seconds`, +`connector-health-and-trigger-durations-unit-in-key` (both connector keys in one +pass, emitting separately), +`memory-persistence-auto-save-interval-to-ms` (both persistence arms) and +`turso-config-timeout-to-timeout-ms`. The two datasource conversions are +driver-aware for the reason `datasource-config-driver-key-aliases` records: a +bare `config.timeout` under another driver is that driver's own key and must not +be touched. + +**Semantic entries** (two keys). `ConversationAnalytics` is computed at runtime +and handed to a consumer, and `NoSQLQueryOptions` is a per-call driver argument +reached only through `AggregationPipeline.options`. Neither is a stack collection +member or a stored row, so the chain has no seam — the disposition every +runtime-emitted measurement in this stack has taken. + +All eight are registered by exact key in `RETIRED_KEYS_BY_MAJOR`. + +## A retirement tombstone is no longer read as a secret + +`refusedCredentialKeys` derives a driver's refused inline credentials by finding +`z.never()` keys in its config contract. A `retiredKey()` tombstone is also a +`z.never()`, and until this card no driver contract carried one — so "never ⇒ +credential" held by accident of population rather than by construction. The first +tombstone to arrive (`TursoConfig.timeout`) made the derivation answer that a +millisecond budget was a secret: it was redacted off the datasource read path and +dragged a non-credential name into the fallback list every unrecognised driver is +scrubbed by. + +The derivation now skips keys carrying the `[REMOVED] ` prefix `retiredKey()` +itself stamps. The exclusion is deliberately **negative** — skip declared +tombstones — rather than positive (keep only keys marked `format: 'password'`), +even though every credential slot in every builtin contract does carry that +marker today: under-redacting is the dangerous direction, so a future credential +key whose author forgets the marker is still scrubbed, and only a key that has +explicitly declared itself retired may drop out. Both directions are pinned. + +## Keys deliberately left alone + +`TursoConfig.sync.intervalSeconds` and `CircuitBreakerConfig.resetTimeoutMs` +already carried their unit — they are the same-shape neighbours that made the +bare `timeout` and `monitoringWindow` collisions visible, and pins assert they +did not move. `NoSQLQueryOptions.batchSize` is a COUNT of documents and every +number on `ConversationAnalytics` other than the duration is a count of messages, +tokens or events: a count has no unit to carry. The turso schema shipped by +`@objectstack/driver-turso` is a separate declaration outside this gate's +declared population and is not touched here; card 6/6 owns it, so the two +declarations disagree by design until that lands. diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md new file mode 100644 index 0000000000..7ca7bf4799 --- /dev/null +++ b/.changeset/driver-memory-auto-save-interval-ms.md @@ -0,0 +1,26 @@ +--- +"@objectstack/driver-memory": minor +--- + +feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + +**BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and +`FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both +renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of +the authored keys on both persistence arms. + +Same value, same milliseconds, same 2000 default, same `setInterval` cadence. The +option was always milliseconds — it is passed straight to `setInterval` — and the +spec's `min(100)` bound is what made the bare name dangerous rather than untidy: +100 reads as a plausible number of seconds, so an author who guessed the unit +wrong cleared the bound, was refused nowhere, and saved a thousand times more +often than intended. + +Both persistence arms move together: `type: 'auto'` resolves to this same file +adapter and forwards the same field, so this package reads exactly one spelling +rather than two. + +```diff +- new InMemoryDriver({ persistence: { type: 'file', autoSaveInterval: 5000 } }) ++ new InMemoryDriver({ persistence: { type: 'file', autoSaveIntervalMs: 5000 } }) +``` diff --git a/.changeset/platform-objects-dashboard-refresh-interval-seconds.md b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md new file mode 100644 index 0000000000..6b6b872d5c --- /dev/null +++ b/.changeset/platform-objects-dashboard-refresh-interval-seconds.md @@ -0,0 +1,12 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): the dashboard metadata-form bundles follow the `refreshIntervalSeconds` rename (#14478) + +The `metadataForms.dashboard` translation bundles key the auto-refresh field as +`refreshIntervalSeconds`, following the `@objectstack/spec` rename of the +authored key. Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the +hand-written `zh-CN` / `ja-JP` / `es-ES` label and help text were carried across +the rename unchanged, because the field still means what it meant and each help +text already named the unit. From 3351a860dbf5db0627f85a0a95c13d074c213743 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:15:47 +0000 Subject: [PATCH 28/33] docs(changeset): declare the ADR-0087 disposition on the driver-memory rename (#15680) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .changeset/driver-memory-auto-save-interval-ms.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/driver-memory-auto-save-interval-ms.md b/.changeset/driver-memory-auto-save-interval-ms.md index 7ca7bf4799..aa551961ff 100644 --- a/.changeset/driver-memory-auto-save-interval-ms.md +++ b/.changeset/driver-memory-auto-save-interval-ms.md @@ -4,6 +4,8 @@ feat(driver-memory)!: the file-persistence auto-save interval names its unit (#15680, ruling B on #14478) + + **BREAKING** — `InMemoryDriverOptions.persistence.autoSaveInterval` and `FileSystemPersistenceAdapter`'s `autoSaveInterval` constructor option are both renamed to **`autoSaveIntervalMs`**, following the `@objectstack/spec` rename of From 8c39a949b5c85f9b96b43332e18602a0ad1e77e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 18:59:05 +0000 Subject: [PATCH 29/33] wip(spec): widen check:duration-unit-keys to every workspace package's src (#15682) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../spec/scripts/check-duration-unit-keys.ts | 213 ++++++++++++++++-- 1 file changed, 190 insertions(+), 23 deletions(-) diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index fe98c8fae5..4a9e4f15c3 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -34,7 +34,8 @@ * ## The rule * * For every property whose value is a numeric Zod chain — a chain rooted at - * `z.number()`, `z.int()` or `z.coerce.number()` — in `src/**` (tests excluded): + * `z.number()`, `z.int()` or `z.coerce.number()` — in every workspace package's + * `src/**` (tests, build output and installed dependencies excluded): * if its `.describe()` names a time unit (milliseconds, seconds, minutes, * hours, days — plus their short forms), the key NAME must carry a unit * token, and that token must be one the describe names. Two failure @@ -114,6 +115,39 @@ * `--update`, and no `gen:`. A red here is a rename (with its ADR-0087 * conversion) or a describe to fix, never a command to run. * + * ## The population: every workspace package's `src/**` (#15682) + * + * The rule is about how a duration is DECLARED, and the declaration is the same + * defect wherever it is written: a `timeout` whose unit lives only in its + * describe misleads an author identically in `packages/spec` and in a driver + * package that publishes its own connection-config schema. This gate walked + * `packages/spec/src/**` alone until #15682 widened it to every workspace + * member's `src/` subtree. Measured across the widening on this tree: 2291 + * source files against 838, and exactly one offender outside `packages/spec` — + * `@objectstack/driver-turso`'s published `config.timeout`, renamed in the same + * PR that widened the walk. + * + * Members are enumerated through the shared `workspace-enumerator` module (the + * ONE parse of `pnpm-workspace.yaml`) rather than a private copy of that parse, + * and {@link ROOT_DIR_WATCH_HINTS} is held against the live globs in BOTH + * directions by the self-test. `src/` is the whole boundary and that is + * measured rather than assumed: all 210 tracked `*.zod.ts` files in this repo + * live under some workspace member's `src/`. + * + * ⛔ THE WALK EXCLUDES `node_modules`, BUILD OUTPUT AND TEST FILES, AND THE + * SELF-TEST PINS IT BEHAVIOURALLY. Measured on #15642 before the exclusion + * existed: pointing `--root` at a package ROOT walked that package's installed + * dependencies and reported *"7151 offender(s) … in 150098 source file(s)"*. + * That is not a finding, it is a LOST POPULATION — a reading about this repo's + * dependencies wearing this gate's verdict line, and a widened gate reporting + * thousands of offenders has not found a problem, it has stopped describing + * this repo. A `dist/` tree is the same hazard one step on: it re-reports every + * offender its own source already carries, so one rename reads as two. + * {@link SKIP_DIRS} is applied to the WALK rather than to the roots, so an + * explicit `--root` cannot route around it. On this tree the exclusion removes + * nothing tracked: no tracked file under any member's `src/` sits below a + * skipped directory name. + * * ## Why here and not `packages/lint` * * `@objectstack/lint` validates a customer's METADATA GRAPH at build time — @@ -135,26 +169,50 @@ * is outside the population. Widening it is a decision, not a bug fix. */ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { isExclusionGlob, readWorkspaceGlobs, workspacePackageDirs } from '../../../scripts/workspace-enumerator.mjs'; + const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -const SRC_ROOT = join(pkgRoot, 'src'); +const REPO_ROOT = join(pkgRoot, '..', '..'); /** * The dispatch-gates declaration — the `ROOT_DIR_WATCH_HINTS` idiom (#12310). * `scripts/pm/dispatch-gates.mjs` derives which gates a card must run from the * path literals in each gate's source, and `check:declared-population-live` * refuses a gate whose only path-shaped literal names nothing in the tree. - * This gate walks exactly one subtree — `packages/spec/src/`, tests excluded — - * so that is what it declares, as a LITERAL (the extractor reads source text; - * a value computed from `SRC_ROOT` would produce no hint). The self-test holds - * the literal against the constant the scan actually reads from. + * + * Since #15682 this gate walks every workspace member's `src/` subtree, so that + * is what it declares — one entry per workspace glob, as LITERALS. The literal + * spelling is load-bearing rather than stylistic: the extractor reads SOURCE + * TEXT, so the same array computed from the workspace parse contributes no hint + * at all and the gate drops silently out of every dispatch brief + * (`check:watch-hint-literal` refuses that spelling for exactly this reason). + * + * The self-test holds these entries against the LIVE `pnpm-workspace.yaml` + * globs in both directions. Each direction has its own silent failure: a + * workspace root added there and not here leaves this gate walking a tree no + * card is ever dispatched for, and an entry here the workspace no longer + * declares announces a population nothing reads. */ -export const ROOT_DIR_WATCH_HINTS = ['packages/spec/src/**']; +export const ROOT_DIR_WATCH_HINTS = [ + 'packages/*/src/**', + 'packages/apps/*/src/**', + 'packages/drivers/*/src/**', + 'packages/plugins/*/src/**', + 'packages/qa/*/src/**', + 'packages/triggers/*/src/**', + 'packages/services/*/src/**', + 'packages/adapters/*/src/**', + 'packages/connectors/*/src/**', + 'apps/*/src/**', + 'examples/*/src/**', +]; /** Canonical unit → every spelling the describe prose or a key token may use. */ const UNIT_SPELLINGS: Readonly> = { @@ -519,24 +577,71 @@ function isSourceFile(rel: string): boolean { return true; } +/** + * Directory names the walk never descends into — see the population section of + * this file's header for what each one costs when it is walked. Applied to the + * WALK rather than to the roots, so an explicit `--root` at a package root + * cannot route around it: that is the exact shape of the measured `node_modules` + * reading (#15642), and a root-level filter would have let it back in. + */ +const SKIP_DIRS: ReadonlySet = new Set([ + 'node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', '.cache', +]); + +/** + * The `src/` subtree of every workspace member that has one — the population, + * enumerated live rather than listed. A member with no `src/` (the console + * bundle, the docs app, the dogfood suite) contributes nothing and is not an + * error: this gate reads declarations, and a package that declares none has + * none to get wrong. + */ +export function sourceRoots(repoRoot: string = REPO_ROOT): string[] { + const out: string[] = []; + for (const dir of workspacePackageDirs(repoRoot)) { + const src = join(repoRoot, dir, 'src'); + if (existsSync(src) && statSync(src).isDirectory()) out.push(src); + } + return out; +} + function walk(dir: string, out: string[]): void { for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; const p = join(dir, name); if (statSync(p).isDirectory()) walk(p, out); else out.push(p); } } -export function scanTree(root = SRC_ROOT): { sites: DurationKey[]; findings: Finding[]; files: number } { - const files: string[] = []; - walk(root, files); +/** + * How a file is NAMED in a finding and in `--list`: repo-relative, so an + * offender in any package is a path a reader can open. A `--root` outside this + * repo (the self-test's fixture tree) falls back to root-relative rather than + * printing a `../../..` climb. + */ +function labelFor(root: string, file: string): string { + const fromRepo = relative(REPO_ROOT, file).split('\\').join('/'); + if (fromRepo !== '' && !fromRepo.startsWith('../')) return fromRepo; + return relative(root, file).split('\\').join('/'); +} + +/** + * Scan the declared population, or one explicit tree when `root` is given + * (ablation / demo). The exclusions apply to both — see {@link SKIP_DIRS}. + */ +export function scanTree(root?: string): { sites: DurationKey[]; findings: Finding[]; files: number } { + const roots = root === undefined ? sourceRoots() : [root]; const sites: DurationKey[] = []; let count = 0; - for (const f of files.sort()) { - const rel = relative(root, f).split('\\').join('/'); - if (!isSourceFile(rel)) continue; - count++; - sites.push(...collectDurationKeys(`src/${rel}`, readFileSync(f, 'utf8'))); + for (const r of roots) { + const files: string[] = []; + walk(r, files); + for (const f of files.sort()) { + const label = labelFor(r, f); + if (!isSourceFile(label)) continue; + count++; + sites.push(...collectDurationKeys(label, readFileSync(f, 'utf8'))); + } } const findings = sites.map(judge).filter((x): x is Finding => x !== undefined); return { sites, findings, files: count }; @@ -689,12 +794,74 @@ function selfTest(): number { return new RegExp(`export const ${INSTANT_ROOT}\\b`).test(src); })()); - // The declared population must be the population the scan reads (the - // ROOT_DIR_WATCH_HINTS idiom's coupling, held from this side). - const repoRoot = join(pkgRoot, '..', '..'); - const declared = `${relative(repoRoot, SRC_ROOT).split('\\').join('/')}/**`; - expect(`declared population \`${ROOT_DIR_WATCH_HINTS.join(', ')}\` is the subtree the scan walks (\`${declared}\`)`, - ROOT_DIR_WATCH_HINTS.length === 1 && ROOT_DIR_WATCH_HINTS[0] === declared); + // ── the DECLARED population, held against the LIVE workspace (#15682) ──── + // + // The literal is what `scripts/pm/dispatch-gates.mjs` reads; the workspace + // file is what `sourceRoots()` actually enumerates. Held in BOTH directions + // because each has its own silent failure — see ROOT_DIR_WATCH_HINTS' own + // docblock. The declaration is NOT replaced by the live parse: a parse spells + // no literal, and a gate that declares nothing is dispatched for nothing. + const liveHints = readWorkspaceGlobs(REPO_ROOT) + .filter((g) => !isExclusionGlob(g)) + .map((g) => `${g}/src/**`); + for (const hint of liveHints) { + expect(`pnpm-workspace.yaml's \`${hint.replace('/src/**', '')}\` is declared here as \`${hint}\``, + ROOT_DIR_WATCH_HINTS.includes(hint)); + } + for (const hint of ROOT_DIR_WATCH_HINTS) { + expect(`declared \`${hint}\` is still a workspace root pnpm-workspace.yaml names`, + liveHints.includes(hint)); + } + + // The population must REACH the tree, and reach PAST the one subtree this + // gate used to walk alone. "Exactly one offender outside packages/spec" is + // only news if the instrument fired outside packages/spec at all — the + // reading the widening exists to produce, and the one a silently-empty + // enumeration fakes perfectly (measured next door: a `packages/*/src` + // pathspec that returned zero and zeroed its positive control with it). + const roots = sourceRoots(); + const specSrc = join(pkgRoot, 'src'); + expect('the enumerated population contains `packages/spec/src`', roots.includes(specSrc)); + expect(`the enumerated population reaches ${roots.length - 1} src tree(s) OUTSIDE packages/spec`, + roots.some((r) => r !== specSrc)); + expect('no enumerated root is itself inside `node_modules`', + roots.every((r) => !r.split(sep).includes('node_modules'))); + + // ── the walk's exclusions, pinned BEHAVIOURALLY (#15682) ───────────────── + // + // Measured on #15642 before they existed: `--root` at a package ROOT walked + // that package's installed dependencies and reported "7151 offender(s) … in + // 150098 source file(s)". A `SKIP_DIRS.has('node_modules')` assertion cannot + // catch that coming back — the trap is that the WALK DESCENDS, so this builds + // a tree containing every excluded shape, each carrying the same offender the + // first case of this self-test uses, and asserts the walk finds ONE file. + // Seven offenders on disk, one in the verdict. + const fixtureRoot = mkdtempSync(join(tmpdir(), 'duration-unit-keys-')); + try { + const offender = "const S = z.object({ ttl: z.number().describe('Cache TTL in seconds') });\n"; + const excluded = [ + 'node_modules/some-dep/index.ts', + 'node_modules/@scope/dep/nested/schema.ts', + 'dist/bundle.ts', + 'build/out.ts', + 'nested/__tests__/helper.ts', + 'unit.test.ts', + 'unit.spec.ts', + 'generated.d.ts', + ]; + for (const rel of [...excluded, 'real.ts', 'nested/also-real.ts']) { + const p = join(fixtureRoot, rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, offender); + } + const walked = scanTree(fixtureRoot); + expect(`the walk skips node_modules/, dist/, build/ and test files — 2 source file(s) of ${excluded.length + 2}, 2 offender(s)`, + walked.files === 2 && walked.findings.length === 2); + expect('an excluded file is not merely unjudged, it is never read', + walked.sites.every((site) => !excluded.includes(site.file))); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } console.log(failures === 0 ? '\nself-test: all cases pass' : `\nself-test: ${failures} case(s) FAILED`); return failures === 0 ? 0 : 1; From 8c12a916cb76d8a992b91b24dfb5a6d2c7ff92ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:06:26 +0000 Subject: [PATCH 30/33] feat(driver-turso)!: rename the published config timeout to timeoutMs, tombstone on the old spelling (#15682) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .changeset/driver-turso-config-timeout-ms.md | 38 ++++++++++++++++ .../driver-turso/src/spec/turso.test.ts | 45 +++++++++++++++---- .../driver-turso/src/spec/turso.zod.ts | 44 +++++++++++++++++- packages/spec/src/conversions/registry.ts | 13 ++++-- 4 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 .changeset/driver-turso-config-timeout-ms.md diff --git a/.changeset/driver-turso-config-timeout-ms.md b/.changeset/driver-turso-config-timeout-ms.md new file mode 100644 index 0000000000..41cde995eb --- /dev/null +++ b/.changeset/driver-turso-config-timeout-ms.md @@ -0,0 +1,38 @@ +--- +"@objectstack/driver-turso": minor +--- + +feat(driver-turso)!: the published connection config names its timeout's unit (#15682, ruling B on #14478) + + + +**BREAKING** — `TursoConfigSchema`'s `timeout` is renamed to **`timeoutMs`**. The +value is unchanged: the same milliseconds, the same `min(0)` bound, the same +optionality. + +`@objectstack/spec`'s own turso contract renamed the same authored key in +#15680. This package publishes a parallel schema for the same connection config +— the Spec / Studio metadata a host reads to expose Turso configuration UI — so +until now the two declarations of one setting disagreed on its spelling. They +agree again. + +The unit was never in the key name, only in the describe prose, while +`sync.intervalSeconds` — the same shape, three keys above — already spelled its +own. One published config carrying both conventions is what made the bare name +dangerous rather than untidy: an author who has just written +`intervalSeconds: 30` has no reason to read `timeout: 30` as milliseconds, and +nothing in the schema, the type or the parse would have told them otherwise. + +The old spelling is not dropped in silence. `TursoConfigSchema` is a plain +`z.object`, so a bare deletion would have STRIPPED `timeout` and parsed +successfully. The key stays declared as a tombstone instead: `tsc` refuses it on +anything typed `TursoConfig`, and a value that reaches the parse raises a +message naming `timeoutMs` rather than a generic unrecognised-key error. + +```diff +- TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeout: 30000 }) ++ TursoConfigSchema.parse({ url: 'libsql://app.turso.io', timeoutMs: 30000 }) +``` + +`TursoDriverConfig` — this package's TypeScript constructor option, a separate +declaration — keeps its `timeout` spelling and is untouched here. diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts index ba3f824333..ce19c484f7 100644 --- a/packages/drivers/driver-turso/src/spec/turso.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso.test.ts @@ -62,13 +62,13 @@ describe('TursoConfigSchema', () => { intervalSeconds: 120, onConnect: false, }, - timeout: 30000, + timeoutMs: 30000, wasm: true, }); expect(config.encryptionKey).toBe('my-secret-key-256'); expect(config.concurrency).toBe(50); - expect(config.timeout).toBe(30000); + expect(config.timeoutMs).toBe(30000); expect(config.wasm).toBe(true); }); @@ -83,7 +83,7 @@ describe('TursoConfigSchema', () => { expect(config.syncUrl).toBeUndefined(); expect(config.localPath).toBeUndefined(); expect(config.sync).toBeUndefined(); - expect(config.timeout).toBeUndefined(); + expect(config.timeoutMs).toBeUndefined(); expect(config.wasm).toBeUndefined(); }); @@ -124,13 +124,42 @@ describe('TursoConfigSchema', () => { })).toThrow(); }); - it('should reject config with negative timeout', () => { + it('should reject config with negative timeoutMs', () => { expect(() => TursoConfigSchema.parse({ url: ':memory:', - timeout: -1, + timeoutMs: -1, })).toThrow(); }); + // [#15682, ruling B on #14478] The rename's tombstone. Asserted on the + // REFUSAL ENVELOPE — which key was refused and what the message prescribes — + // rather than on `toThrow()` alone: a bare `toThrow()` here stays green for a + // schema that lost the tombstone entirely and simply required `url`, and it + // would stay green for a strip that never refused at all. What must hold is + // that the old spelling is REFUSED and that the refusal names the new key. + it('refuses the retired `timeout` spelling, and the refusal names `timeoutMs`', () => { + const result = TursoConfigSchema.safeParse({ url: ':memory:', timeout: 30000 }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('timeoutMs'); + expect(issue!.message).toContain('milliseconds'); + // The standardized closing sentence — the one channel an upgrading author + // is guaranteed to hit carries the command, not just the diagnosis. + expect(issue!.message).toContain('os migrate meta --from 17'); + }); + + // The other half of the tombstone: it refuses a VALUE, it does not make the + // whole config unparseable. A tombstone that took the object down with it + // would read identically in the case above. + it('the tombstone leaves a config that never wrote `timeout` untouched', () => { + const config = TursoConfigSchema.parse({ url: ':memory:', timeoutMs: 5000 }); + + expect(config.timeoutMs).toBe(5000); + expect('timeout' in config).toBe(false); + }); + it('should accept config with environment variable patterns', () => { const config = TursoConfigSchema.parse({ url: '${TURSO_DATABASE_URL}', @@ -150,13 +179,13 @@ describe('TursoConfigSchema', () => { expect(config.concurrency).toBe(100); }); - it('should accept zero timeout (no timeout)', () => { + it('should accept zero timeoutMs (no timeout)', () => { const config = TursoConfigSchema.parse({ url: ':memory:', - timeout: 0, + timeoutMs: 0, }); - expect(config.timeout).toBe(0); + expect(config.timeoutMs).toBe(0); }); }); diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index 5b7ff63231..c3ddc7a9f2 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -46,6 +46,22 @@ export const TursoSyncConfigSchema = lazySchema(() => z.object({ // 2. Connection Configuration // ========================================================================== +/** + * The prescription the retired `timeout` key raises, and the text `tsc` and the + * parse both carry. Standardized closing sentence — the `os migrate meta` + * wording states a property of the TOOL and is not a choice (see + * `retired-key.ts` in `@objectstack/spec`, whose header owns that ruling). + * + * ⛔ No internal issue id in this string: it is customer-facing text and + * `check:doc-authoring` refuses one. The ids live in the comments beside the + * keys below. + */ +const TIMEOUT_RETIRED = + '`turso config.timeout` was renamed to `timeoutMs` in @objectstack/driver-turso 17 — the unit of a ' + + 'duration-shaped number lives in the key name, not only in the describe prose. Rename the key to ' + + '`timeoutMs`; the value (milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + export const TursoConfigSchema = lazySchema(() => z.object({ /** * Database URL. @@ -100,8 +116,34 @@ export const TursoConfigSchema = lazySchema(() => z.object({ /** * Timeout for database operations in milliseconds. + * + * Renamed from `timeout` (#15682, ruling B on #14478): the unit lived only in + * the describe prose, while `sync.intervalSeconds` — the same shape, three + * keys above — already spelled ITS unit. One published connection config + * carrying both conventions is what made the bare name dangerous rather than + * untidy. `@objectstack/spec`'s own turso contract renamed the same authored + * key in #15680; this mirror now agrees with it, and with the ADR-0087 + * conversion (`turso-config-timeout-to-timeout-ms`) that rewrites the stored + * spelling on load. + */ + timeoutMs: z.number().int().min(0).optional().describe('Operation timeout in milliseconds'), + + /** + * Tombstone for the rename above (#15682, ruling B on #14478). + * + * This is a plain `z.object`, so zod's default STRIP posture would make a + * bare deletion SILENT: an author's `timeout: 30000` would vanish and the + * parse would still succeed. `z.never()` keeps the key DECLARED and + * unwritable, so the old spelling raises the prescription instead of + * disappearing — the two channels an upgrading author actually meets (`tsc` + * sees `never` at the authoring site; the parse raises the text itself). + * + * Spelled inline rather than through `@objectstack/spec`'s `retiredKey()`: + * that helper is internal to the spec package and is not on its published + * `./shared` entry point, so this package cannot import it. The shape is the + * same one-liner. */ - timeout: z.number().int().min(0).optional().describe('Operation timeout in milliseconds'), + timeout: z.never({ error: () => TIMEOUT_RETIRED }).optional().describe(`[REMOVED] ${TIMEOUT_RETIRED}`), /** * Enable WASM mode. diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 07e13ffe12..3a8d38d387 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8945,10 +8945,15 @@ const memoryPersistenceAutoSaveIntervalToMs: MetadataConversion = { * The neighbour is why this key was worth the rename: `sync.intervalSeconds`, * two keys above, already spelled ITS unit. One shape, both conventions. * - * ⚠️ This converts the SPEC's turso contract. The driver package ships its own - * parallel `turso.zod.ts` whose `timeout` is outside this card's declared - * population; it is renamed by the card that widens that population, and until - * then the two declarations disagree by design. + * ⚠️ This converts the SPEC's turso contract. `@objectstack/driver-turso` ships + * its own parallel `turso.zod.ts` declaring the same authored key, which was + * outside this gate's declared population when this entry was written. #15682 + * widened that population to every workspace package's zod schemas and renamed + * the mirror in the same PR, so both declarations now spell `timeoutMs` and + * this ONE conversion covers the authored surface for both. The mirror + * registers no second entry: it would restate the same rewrite in + * `spec-changes.json` and the upgrade guide without converting anything the + * rename above has not already converted. */ const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { id: 'turso-config-timeout-to-timeout-ms', From eb55348e23cf205afe6c3c36e76a2d4417322dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:27:26 +0000 Subject: [PATCH 31/33] fix(scripts): declare the three workspace-enumerator exports the widened gate imports (#15682) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- scripts/workspace-enumerator.d.mts | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 scripts/workspace-enumerator.d.mts diff --git a/scripts/workspace-enumerator.d.mts b/scripts/workspace-enumerator.d.mts new file mode 100644 index 0000000000..226071f254 --- /dev/null +++ b/scripts/workspace-enumerator.d.mts @@ -0,0 +1,40 @@ +// Types for the three `workspace-enumerator.mjs` exports a gate under +// `packages/spec/scripts/` consumes — the same problem, and the same fix, as +// `check-regen-pending.d.mts` and `js-comment-mask.d.mts` next door (#5475). +// +// The module itself stays `.mjs`: it is imported by root gates that run under +// bare `node`, and it is deliberately NOT a gate of its own (see its header — +// being one is exactly what it must not be). What changed is that +// `check-duration-unit-keys.ts` now imports it (#15682, when that gate's +// population widened from `packages/spec/src/**` to every workspace package's +// `src/`), and since #5475 that directory sits inside a tsc program +// (`tsconfig.scripts.json`), where an untyped `.mjs` import is TS7016 — the +// enumeration silently becomes `any`, and a misspelled export would type-check +// clean while resolving to `undefined` at runtime. +// +// PARTIAL BY DESIGN, the shape `check-regen-pending.d.mts` already takes and +// `check:declaration-mirrors` explicitly sanctions: the module exports twelve +// names and this declares the THREE this repo's TypeScript consumers use. +// Importing an undeclared name from here is `TS2305` — loud, red and immediate +// — never a silent `any`. Declared rather than inferred (no `allowJs`) because +// the module sits at the repo root, outside the consuming program's `rootDir`. +// Keep this file in step with the module by hand; `check:declaration-mirrors` +// holds the names, kinds and required arities. + +/** + * The `packages:` globs of the workspace rooted at `root`. + * + * A MISSING `pnpm-workspace.yaml` is this function's refusal, not an empty + * answer — callers for whom "no workspace here" is ordinary test with + * `existsSync` first. + */ +export function readWorkspaceGlobs(root: string): string[]; + +/** Whether a glob is a pnpm exclusion (`!pattern`), which enumerates nothing. */ +export function isExclusionGlob(glob: string): boolean; + +/** + * Every workspace member that actually holds a `package.json`, as repo-relative + * POSIX directories, sorted. + */ +export function workspacePackageDirs(root: string): string[]; From 808691883023a04d844f5360bfaae7361a01b61f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 20:14:46 +0000 Subject: [PATCH 32/33] fix(service-datasource): read the canonical turso `config.timeoutMs` at the shared libSQL seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildTursoDriverConfig` still consulted `config.timeout` after that authored key was renamed to `timeoutMs` and tombstoned, so a datasource authored the canonical way had its timeout silently dropped. `TursoConfigSource.config` is an untyped string-keyed bag, so tsc could not see the rename through it, and the covering test authored the retired spelling at all three of its sites and stayed green over the defect. The reader now reads `config.timeoutMs`; the driver key it lands on stays `timeout` (published-but-inert, must not be ratified by a rename). No fallback arm for the retired spelling — the sqlite `filename` and mongo `url` arms in `default-datasource-driver-factory.ts` set that precedent, and both authoring and stored-row rehydration deliver the canonical key already. The covering test moves to the canonical spelling and gains contract-derived cases that read the schema's own tombstones, so they hold for the next rename without being edited. The two sibling pins that author the same spec move with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- ...vice-datasource-turso-timeout-ms-reader.md | 57 ++++++++ packages/cli/src/utils/storage-driver.test.ts | 2 +- .../turso-driver-factory.convergence.test.ts | 4 +- .../src/__tests__/turso-driver-config.test.ts | 138 +++++++++++++++++- .../src/turso-driver-config.ts | 40 ++++- 5 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 .changeset/service-datasource-turso-timeout-ms-reader.md diff --git a/.changeset/service-datasource-turso-timeout-ms-reader.md b/.changeset/service-datasource-turso-timeout-ms-reader.md new file mode 100644 index 0000000000..cd2e32c621 --- /dev/null +++ b/.changeset/service-datasource-turso-timeout-ms-reader.md @@ -0,0 +1,57 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): the shared libSQL config builder reads the canonical `config.timeoutMs` (#16023, follow-up on #15680) + +`buildTursoDriverConfig` — the ONE seam both libSQL loaders go through (#7314) — +still consulted `config.timeout` after #15680 renamed that authored key to +`timeoutMs` and tombstoned the old spelling. A turso datasource authored the +canonical way therefore reached the seam, matched nothing, and had its timeout +**silently dropped**: no diagnostic in any channel. + +The reader now consults `config.timeoutMs`. The DRIVER key it lands on is +unchanged and still spelled `timeout` — `TursoDriverConfig.timeout` is +published-but-inert (#16024), and renaming an inert key would ratify it as real, +which is what ADR-0049 exists to prevent. So this seam is the one place the +authored and driver spellings differ, and it now says so. + +## No fallback arm for the retired spelling — the seam's own precedent + +Both sibling arms in `default-datasource-driver-factory.ts` already answer this +in the same words: sqlite's "`filename` is the whole contract … so no `??` +tolerance survives here", mongo's "`url` is the one spelling". A renamed +datasource config key reaches a reader already canonical from two directions — +authoring refuses the retired spelling at the door (`retiredKey()`: `tsc` +`never` plus a parse-time prescription), and a stored `sys_metadata` row replays +the full ADR-0087 chain including `retiredFromLoadPath` entries at +`loadDatasourceRows` / `loadDatasourceRow`, so the D2 conversion +`turso-config-timeout-to-timeout-ms` has rewritten the key before this table +sees it. A `??` arm would be a consumer-side dialect (Prime Directive #12) for a +spelling both doors have closed. + +`authToken`'s legacy arm is not a counter-precedent: it is kept for a LIVE route +(host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it constructs +itself, which never meets the authoring schema), not for a retired spelling. + +## Why the covering test did not catch it, and what replaces it + +`TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +rename through it — the tombstone's type channel, which caught the alias tables +elsewhere in this stack, does not reach here. And the covering test authored the +**retired** spelling at all three of its turso `config` sites, so it was green +for exactly the behaviour that had become wrong. A test that pins the retired +spelling cannot notice this class of bug. + +The three sites now author the canonical spelling, and the file gains cases +DERIVED from the authoring contract rather than written against today's key +list: they read `TursoConfigSchema`'s own `retiredKey()` tombstones and assert +that (a) every canonical replacement is consulted by some reader, and (b) no +retired spelling is — probed at every JS type a reader could type-test, with a +vacuity guard so a mis-derived empty list fails instead of passing. They hold +for the next rename without being edited. + +The two sibling pins that author the same spec — `packages/cli`'s driver +correspondence check and `packages/runtime`'s cross-loader convergence check — +move to the canonical spelling with it; their assertions read driver keys and +are unchanged. diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index f1ed2ac785..e903ecf97a 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -474,7 +474,7 @@ describe('#7314 — the shared libSQL config builder against the real TursoDrive concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, } as const; diff --git a/packages/runtime/src/turso-driver-factory.convergence.test.ts b/packages/runtime/src/turso-driver-factory.convergence.test.ts index 6865ce1847..44834fc906 100644 --- a/packages/runtime/src/turso-driver-factory.convergence.test.ts +++ b/packages/runtime/src/turso-driver-factory.convergence.test.ts @@ -64,7 +64,9 @@ const FULL_SPEC: DatasourceConnectionSpec = { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + // The AUTHORED key (#15680). The driver key it lands on is still `timeout` + // — asserted on the constructor argument below. + timeoutMs: 9000, mode: 'replica', }, }; diff --git a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts index b709fd160e..bdf742ae91 100644 --- a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts +++ b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts @@ -23,6 +23,7 @@ // authoring-door version lives in `turso-bound-secret-authoring.test.ts`. import { describe, it, expect } from 'vitest'; +import { getDriverConfigSchema } from '@objectstack/spec/data'; import { buildTursoDriverConfig, resolveTursoUrl, @@ -44,7 +45,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 7, syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 30, onConnect: false }, - timeout: 9000, + timeoutMs: 9000, mode: 'replica', }, }; @@ -75,7 +76,7 @@ describe('buildTursoDriverConfig (#7314)', () => { concurrency: 1, syncUrl: 'libsql://y', sync: {}, - timeout: 1, + timeoutMs: 1, mode: 'remote', }, }; @@ -92,12 +93,12 @@ describe('buildTursoDriverConfig (#7314)', () => { // Empty strings are unset, not credentials of length zero — the open-core // arm's own type-tests, carried over unchanged. The number keys deliberately - // have no truthiness check: `concurrency: 0` / `timeout: 0` are values the + // have no truthiness check: `concurrency: 0` / `timeoutMs: 0` are values the // driver reads. it('treats empty string credentials as unset and keeps zero-valued numbers', () => { const spec: DatasourceConnectionSpec = { driver: 'turso', - config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeout: 0 }, + config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeoutMs: 0 }, }; expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); @@ -204,6 +205,135 @@ describe('buildTursoDriverConfig (#7314)', () => { }); }); +// ── #16023 — the reader table reads the AUTHORED spelling, whatever it is now ── +// +// The defect this closes, and why the cases above could not have caught it. +// `TursoConfigSource.config` is a bare string-keyed bag, so `tsc` cannot see a +// key rename through it: when `TursoConfig.timeout` became `timeoutMs` +// (#15680), the `timeout` reader kept consulting the spelling the authoring +// contract had just started REFUSING, and every case above stayed green — +// because every one of them authored the retired spelling too. A datasource +// authored the canonical way reached the seam and had its timeout dropped, with +// no diagnostic in any channel. +// +// "Grep by TYPE, not by name" — the discipline the rename stack used everywhere +// else — fails at an untyped seam BY CONSTRUCTION. So these cases are derived +// from the authoring contract rather than written against today's key list: +// they read `TursoConfigSchema`'s own tombstones and hold for the NEXT rename +// without being edited. A test that has to be remembered is the same defect one +// layer up. +describe('#16023 — every reader consults the spelling the authoring contract accepts', () => { + /** `retiredKey()` stamps this prefix on the description; that IS the marker. */ + const RETIRED_PREFIX = '[REMOVED] '; + + /** The turso authoring contract, through the published door the spec offers. */ + function tursoShape(): Record { success: boolean } }> { + const schema = getDriverConfigSchema('turso') as unknown as { shape?: unknown; def?: { shape?: unknown } }; + const raw = schema.shape ?? schema.def?.shape; + const shape = typeof raw === 'function' ? (raw as () => unknown)() : raw; + return shape as Record { success: boolean } }>; + } + + /** Retired authoring keys, and the canonical key each prescription names. */ + function retirements(): Array<{ retired: string; replacement: string | undefined }> { + return Object.entries(tursoShape()) + .filter(([, field]) => (field.description ?? '').startsWith(RETIRED_PREFIX)) + .map(([retired, field]) => ({ + retired, + replacement: /was renamed to `([A-Za-z0-9_]+)`/.exec(field.description ?? '')?.[1], + })); + } + + /** + * The JS types a reader can type-test for. Every probe below is run at all + * three rather than at one guessed type: a reader tests `typeof === 'number'` + * or `'string'`, so a probe of the wrong type passes vacuously. + */ + const CANDIDATES: readonly unknown[] = [424_242, 'SENTINEL-424242', true]; + + /** + * A value the LIVE field accepts, chosen by asking the field rather than by + * guessing its type. + * + * ⚠️ Only ever called on a canonical key. A retired key is `z.never()`, so + * NOTHING parses against it — asking a tombstone for a valid sample is a + * category error, and this throws rather than answering one. (It did, on the + * first run of this file: the guard is here because it fired.) + */ + function sampleFor(key: string): unknown { + const field = tursoShape()[key]; + for (const candidate of CANDIDATES) { + if (field?.safeParse(candidate).success) return candidate; + } + throw new Error( + `no sample value of a supported JS type parses against \`${key}\` — extend the candidate list ` + + 'rather than letting this case go vacuous', + ); + } + + const build = (config: Record) => { + const spec = { driver: 'turso', config } as const; + return buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + }; + + // The vacuity guard. Every case below iterates the derived list, so an empty + // or mis-derived list would make all of them pass while testing nothing. + it('derives at least one retirement from the contract, including the known one', () => { + const retired = retirements().map((r) => r.retired); + expect(retired.length).toBeGreaterThan(0); + expect(retired).toContain('timeout'); + expect(retirements().find((r) => r.retired === 'timeout')?.replacement).toBe('timeoutMs'); + }); + + // ⭐ RED BEFORE THE FIX. The reproduction, stated as a property: a config + // authored the CANONICAL way must reach the driver. Before the fix this + // failed for `timeoutMs` — the built config was `{ url }` and the authored + // 424242 appeared nowhere. + it('reads every canonical replacement — an authored value reaches the driver config', () => { + for (const { retired, replacement } of retirements()) { + if (!replacement) continue; // a removal with no successor has nothing to read + const value = sampleFor(replacement); + const built = build({ url: 'libsql://x', [replacement]: value }); + expect( + Object.values(built), + `no reader consults the canonical \`${replacement}\` (retired: \`${retired}\`) — ` + + 'the rename moved the authoring contract and left this seam behind', + ).toContain(value); + } + }); + + // The other half, and the one that pins the DECISION rather than the fix: no + // reader keeps a tolerance arm for a retired spelling. Authoring is refused at + // the door and a stored row is canonicalized by the ADR-0087 chain before it + // arrives (`loadDatasourceRows` / `loadDatasourceRow`), so a `??` fallback + // here would be a consumer-side dialect for a spelling both doors closed — + // the sqlite `filename` and mongo `url` arms say exactly this. + it('reads NO retired spelling — a config authored the old way yields only `url`', () => { + for (const { retired } of retirements()) { + // Probed at every candidate type, not at the replacement's: a tolerance + // arm could have been written with any type-test, and the tombstone + // itself accepts nothing to sample from. + for (const value of CANDIDATES) { + const built = build({ url: 'libsql://x', [retired]: value }); + expect( + Object.keys(built), + `a reader still consults the retired \`${retired}\` (probed with ${typeof value})`, + ).toEqual(['url']); + } + } + }); + + // The named, readable instance of the property above — the case #16023 + // reported, spelled out so a reader of this file does not have to run the + // derivation in their head. + it('the reported instance: an authored `timeoutMs` lands on the driver `timeout`', () => { + expect(build({ url: 'libsql://x', timeoutMs: 9000 })) + .toEqual({ url: 'libsql://x', timeout: 9000 }); + // ...and the retired spelling is not a second way to say it. + expect(build({ url: 'libsql://x', timeout: 9000 })).toEqual({ url: 'libsql://x' }); + }); +}); + describe('resolveTursoUrl (#7314)', () => { it('trims, and reports a whitespace-only or absent url as none', () => { expect(resolveTursoUrl({ driver: 'turso', config: { url: ' libsql://x ' } })).toBe('libsql://x'); diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts index ecdd75d0d2..113d83dcd5 100644 --- a/packages/services/service-datasource/src/turso-driver-config.ts +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -71,7 +71,12 @@ export interface TursoDriverConfigInput { syncUrl?: string; /** Embedded-replica sync settings (requires `syncUrl`). */ sync?: { intervalSeconds?: number; onConnect?: boolean }; - /** Operation timeout in ms for remote operations. */ + /** + * Operation timeout in ms for remote operations. + * + * The DRIVER's key name. The datasource authors it as `config.timeoutMs` + * (#15680); this one keeps the bare spelling on purpose (#16024). + */ timeout?: number; /** Force a transport mode instead of detecting it from the url. */ mode?: 'local' | 'replica' | 'remote'; @@ -108,7 +113,7 @@ interface TursoConfigSource { * The type-tests are the open-core arm's — including the truthiness check on the * string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is an unset one, * never a credential of length zero) and its absence on the number keys - * (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + * (`concurrency: 0` and `timeoutMs: 0` are meaningful values the driver reads). * * A reader is not obliged to read only `config`: `schemaMode` and `authToken` * both consult the spec itself. `authToken`'s reason is a credential route, and @@ -167,7 +172,36 @@ const TURSO_CONFIG_READERS: { config.sync && typeof config.sync === 'object' ? (config.sync as TursoDriverConfigInput['sync']) : undefined, - timeout: ({ config }) => (typeof config.timeout === 'number' ? config.timeout : undefined), + /** + * The AUTHORED key is `timeoutMs`; the DRIVER key is `timeout`. + * + * This is the one reader whose two spellings differ, and the split is + * deliberate on both sides. `TursoConfig.timeout` was renamed to `timeoutMs` + * (#15680, ruling B on #14478) because the unit of a duration-shaped number + * belongs in the key name; `TursoDriverConfig.timeout` was NOT renamed with + * it, because renaming a published-but-inert driver key would ratify it as + * real (#16024) — which is the outcome ADR-0049 exists to prevent. + * + * ⚠️ NO fallback arm for the retired `config.timeout`, and that is the + * precedent rather than a new rule. Both sibling arms in + * `default-datasource-driver-factory.ts` say it in the same words: sqlite's + * "`filename` is the whole contract … so no `??` tolerance survives here", + * mongo's "`url` is the one spelling". Each renamed key reaches a reader + * ALREADY canonical, from two directions — authoring refuses the retired + * spelling at the door (`retiredKey()`, tsc `never` + a parse-time + * prescription), and a stored `sys_metadata` row replays the full ADR-0087 + * chain, retired entries included, at `loadDatasourceRows` / + * `loadDatasourceRow` in `datasource-admin-plugin.ts` — so the D2 conversion + * `turso-config-timeout-to-timeout-ms` has already rewritten the key before + * this table ever sees it. A `??` arm here would be a consumer-side dialect + * (Prime Directive #12) reintroducing the spelling both doors just closed. + * + * `authToken`'s legacy arm above is NOT a counter-precedent: it is kept for a + * LIVE route (host boot translating `OS_DATABASE_AUTH_TOKEN` into a config it + * constructs itself, which never meets the authoring schema), not for a + * retired spelling. + */ + timeout: ({ config }) => (typeof config.timeoutMs === 'number' ? config.timeoutMs : undefined), mode: ({ config }) => typeof config.mode === 'string' ? (config.mode as TursoDriverConfigInput['mode']) : undefined, schemaMode: ({ spec }) => resolveDatasourceSchemaMode(spec), From 0ae5a8aa5eafa2073304675918966369824cc23c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 20:50:04 +0000 Subject: [PATCH 33/33] docs(spec): correct the exclusion pin's stale comment to the fixture it actually builds (#15682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavioural exclusion pin in `check-duration-unit-keys.ts` was described against an earlier, smaller fixture: "asserts the walk finds ONE file. Seven offenders on disk, one in the verdict." The code below it writes 8 excluded shapes plus 2 real source files and asserts `walked.files === 2 && walked.findings.length === 2` — three numbers the prose got wrong. The code is right and the comment was stale, so only the comment moves. The assertion, the `excluded` list and the second `expect` ("an excluded file is not merely unjudged, it is never read") are untouched; the wider fixture is the point of the pin and is deliberately kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/scripts/check-duration-unit-keys.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index 4a9e4f15c3..1cc0d82cea 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -834,8 +834,9 @@ function selfTest(): number { // 150098 source file(s)". A `SKIP_DIRS.has('node_modules')` assertion cannot // catch that coming back — the trap is that the WALK DESCENDS, so this builds // a tree containing every excluded shape, each carrying the same offender the - // first case of this self-test uses, and asserts the walk finds ONE file. - // Seven offenders on disk, one in the verdict. + // first case of this self-test uses, plus two real source files, and asserts + // the walk finds TWO files. Ten copies of the offender on disk — eight of + // them behind an exclusion — two in the verdict. const fixtureRoot = mkdtempSync(join(tmpdir(), 'duration-unit-keys-')); try { const offender = "const S = z.object({ ttl: z.number().describe('Cache TTL in seconds') });\n";