From e22957d19d0f161c3df7476f74bfaaa4ed5bd1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:08:49 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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()`). | ---