From 7e0ba72950c9d677de0ba21b6ce424118e3f649c Mon Sep 17 00:00:00 2001 From: os-dev Date: Fri, 4 Sep 2026 23:07:30 +0000 Subject: [PATCH 1/6] wip(spec): duration-unit-keys gate draft (#14478) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../spec/scripts/check-duration-unit-keys.ts | 474 ++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 packages/spec/scripts/check-duration-unit-keys.ts diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts new file mode 100644 index 0000000000..6c02974adc --- /dev/null +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -0,0 +1,474 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-duration-unit-keys — a duration-shaped `z.number()` key carries its + * unit in its NAME, never only in its `.describe()` prose (#14478, maintainer + * ruling 2026-09-02, recorded on the card as "ruled B"). + * + * tsx scripts/check-duration-unit-keys.ts # gate: exit 1 on any offender + * tsx scripts/check-duration-unit-keys.ts --self-test # prove the detector still detects + * tsx scripts/check-duration-unit-keys.ts --list # every duration-shaped number key it sees + * + * ## The defect class + * + * `metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines apart: + * `cache.ttl` in SECONDS (default 3600) and `cache.databaseLoader.ttl` in + * MILLISECONDS (default 60_000). Both `.describe()` strings named their unit; + * the key names did not. An author — very often a model (ADR-0033) — who copies + * the outer `3600` into the inner block gets a 3.6-second cache and no error + * anywhere: the number is valid, the type is right, the cache is simply cold. + * `Hook.timeout`, `Job.timeout` and `DriverOptions.timeout` had the same shape + * (milliseconds, said only in prose) beside siblings that spell it (`backoffMs`, + * `intervalMs`, `timeoutMs` on the script body), so the population carried two + * conventions and the wrong one was indistinguishable at the authoring site. + * + * The published reference pages make it worse, not better: `.describe()` is + * what `content/docs/references/**` renders and the JSDoc above a key is NOT — + * so a key whose unit lives in the JSDoc alone (`tenant.zod.ts`'s + * `idleTimeout` / `sessionTimeout`, "in seconds" one line above the key and + * absent from the describe) publishes a bare number to exactly the reader who + * never sees the source. That reader was the one #14519 was filed for. + * + * ## The rule + * + * For every property whose value is a numeric Zod chain — a chain rooted at + * `z.number()`, `z.int()` or `z.coerce.number()` — in `src/**` (tests excluded): + * if its `.describe()` names a time unit (milliseconds, seconds, minutes, + * hours, days — plus their short forms), the key NAME must carry a unit + * token, and that token must be one the describe names. Two failure + * directions, one rule: `ttl` with "in seconds" fails (no unit in the name); + * `ttlMs` with "in seconds" fails too (the name names the WRONG unit — the + * 1000× bug wearing a false sense of safety). + * + * A unit-carrying VALUE is the other sanctioned spelling and is not a number, + * so it is outside the population by construction: `LIFECYCLE_DURATION_REGEX` + * literals (`'14d'`) are strings, and the `{ value, unit }` pairs of + * `disaster-recovery.zod.ts` put the unit in a sibling enum. The one numeric + * shape that legitimately carries no unit in its name is that pair's `value` + * — recognised structurally, by the sibling `unit` key on the same object + * literal, never by name. + * + * ## What `--list` reports and the verdict does NOT judge: no unit anywhere + * + * A duration-SHAPED name (`sessionTimeout`, `flushInterval`) whose describe + * names no unit at all is the #14519 shape — the reference-page reader gets a + * bare `3600`. It is outside this verdict on purpose, and the reason is + * measured, not aesthetic: judged by name alone on `ca46f8f12` (2026-09-04) + * that rule fired 44 times, and most were counts wearing a duration's + * vocabulary — `contextWindow`, `slidingWindowSize`, `snapshotInterval` + * ("every N events"), `reflectionInterval` ("every N interactions"), + * `backoffMultiplier`, `staleKeys`. A rule that cannot tell a window of + * tokens from a window of seconds would either grandfather those by name + * (an exception list) or teach authors to append `Ms` to a count. The + * describe-driven rule has no such ambiguity: prose that says "seconds" is + * talking about time. `--list` still prints the unit-nowhere keys so the + * population stays visible; closing it is a describe-by-describe decision. + * + * ## No baseline, by ruling + * + * Triage proposed a ratchet from the day's count with the existing keys + * grandfathered. The maintainer adopted the alternative: convert every + * offender under ADR-0087 in the same PR and let the gate demand ZERO. A + * ratchet baseline is a named list of permanent exceptions, and the standing + * rules that decided it are quoted on the card — 「不考虑存量」 and 「项目在创 + * 业阶段,用户也很少,短期不考虑渐进。」. So this script has no ledger, no + * `--update`, and no `gen:`. A red here is a rename (with its ADR-0087 + * conversion) or a describe to fix, never a command to run. + * + * ## Why here and not `packages/lint` + * + * `@objectstack/lint` validates a customer's METADATA GRAPH at build time — + * pure `(stack) => Issue[]` functions the CLI and AI authoring share. This + * gate reads this package's own SOURCE and judges how a schema is declared; + * it has no stack to validate and nothing a customer could run it on. That is + * the shape of every other source audit in this directory + * (`check-exported-any`, `check-dual-source-exports`, `check-error-code- + * provenance`), and `check:generated` classifies it the same way: NO_GENERATOR. + * + * ## What it deliberately does not judge + * + * - Calendar POSITIONS are not durations: `dayOfMonth` "Day of the month + * (1-31)", `hour` "Hour of the day (0-23)". They are recognised by the + * position idioms in {@link POSITION_IDIOMS} and skipped. + * - RATES are not durations either: "requests per second" names a unit, but + * the number is a count. Recognised by the `per ` idiom and skipped. + * - A chain rooted anywhere else (`z.string()`, an imported schema constant) + * is outside the population. Widening it is a decision, not a bug fix. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SRC_ROOT = join(pkgRoot, 'src'); + +/** Canonical unit → every spelling the describe prose or a key token may use. */ +const UNIT_SPELLINGS: Readonly> = { + ms: ['ms', 'msec', 'msecs', 'millis', 'millisecond', 'milliseconds'], + seconds: ['sec', 'secs', 'second', 'seconds'], + minutes: ['minute', 'minutes'], + hours: ['hr', 'hrs', 'hour', 'hours'], + days: ['day', 'days'], +}; + +/** Key-name tokens accepted as carrying a unit. Deliberately NOT `min`/`mins` + * — in a key name `min` means minimum (`minDelay`), and reading it as + * minutes would flag `minAgeSeconds`-style keys for a mismatch they do not have. */ +const KEY_TOKEN_UNITS: ReadonlyMap = new Map( + Object.entries(UNIT_SPELLINGS).flatMap(([unit, spellings]) => spellings.map((s) => [s, unit] as const)), +); + +/** + * Prose spellings. PLURAL and short forms stand on their own ("in seconds", + * "(ms)", "5 mins"); a SINGULAR form counts only when a number precedes it + * ("1 second", "15-minute", "one day" is not counted — write the digit). The + * asymmetry is what keeps two measured false positives out: the ordinal + * "second" ("deferred to second pass") and `min` as MINIMUM ("min 5MB", + * "the same class as `min`"), both of which read as units to a bare word + * match and neither of which is one. + */ +const PROSE_PLURAL_RE = /\b(milliseconds|millis|msecs|ms|seconds|secs|minutes|mins|hours|hrs|days)\b/gi; +const PROSE_COUNTED_RE = /\b\d+(?:[.,]\d+)?[\s-]*(millisecond|msec|ms|second|sec|minute|min|hour|hr|day)s?\b/gi; + +function proseUnitOf(spelling: string): string { + const s = spelling.toLowerCase().replace(/s$/, ''); + switch (s) { + case 'millisecond': case 'milli': case 'msec': case 'm': return 'ms'; + case 'second': case 'sec': return 'seconds'; + case 'minute': case 'min': return 'minutes'; + case 'hour': case 'hr': return 'hours'; + case 'day': return 'days'; + default: return s; + } +} + +/** A describe that names a unit as part of a calendar POSITION, not a duration. */ +const POSITION_IDIOMS: readonly RegExp[] = [ + /\b(day|hour|minute|second)s?\s+of\s+(the\s+)?(week|month|year|day|hour|minute)\b/i, + /\b(weekday|month|year)\b/i, + /\(0-(23|59|6)\)/, + /\b1-31\b/, +]; + +/** A describe that names a unit as the denominator of a RATE. */ +const RATE_IDIOM = /\b(per|a|each|every)\s+(milli)?(second|minute|hour|day)\b/i; + +/** Key names (or key-name tokens) that read as a duration even when no unit is named anywhere. */ +const DURATION_SHAPED_TOKENS = new Set([ + 'timeout', 'ttl', 'interval', 'delay', 'duration', 'maxage', 'expireafter', 'retention', + 'cooldown', 'debounce', 'throttle', 'window', 'grace', 'lifetime', 'expiry', 'expiration', + 'heartbeat', 'backoff', 'idle', 'stale', 'age', 'period', 'every', 'wait', 'timeouts', +]); + +const NUMERIC_ROOTS = new Set(['z.number', 'z.int', 'z.coerce.number']); + +export interface DurationKey { + file: string; + line: number; + key: string; + describe: string | undefined; + /** units the describe prose names (canonical) */ + proseUnits: string[]; + /** units the key name carries (canonical) */ + keyUnits: string[]; + /** true when a sibling `unit` key sits on the same object literal */ + valueUnitPair: boolean; + durationShaped: boolean; +} + +export interface Finding { + site: DurationKey; + rule: 'unit-in-prose-not-in-name' | 'name-unit-contradicts-prose'; + message: string; +} + +// ── tokenising ───────────────────────────────────────────────────────────── + +/** `ttlMs` → ['ttl','ms']; `idle_timeout_seconds` → ['idle','timeout','seconds']; `HTTPTimeoutMs` → ['http','timeout','ms'] */ +export function keyTokens(key: string): string[] { + return (key.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) ?? []).map((t) => t.toLowerCase()); +} + +export function unitsInKey(key: string): string[] { + const out = new Set(); + for (const t of keyTokens(key)) { + const u = KEY_TOKEN_UNITS.get(t); + if (u) out.add(u); + } + return [...out]; +} + +export function unitsInProse(describe: string | undefined): string[] { + if (!describe) return []; + if (POSITION_IDIOMS.some((re) => re.test(describe))) return []; + const withoutRates = describe.replace(new RegExp(RATE_IDIOM.source, 'gi'), ' '); + const out = new Set(); + for (const m of withoutRates.matchAll(PROSE_PLURAL_RE)) out.add(proseUnitOf(m[1])); + for (const m of withoutRates.matchAll(PROSE_COUNTED_RE)) out.add(proseUnitOf(m[1])); + return [...out]; +} + +export function isDurationShaped(key: string): boolean { + const toks = keyTokens(key); + if (toks.some((t) => DURATION_SHAPED_TOKENS.has(t))) return true; + // `maxAge` / `expireAfter` split into two tokens each; test the joined pairs too. + for (let i = 0; i + 1 < toks.length; i++) { + if (DURATION_SHAPED_TOKENS.has(toks[i] + toks[i + 1])) return true; + } + return false; +} + +// ── 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[] } { + const describes: string[] = []; + 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.isPropertyAccessExpression(cur.expression)) { + // `someHelper(...)` — a call whose callee is not `a.b`; not a `z.` root + return { root: undefined, describes }; + } + const method = cur.expression.name.text; + if (method === 'describe' && cur.arguments.length > 0) { + const a = cur.arguments[0]; + const text = concatLiteral(a); + if (text !== undefined) describes.push(text); + } + // 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 }; + } + // otherwise `p` is the receiver of this method call — keep walking down it + cur = p; + } +} + +function concatLiteral(e: ts.Expression): string | undefined { + if (ts.isStringLiteralLike(e)) return e.text; + if (ts.isParenthesizedExpression(e)) return concatLiteral(e.expression); + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const l = concatLiteral(e.left); + const r = concatLiteral(e.right); + if (l === undefined || r === undefined) return undefined; + return l + r; + } + return undefined; +} + +/** Every numeric-chain property in one source text. */ +export function collectDurationKeys(fileName: string, code: string): DurationKey[] { + const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, /* setParentNodes */ true, ts.ScriptKind.TS); + const out: DurationKey[] = []; + const visit = (node: ts.Node) => { + 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 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; + out.push({ + file: fileName, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + key: name, + describe, + proseUnits: unitsInProse(describe), + keyUnits: unitsInKey(name), + valueUnitPair, + durationShaped: isDurationShaped(name), + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +export function judge(site: DurationKey): Finding | undefined { + if (site.valueUnitPair) return undefined; + const where = `${site.file}:${site.line} \`${site.key}\``; + if (site.proseUnits.length > 0) { + if (site.keyUnits.length === 0) { + return { + site, + rule: 'unit-in-prose-not-in-name', + message: `${where} — describe names ${site.proseUnits.join('/')} but the key name carries no unit. ` + + `Rename it to carry the unit (e.g. \`${site.key}${suffixFor(site.proseUnits[0])}\`), with an ADR-0087 conversion if the key is published.`, + }; + } + if (!site.keyUnits.some((u) => site.proseUnits.includes(u))) { + 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.`, + }; + } + return undefined; + } + return undefined; +} + +function suffixFor(unit: string): string { + return { ms: 'Ms', seconds: 'Seconds', minutes: 'Minutes', hours: 'Hours', days: 'Days' }[unit] ?? ''; +} + +// ── population ───────────────────────────────────────────────────────────── + +function isSourceFile(rel: string): boolean { + if (!rel.endsWith('.ts')) return false; + if (rel.endsWith('.d.ts') || rel.endsWith('.test.ts') || rel.endsWith('.spec.ts')) return false; + if (rel.includes('/__tests__/') || rel.startsWith('__tests__/')) return false; + return true; +} + +function walk(dir: string, out: string[]): void { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else out.push(p); + } +} + +export function scanTree(root = SRC_ROOT): { sites: DurationKey[]; findings: Finding[]; files: number } { + const files: string[] = []; + walk(root, files); + const sites: DurationKey[] = []; + let count = 0; + for (const f of files.sort()) { + const rel = relative(root, f).split('\\').join('/'); + if (!isSourceFile(rel)) continue; + count++; + sites.push(...collectDurationKeys(`src/${rel}`, readFileSync(f, 'utf8'))); + } + const findings = sites.map(judge).filter((x): x is Finding => x !== undefined); + return { sites, findings, files: count }; +} + +// ── self-test ────────────────────────────────────────────────────────────── + +function selfTest(): number { + let failures = 0; + const expect = (label: string, ok: boolean) => { + console.log(` ${ok ? '✓' : '✗'} ${label}`); + if (!ok) failures++; + }; + const rulesOf = (code: string) => collectDurationKeys('fixture.ts', code).map(judge).map((f) => f?.rule); + + expect('offender: unit in describe, none in name → unit-in-prose-not-in-name', + rulesOf(`const S = z.object({ ttl: z.number().int().min(0).default(3600).describe('Cache TTL in seconds') });`) + .join() === 'unit-in-prose-not-in-name'); + expect('offender: short-form "ms" in describe, bare `timeout` → unit-in-prose-not-in-name', + rulesOf(`const S = z.object({ timeout: z.number().optional().describe('Timeout in ms') });`) + .join() === 'unit-in-prose-not-in-name'); + expect('offender: name says Ms, describe says seconds → name-unit-contradicts-prose', + rulesOf(`const S = z.object({ ttlMs: z.number().describe('Cache TTL in seconds') });`) + .join() === 'name-unit-contradicts-prose'); + expect('listed, not judged: duration-shaped name with no unit anywhere (the #14519 shape) is a census row', + (() => { + const sites = collectDurationKeys('fixture.ts', `const S = z.object({ sessionTimeout: z.number().int().positive().default(3600).describe('Session timeout'), idleTimeout: z.number().optional() });`); + return sites.length === 2 && sites.every((x) => x.durationShaped && judge(x) === undefined); + })()); + expect('offender through `z.int()` and `z.coerce.number()` roots', + rulesOf(`const S = z.object({ a: z.int().describe('Delay in seconds'), b: z.coerce.number().describe('Delay in hours') });`) + .join() === 'unit-in-prose-not-in-name,unit-in-prose-not-in-name'); + expect('offender inside a `lazySchema(() => strictObject({...}, {...}))` wrapper', + rulesOf(`export const S = lazySchema(() => strictObject({ surface: 's', history: 'h' }, { timeout: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds') }));`) + .join() === 'unit-in-prose-not-in-name'); + expect('offender through a concatenated describe string', + rulesOf(`const S = z.object({ timeout: z.number().describe('Timeout ' + 'in milliseconds') });`) + .join() === 'unit-in-prose-not-in-name'); + + expect('compliant: `ttlMs` / "milliseconds"', + rulesOf(`const S = z.object({ ttlMs: z.number().int().min(0).default(60_000).describe('Cache TTL in milliseconds') });`) + .join() === ''); + expect('compliant: snake_case `idle_timeout_seconds` / "seconds"', + rulesOf(`const S = z.object({ idle_timeout_seconds: z.number().describe('Idle timeout in seconds') });`) + .join() === ''); + expect('compliant: `retentionDays` / "days"; `timeoutHours` / "hours"; `intervalMinutes` / "minutes"', + rulesOf(`const S = z.object({ retentionDays: z.number().describe('Keep for N days'), timeoutHours: z.number().describe('Escalate after N hours'), intervalMinutes: z.number().describe('Poll every N minutes') });`) + .join() === ',,'); + expect('compliant: knex-inherited `idleTimeoutMillis` / "ms"', + rulesOf(`const S = z.object({ idleTimeoutMillis: z.number().min(0).default(30000).describe('Time in ms before idle connection is closed') });`) + .join() === ''); + expect('compliant: `{ value, unit }` pair — the sibling `unit` key exempts the numeric `value`', + rulesOf(`const S = z.object({ value: z.number().min(0).describe('RPO value in seconds, minutes or hours'), unit: z.enum(['seconds', 'minutes', 'hours']) });`) + .join() === ''); + expect('compliant: a describe naming a unit AND a matching key unit, other prose units present', + rulesOf(`const S = z.object({ backoffMs: z.number().describe('Backoff in milliseconds (default 30 seconds)') });`) + .join() === ''); + expect('skipped: calendar position, not a duration (`dayOfMonth`, `hour`)', + rulesOf(`const S = z.object({ dayOfMonth: z.number().describe('Day of the month (1-31)'), hour: z.number().describe('Hour of the day (0-23)'), weekday: z.number().describe('Day of week, 0 = Sunday') });`) + .join() === ',,'); + expect('skipped: a rate, not a duration ("requests per second")', + rulesOf(`const S = z.object({ limit: z.number().describe('Max requests per second') });`) + .join() === ''); + expect('skipped: non-numeric roots are outside the population (`z.string()`, imported schema)', + rulesOf(`const S = z.object({ expireAfter: z.string().regex(RE).describe('Duration such as 14d or 36h'), timeout: PositiveInt.describe('Timeout in ms') });`) + .join() === ''); + expect('skipped: a non-duration number whose describe names no unit', + rulesOf(`const S = z.object({ maxRetries: z.number().int().describe('Retry attempts'), priority: z.number().describe('Order') });`) + .join() === ','); + expect('skipped: `min` in a key name is minimum, not minutes', + rulesOf(`const S = z.object({ minAgeSeconds: z.number().describe('Minimum age in seconds') });`) + .join() === ''); + expect('skipped: ordinal "second" and `min` as minimum are not units in prose', + rulesOf(`const S = z.object({ referencesDeferred: z.number().describe('References deferred to second pass'), partSize: z.number().describe('Part size in bytes (min 5MB, max 5GB)'), maxLength: z.number().describe('Max length; the same transition-gate class as \`min\`') });`) + .join() === ',,'); + expect('counted singular/short forms ARE units: "1 second", "15-minute", "5 min", "30 ms"', + 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'); + + console.log(failures === 0 ? '\nself-test: all cases pass' : `\nself-test: ${failures} case(s) FAILED`); + return failures === 0 ? 0 : 1; +} + +// ── main ─────────────────────────────────────────────────────────────────── + +function main(argv: string[]): number { + if (argv.includes('--self-test')) return selfTest(); + const { sites, findings, files } = scanTree(); + const durationSites = sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0); + + 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)}`); + } + console.log(`\n${durationSites.length} duration-shaped numeric key(s) across ${files} source file(s); ${sites.length} numeric keys in all.`); + } + + 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.`); + 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`); + 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.', + ); + return 1; +} + +const invokedDirectly = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]; +if (invokedDirectly) process.exit(main(process.argv.slice(2))); From deba8a189ba9fa9d32411f58d11c04deb49d26a1 Mon Sep 17 00:00:00 2001 From: os-dev Date: Fri, 4 Sep 2026 23:14:55 +0000 Subject: [PATCH 2/6] wip(spec): rename the seven duration keys, conversions, readers, pins (#14478) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .changeset/duration-unit-in-key-name.md | 82 +++++++++++++ .changeset/metadata-database-loader-ttl-ms.md | 23 ++++ .changeset/objectql-hook-timeout-ms.md | 10 ++ .changeset/runtime-job-timeout-ms.md | 10 ++ .changeset/service-job-timeout-ms.md | 11 ++ .github/workflows/lint.yml | 18 +++ content/docs/automation/jobs.mdx | 2 +- .../docs/protocol/kernel/metadata-service.mdx | 4 +- .../app-showcase/src/automation/jobs/index.ts | 2 +- packages/metadata/README.md | 4 +- .../src/loaders/database-loader.test.ts | 4 +- .../metadata/src/loaders/database-loader.ts | 12 +- packages/objectql/src/hook-binder.test.ts | 2 +- packages/objectql/src/hook-binder.ts | 2 +- packages/objectql/src/hook-metrics.test.ts | 2 +- packages/objectql/src/hook-wrappers.ts | 2 +- packages/runtime/src/app-plugin.ts | 6 +- .../service-job/src/cron-job-adapter.test.ts | 2 +- .../src/db-job-adapter.timeout.test.ts | 18 +-- .../service-job/src/db-job-adapter.ts | 8 +- .../src/interval-job-adapter.test.ts | 2 +- .../service-job/src/run-with-policy.ts | 11 +- packages/spec/liveness/hook.json | 13 +- packages/spec/liveness/job.json | 15 ++- packages/spec/package.json | 1 + packages/spec/scripts/check-generated.ts | 12 ++ packages/spec/src/contracts/job-service.ts | 11 +- packages/spec/src/conversions/registry.ts | 94 ++++++++++++++ packages/spec/src/data/driver.test.ts | 31 +++++ packages/spec/src/data/driver.zod.ts | 12 +- packages/spec/src/data/hook.form.ts | 2 +- packages/spec/src/data/hook.test.ts | 31 +++++ packages/spec/src/data/hook.zod.ts | 22 +++- .../spec/src/kernel/metadata-loader.test.ts | 52 +++++++- .../spec/src/kernel/metadata-loader.zod.ts | 25 +++- .../18.data__DriverOptions__timeout.ts | 14 +++ .../retired-keys/18.system__Job__timeout.ts | 18 +++ ...18.driver-options-timeout-to-timeout-ms.ts | 25 ++++ ...ta-manager-config-cache-ttl-unit-in-key.ts | 32 +++++ .../18.tenant-timeouts-unit-in-key.ts | 30 +++++ packages/spec/src/migrations/registry.ts | 116 +++++++++++++++++- packages/spec/src/system/job.test.ts | 43 ++++++- packages/spec/src/system/job.zod.ts | 22 +++- packages/spec/src/system/tenant.test.ts | 56 ++++++++- packages/spec/src/system/tenant.zod.ts | 35 +++++- .../objectstack-data/references/data-hooks.md | 6 +- 46 files changed, 871 insertions(+), 84 deletions(-) create mode 100644 .changeset/duration-unit-in-key-name.md create mode 100644 .changeset/metadata-database-loader-ttl-ms.md create mode 100644 .changeset/objectql-hook-timeout-ms.md create mode 100644 .changeset/runtime-job-timeout-ms.md create mode 100644 .changeset/service-job-timeout-ms.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.data__DriverOptions__timeout.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.system__Job__timeout.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.driver-options-timeout-to-timeout-ms.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-cache-ttl-unit-in-key.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts diff --git a/.changeset/duration-unit-in-key-name.md b/.changeset/duration-unit-in-key-name.md new file mode 100644 index 0000000000..872f8343d6 --- /dev/null +++ b/.changeset/duration-unit-in-key-name.md @@ -0,0 +1,82 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.ttl` → `ttlSeconds`, `cache.databaseLoader.ttl` → `ttlMs`, tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) + + + +**BREAKING** rename of seven published authorable keys, shipped as `minor` under +the repo's launch-window convention for breaking changes; every rename is +registered under protocol major 18. Maintainer ruling 2026-09-02 on #14478 +(director decision batch #14, verbatim 「14461 你不处理,其他同意」): **ruled B** — +a spec-source gate for duration-shaped number keys **with no grandfathered +baseline**, plus an ADR-0087 conversion of every offender the ruling named, in +one PR, on the standing rules 「不考虑存量」 and 「项目在创业阶段,用户也很少,短期不考虑渐进。」. +⛔ No alias, no transition window: each old spelling is a `retiredKey()` +tombstone whose rejection names the new key. + +## The defect + +`kernel/metadata-loader.zod.ts` carried two keys spelled `ttl` fourteen lines +apart: `cache.ttl` in **seconds** (default 3600) and `cache.databaseLoader.ttl` +in **milliseconds** (default 60000). Both descriptions named their unit; the +key names did not. An author who copied the outer number into the inner block +got a 3.6-second cache and no error anywhere — the number was valid, the type +was right, the cache was simply cold. `hook.timeout`, `job.timeout` and +`DriverOptions.timeout` had the same shape (milliseconds, said only in prose) +beside siblings that spell theirs (`backoffMs`, `intervalMs`, the body-level +`timeoutMs`). The two tenant keys were worse for the reader who matters most: +`.describe()` is what `content/docs/references/**` publishes and the JSDoc above +a key is not, so `idleTimeout` / `sessionTimeout` said "in seconds" in a source +comment and published a bare `300` / `3600` to the reference page (#14519). + +## FROM → TO + +| schema | before | after | value | +|:--|:--|:--|:--| +| `HookSchema` (`hooks[]`) | `timeout` | `timeoutMs` | unchanged (ms) | +| `JobSchema` (`jobs[]`) | `timeout` | `timeoutMs` | unchanged (ms) | +| `DriverOptionsSchema` | `timeout` | `timeoutMs` | unchanged (ms) | +| `MetadataManagerConfigSchema` | `cache.ttl` | `cache.ttlSeconds` | unchanged (s, default 3600) | +| `MetadataManagerConfigSchema` | `cache.databaseLoader.ttl` | `cache.databaseLoader.ttlMs` | unchanged (ms, default 60000) | +| `DatabaseLevelIsolationStrategySchema` | `connectionPool.idleTimeout` | `connectionPool.idleTimeoutSeconds` | unchanged (s, default 300) | +| `TenantSecurityPolicySchema` | `accessControl.sessionTimeout` | `accessControl.sessionTimeoutSeconds` | unchanged (s, default 3600) | + +```ts +// before +defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }); +defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeout: 300000 }); +new MetadataManager({ cache: { ttl: 3600, databaseLoader: { ttl: 60_000 } } }); + +// after — rename the key; the number is unchanged +defineHook({ name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }); +defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep', timeoutMs: 300000 }); +new MetadataManager({ cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } }); +``` + +**Migration.** Rename each key; no value changes. Authoring an old spelling +fails to compile (`tsc`: the input type is `never`) and fails to parse with a +prescription naming the new key. For `hooks[]` / `jobs[]` the rename is a +mechanical D2 conversion (`hook-timeout-to-timeout-ms`, +`job-timeout-to-timeout-ms`, retired from the load path): run +`os migrate meta --from 17` to list the edits for existing sources and apply +them by hand; stored `sys_metadata` rows are rehydrated through the same chain. +The other five keys have no stack seam (runtime config, a per-call options +argument, cloud tenancy config) and carry a semantic entry each. The +`JobScheduleOptions` contract key that carries `job.timeoutMs` to the scheduler +is renamed in lockstep (`timeout` → `timeoutMs`), as is `DatabaseLoaderOptions.cache.ttl` → `ttlMs` in `@objectstack/metadata`. + +## The gate + +`pnpm --filter @objectstack/spec check:duration-unit-keys` +(`packages/spec/scripts/check-duration-unit-keys.ts`, wired into `lint.yml`): +a property whose value is a `z.number()` / `z.int()` / `z.coerce.number()` +chain and whose `.describe()` names a time unit must carry that unit as a token +of its key name (`Ms` / `Seconds` / `Minutes` / `Hours` / `Days`, and the +knex-inherited `Millis`), and the token must agree with the prose — `ttlMs` +described "in seconds" is refused too. A `{ value, unit }` pair is recognised +by its sibling `unit` key; duration literals are strings and outside the +population. Calendar positions ("day of the month") and rates ("requests per +second") are skipped. There is no baseline and no `gen:`; a red is a rename +under an ADR-0087 conversion or a describe to fix. diff --git a/.changeset/metadata-database-loader-ttl-ms.md b/.changeset/metadata-database-loader-ttl-ms.md new file mode 100644 index 0000000000..272045eb9b --- /dev/null +++ b/.changeset/metadata-database-loader-ttl-ms.md @@ -0,0 +1,23 @@ +--- +"@objectstack/metadata": minor +--- + +feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) + + + +**BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape +(`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the +launch-window convention. `MetadataManager` hands `config.cache.databaseLoader` +straight to `new DatabaseLoader({ cache })`, so this option is the spec key +`cache.databaseLoader.ttlMs` one layer down and renames with it: a loader +configured with `ttlMs: 60_000` expires entries after 60 seconds exactly as +`ttl: 60_000` did. The README example and the kernel metadata-service docs page +spell the new key. + +```ts +// before +new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttl: 60_000 } }); +// after +new DatabaseLoader({ driver, cache: { enabled: true, maxSize: 500, ttlMs: 60_000 } }); +``` diff --git a/.changeset/objectql-hook-timeout-ms.md b/.changeset/objectql-hook-timeout-ms.md new file mode 100644 index 0000000000..90d631ae61 --- /dev/null +++ b/.changeset/objectql-hook-timeout-ms.md @@ -0,0 +1,10 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): the declarative hook wrapper reads the renamed `hook.timeoutMs` (#14478) + +`wrapDeclarativeHook` reads its wall-clock abort budget from `meta.timeoutMs` +instead of `meta.timeout`, following the `@objectstack/spec` rename of the +authored key (the unit now lives in the key name). Same value, same magnitude, +same abort; no public surface of this package changes. diff --git a/.changeset/runtime-job-timeout-ms.md b/.changeset/runtime-job-timeout-ms.md new file mode 100644 index 0000000000..d63d3be90d --- /dev/null +++ b/.changeset/runtime-job-timeout-ms.md @@ -0,0 +1,10 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): `AppPlugin` threads the authored `job.timeoutMs` to the scheduler as `timeoutMs` (#14478) + +The declarative job door passes `{ retryPolicy, timeoutMs }` to +`IJobService.schedule`, following the `@objectstack/spec` rename of the +authored key and of the `JobScheduleOptions` contract key that carries it. Same +value, same per-attempt limit. diff --git a/.changeset/service-job-timeout-ms.md b/.changeset/service-job-timeout-ms.md new file mode 100644 index 0000000000..d8a48f708f --- /dev/null +++ b/.changeset/service-job-timeout-ms.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-job": patch +--- + +fix(service-job): `runWithPolicy` and the DB job adapter read `JobScheduleOptions.timeoutMs` (#14478) + +The per-attempt time limit is read from `options.timeoutMs`, following the +`@objectstack/spec` rename of both the authored `job.timeoutMs` and the +`JobScheduleOptions` contract key that carries it. Same value, same per-attempt +race, same `JobTimeoutError`; `withoutPolicy` strips the renamed key so the +timer adapter downstream never runs a second budget. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 681de61f9d..f6c7153b70 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3884,6 +3884,24 @@ jobs: node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test node packages/lint/scripts/check-reference-carrier-shape.mjs + # #14478 (maintainer ruling 2026-09-02, "ruled B"): a duration-shaped + # `z.number()` key in packages/spec/src/** whose `.describe()` names a + # time unit must carry that unit in its NAME (`Ms` / `Seconds` / + # `Minutes` / `Hours` / `Days`), or the schema uses a unit-carrying + # VALUE (`'14d'` literals, `{ value, unit }` pairs). The founding + # specimen was two keys spelled `ttl` fourteen lines apart, one in + # seconds and one in milliseconds, each unit named only in prose — an + # author copying the outer number into the inner block got a 3.6-second + # cache and no error anywhere. No baseline, by ruling: the gate is green + # only at zero offenders, and a red is a rename under an ADR-0087 + # conversion (or a describe to fix), never a ledger row. Placed LAST in + # this job on purpose: it is a new tree-wide ratchet, and the job aborts + # at the first non-zero exit — nothing behind it would be masked by its + # red. Reads src/ through tsx (no build); self-tests first, wired into + # the package script as `check:self-test-wired` requires. + - name: Duration-shaped spec keys carry their unit in the key name + run: pnpm --filter @objectstack/spec check:duration-unit-keys + # Lane 1 of 4 behind the required `TypeScript Type Check` context. The # aggregator job at the bottom of this file explains the split, holds the # contract, and is the thing the merge queue actually requires — read it diff --git a/content/docs/automation/jobs.mdx b/content/docs/automation/jobs.mdx index 393afca17e..7c035aceb1 100644 --- a/content/docs/automation/jobs.mdx +++ b/content/docs/automation/jobs.mdx @@ -18,7 +18,7 @@ export const HealthSweepJob = defineJob({ schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' }, handler: 'sweepProjectHealth', retryPolicy: { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 }, - timeout: 300000, + timeoutMs: 300000, }); ``` diff --git a/content/docs/protocol/kernel/metadata-service.mdx b/content/docs/protocol/kernel/metadata-service.mdx index 42a1c143c1..a3a1fbb6a8 100644 --- a/content/docs/protocol/kernel/metadata-service.mdx +++ b/content/docs/protocol/kernel/metadata-service.mdx @@ -248,14 +248,14 @@ parse-time error carrying the prescription.) ### 3. DatabaseLoader Read-Through Cache -`DatabaseLoader` wraps `load` / `loadMany` / `list` / `stat` results in a generic LRU cache (lazy TTL, promote-on-get, write invalidation). Reads always observe writes performed through the same loader instance; out-of-band SQL writes are honored within `ttl` milliseconds. +`DatabaseLoader` wraps `load` / `loadMany` / `list` / `stat` results in a generic LRU cache (lazy TTL, promote-on-get, write invalidation). Reads always observe writes performed through the same loader instance; out-of-band SQL writes are honored within `ttlMs` milliseconds. ```typescript new MetadataManager({ datasource: 'default', cache: { enabled: true, - databaseLoader: { enabled: true, maxSize: 500, ttl: 60_000 }, + databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 }, }, }); ``` diff --git a/examples/app-showcase/src/automation/jobs/index.ts b/examples/app-showcase/src/automation/jobs/index.ts index 2bef8c5117..d8675133e2 100644 --- a/examples/app-showcase/src/automation/jobs/index.ts +++ b/examples/app-showcase/src/automation/jobs/index.ts @@ -20,7 +20,7 @@ export const HealthSweepJob = defineJob({ schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' }, handler: 'sweepProjectHealth', retryPolicy: { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 }, - timeout: 300000, + timeoutMs: 300000, enabled: true, }); diff --git a/packages/metadata/README.md b/packages/metadata/README.md index 695a1236ec..943b9b218e 100644 --- a/packages/metadata/README.md +++ b/packages/metadata/README.md @@ -165,7 +165,7 @@ new MetadataManager({ `DatabaseLoader` wraps `load` / `loadMany` / `list` / `stat` results in a generic LRU cache (see `src/utils/lru-cache.ts`). Writes invalidate the affected entries, so reads always observe writes made through the same loader -instance; out-of-band SQL writes are honored within `ttl` milliseconds. +instance; out-of-band SQL writes are honored within `ttlMs` milliseconds. Configuration lives under `cache.databaseLoader`: @@ -177,7 +177,7 @@ new MetadataManager({ databaseLoader: { enabled: true, maxSize: 500, // Max cached (type, name) entries - ttl: 60_000, // Cache TTL in milliseconds + ttlMs: 60_000, // Cache TTL in milliseconds }, }, }); diff --git a/packages/metadata/src/loaders/database-loader.test.ts b/packages/metadata/src/loaders/database-loader.test.ts index e6263ea0eb..bf04ddbc52 100644 --- a/packages/metadata/src/loaders/database-loader.test.ts +++ b/packages/metadata/src/loaders/database-loader.test.ts @@ -1336,10 +1336,10 @@ describe('DatabaseLoader read-through cache', () => { expect(stats.load).toBeNull(); }); - it('honors custom maxSize/ttl via cache options', async () => { + it('honors custom maxSize/ttlMs via cache options', async () => { const loader = new DatabaseLoader({ driver: mockDriver, - cache: { enabled: true, maxSize: 1, ttl: 60_000 }, + cache: { enabled: true, maxSize: 1, ttlMs: 60_000 }, }); await loader.save('object', 'a', { name: 'a' }); await loader.save('object', 'b', { name: 'b' }); diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index d30177e632..5fd4be41f2 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -132,8 +132,14 @@ export interface DatabaseLoaderCacheOptions { enabled?: boolean; /** Max number of cached `(type, name)` entries. Default: `500`. */ maxSize?: number; - /** TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. */ - ttl?: number; + /** + * TTL in milliseconds. Set to `0` to disable expiry. Default: `60_000`. + * + * Renamed from `ttl` (#14478) in lockstep with the spec key it mirrors, + * `MetadataManagerConfig.cache.databaseLoader.ttlMs`: the unit now lives in + * the name, not only in this comment. + */ + ttlMs?: number; } /** @@ -249,7 +255,7 @@ export class DatabaseLoader implements MetadataLoader { if (cacheEnabled) { const lruOpts = { maxSize: cacheOpts?.maxSize ?? 500, - ttl: cacheOpts?.ttl ?? 60_000, + ttl: cacheOpts?.ttlMs ?? 60_000, }; this.loadCache = new LRUCache(lruOpts); this.loadManyCache = new LRUCache(lruOpts); diff --git a/packages/objectql/src/hook-binder.test.ts b/packages/objectql/src/hook-binder.test.ts index d4fd0b93ab..9000bc6534 100644 --- a/packages/objectql/src/hook-binder.test.ts +++ b/packages/objectql/src/hook-binder.test.ts @@ -323,7 +323,7 @@ describe('wrapDeclarativeHook', () => { it('honours timeout by rejecting slow handlers', async () => { const meta: Hook = { name: 'timeout', object: 'a', events: ['beforeInsert'], priority: 100, - timeout: 20, + timeoutMs: 20, handler: () => new Promise((r) => setTimeout(r, 200)), }; const wrapped = wrapDeclarativeHook(meta, meta.handler as any); diff --git a/packages/objectql/src/hook-binder.ts b/packages/objectql/src/hook-binder.ts index f39c4bdbff..6f72007cea 100644 --- a/packages/objectql/src/hook-binder.ts +++ b/packages/objectql/src/hook-binder.ts @@ -11,7 +11,7 @@ * that: * * - Inline function handlers and string-named handlers share one resolver. - * - Declarative fields (`condition`, `async`, `retryPolicy`, `timeout`, + * - Declarative fields (`condition`, `async`, `retryPolicy`, `timeoutMs`, * `onError`) are honoured uniformly via `wrapDeclarativeHook`. * - Hooks can be unregistered as a unit via `packageId`, enabling clean * hot-reload and app uninstall. diff --git a/packages/objectql/src/hook-metrics.test.ts b/packages/objectql/src/hook-metrics.test.ts index 5cf6e84613..7bb83ac717 100644 --- a/packages/objectql/src/hook-metrics.test.ts +++ b/packages/objectql/src/hook-metrics.test.ts @@ -63,7 +63,7 @@ describe('hook metrics', () => { object: 'account', events: ['beforeInsert'], priority: 100, - timeout: 10, + timeoutMs: 10, handler: async () => new Promise((r) => setTimeout(r, 100)), }; bindHooksToEngine(engine, [hook], { packageId: 'p', metrics }); diff --git a/packages/objectql/src/hook-wrappers.ts b/packages/objectql/src/hook-wrappers.ts index 431c1eef99..141c57b64f 100644 --- a/packages/objectql/src/hook-wrappers.ts +++ b/packages/objectql/src/hook-wrappers.ts @@ -355,7 +355,7 @@ export function wrapDeclarativeHook( const declaredRetry = meta.retryPolicy ? retryPolicyDefaults() : undefined; const retryMax = Math.max(0, Number(meta.retryPolicy?.maxRetries ?? declaredRetry?.maxRetries ?? 0)); const retryBackoffMs = Math.max(0, Number(meta.retryPolicy?.backoffMs ?? declaredRetry?.backoffMs ?? 0)); - const timeoutMs = typeof meta.timeout === 'number' && meta.timeout > 0 ? meta.timeout : undefined; + const timeoutMs = typeof meta.timeoutMs === 'number' && meta.timeoutMs > 0 ? meta.timeoutMs : undefined; const onError = meta.onError ?? 'abort'; // `async` is only meaningful for after* events; ignore on before* (we must // wait for the handler to potentially mutate ctx.input). diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index aa24315ee2..d609d60819 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1094,9 +1094,9 @@ export class AppPlugin implements Plugin { // branch exactly as before. return await handler(jobContext); }, - // #3494: thread the authored retryPolicy/timeout to the adapter - (job.retryPolicy || job.timeout) - ? { retryPolicy: job.retryPolicy, timeout: job.timeout } + // #3494: thread the authored retryPolicy/timeoutMs to the adapter + (job.retryPolicy || job.timeoutMs) + ? { retryPolicy: job.retryPolicy, timeoutMs: job.timeoutMs } : undefined, ); ok++; diff --git a/packages/services/service-job/src/cron-job-adapter.test.ts b/packages/services/service-job/src/cron-job-adapter.test.ts index 5667017045..07b09b978c 100644 --- a/packages/services/service-job/src/cron-job-adapter.test.ts +++ b/packages/services/service-job/src/cron-job-adapter.test.ts @@ -145,7 +145,7 @@ describe('CronJobAdapter retryPolicy / timeout (#3494)', () => { 'slow', { type: 'cron', expression: '* * * * *' }, async () => { await new Promise((r) => setTimeout(r, 150)); }, - { timeout: 25 }, + { timeoutMs: 25 }, ); await adapter.trigger('slow'); const execs = await adapter.getExecutions('slow'); diff --git a/packages/services/service-job/src/db-job-adapter.timeout.test.ts b/packages/services/service-job/src/db-job-adapter.timeout.test.ts index fb3e76fafa..eb00f6eb19 100644 --- a/packages/services/service-job/src/db-job-adapter.timeout.test.ts +++ b/packages/services/service-job/src/db-job-adapter.timeout.test.ts @@ -97,7 +97,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('persists sys_job_run.status = "timeout", not "success"', async () => { const { handler } = slowHandler(); - await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('slow', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.trigger('slow'); expect(runRows()).toHaveLength(1); @@ -109,7 +109,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('counts the timeout as a failure on sys_job', async () => { const { handler } = slowHandler(); - await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('slow', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.trigger('slow'); expect(jobRow().last_status).toBe('timeout'); @@ -121,7 +121,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('records the ABANDONED duration, not how long the handler kept running', async () => { const { handler } = slowHandler(); - await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('slow', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.trigger('slow'); // The symptom row carried duration_ms ≈ the handler's full runtime, which @@ -133,7 +133,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('a handler that resolves AFTER the guard fired cannot overwrite the timeout row', async () => { const { state, handler } = slowHandler(); - await adapter.schedule('late', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('late', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.trigger('late'); expect(runRows()[0].status).toBe('timeout'); @@ -156,7 +156,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('a retried timeout persists attempt 2 on its second row', async () => { const { state, handler } = slowHandler(); await adapter.schedule('retried', CRON, handler, { - timeout: TIMEOUT_MS, + timeoutMs: TIMEOUT_MS, retryPolicy: { maxRetries: 1, backoffMs: 1 }, }); await adapter.trigger('retried'); @@ -184,7 +184,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { // ── additivity ─────────────────────────────────────────────────────────── it('a handler that finishes inside its timeout is unchanged: success, attempt 1', async () => { - await adapter.schedule('quick', CRON, async () => {}, { timeout: 60_000 }); + await adapter.schedule('quick', CRON, async () => {}, { timeoutMs: 60_000 }); await adapter.trigger('quick'); expect(runRows()[0].status).toBe('success'); @@ -196,7 +196,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('the in-memory execution and the persisted row report the SAME verdict', async () => { const { handler } = slowHandler(); - await adapter.schedule('agree', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('agree', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.trigger('agree'); const [exec] = await adapter.getExecutions('agree'); @@ -208,7 +208,7 @@ describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => { it('replay of a timing-out job writes NO success row', async () => { const { handler } = slowHandler(); - await adapter.schedule('rp', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('rp', CRON, handler, { timeoutMs: TIMEOUT_MS }); await adapter.replay('rp'); // One synthetic `replay` row + one wrapped row, and they must agree. @@ -237,7 +237,7 @@ describe('the timeout policy still applies through an injected cron adapter (#77 const adapter = new DbJobAdapter({ engine, cron }); const { handler } = slowHandler(); - await adapter.schedule('cronic', CRON, handler, { timeout: TIMEOUT_MS }); + await adapter.schedule('cronic', CRON, handler, { timeoutMs: TIMEOUT_MS }); // This case registers a REAL croner job, so the exact-count assertion below // holds only while that registration owns no schedule of its own. Pin both diff --git a/packages/services/service-job/src/db-job-adapter.ts b/packages/services/service-job/src/db-job-adapter.ts index d8ee435c7f..bd101cbc82 100644 --- a/packages/services/service-job/src/db-job-adapter.ts +++ b/packages/services/service-job/src/db-job-adapter.ts @@ -52,7 +52,7 @@ type TerminalStatus = 'success' | 'failed' | 'degraded' | 'timeout'; /** * The options handed DOWN to the timer adapter. * - * `retryPolicy` and `timeout` are deliberately NOT forwarded (#7734): this + * `retryPolicy` and `timeoutMs` are deliberately NOT forwarded (#7734): this * adapter now runs the policy itself, inside {@link DbJobAdapter.wrap}, which * is what lets the recorder observe a timeout at the instant it happens. A * second `runWithPolicy` downstream would race that whole retry sequence @@ -61,7 +61,7 @@ type TerminalStatus = 'success' | 'failed' | 'degraded' | 'timeout'; */ function withoutPolicy(options?: JobScheduleOptions): JobScheduleOptions | undefined { if (!options) return options; - const { retryPolicy: _retryPolicy, timeout: _timeout, ...rest } = options; + const { retryPolicy: _retryPolicy, timeoutMs: _timeoutMs, ...rest } = options; return Object.keys(rest).length > 0 ? (rest as JobScheduleOptions) : undefined; } @@ -179,7 +179,7 @@ export class DbJobAdapter implements IJobService { */ async schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise { const wrapped = this.wrap(name, handler, 'schedule', options); - // The wrapper OWNS `retryPolicy`/`timeout` from here down — see withoutPolicy. + // The wrapper OWNS `retryPolicy`/`timeoutMs` from here down — see withoutPolicy. const downstream = withoutPolicy(options); if (schedule.type === 'cron') { @@ -554,7 +554,7 @@ export class DbJobAdapter implements IJobService { * `timeout` (#7734) is the mirror image of `degraded` here: it IS a failure — * the run did not finish, the policy retries it — so it bumps `failure_count` * alongside `failed`. Alerting that keys on that count is the reason a job - * stuck at five times its declared `timeout` must not read as a quiet success. + * stuck at five times its declared `timeoutMs` must not read as a quiet success. */ private async bumpJob(name: string, last_status: TerminalStatus, last_error?: string): Promise { try { diff --git a/packages/services/service-job/src/interval-job-adapter.test.ts b/packages/services/service-job/src/interval-job-adapter.test.ts index 919c91ff11..788343e4be 100644 --- a/packages/services/service-job/src/interval-job-adapter.test.ts +++ b/packages/services/service-job/src/interval-job-adapter.test.ts @@ -148,7 +148,7 @@ describe('IntervalJobAdapter retryPolicy / timeout (#3494)', () => { 'slow', { type: 'interval', intervalMs: 100000 }, async () => { await new Promise((r) => setTimeout(r, 150)); }, - { timeout: 25 }, + { timeoutMs: 25 }, ); await adapter.trigger('slow'); const execs = await adapter.getExecutions('slow'); diff --git a/packages/services/service-job/src/run-with-policy.ts b/packages/services/service-job/src/run-with-policy.ts index 191bd1efd1..ad203171f2 100644 --- a/packages/services/service-job/src/run-with-policy.ts +++ b/packages/services/service-job/src/run-with-policy.ts @@ -3,7 +3,7 @@ import type { JobScheduleOptions } from '@objectstack/spec/contracts'; /** - * Error thrown when a job attempt exceeds its configured `timeout`. + * Error thrown when a job attempt exceeds its configured `timeoutMs`. * Executors map it to JobExecution status 'timeout' (vs plain 'failed'). */ export class JobTimeoutError extends Error { @@ -85,11 +85,12 @@ function withTimeout(run: () => Promise, jobId: string, timeoutMs?: number } /** - * Execute one job run under the authored `retryPolicy` / `timeout` - * (#3494 — JobSchema's retryPolicy/timeout used to be parsed-but-ignored). + * Execute one job run under the authored `retryPolicy` / `timeoutMs` + * (#3494 — JobSchema's retryPolicy/timeout used to be parsed-but-ignored; + * the key was renamed `timeoutMs` in #14478 so the unit lives in its name). * * - No options → exactly the legacy behavior: one attempt, no time limit. - * - `timeout` applies per attempt; an over-limit attempt rejects with + * - `timeoutMs` applies per attempt; an over-limit attempt rejects with * {@link JobTimeoutError}. JavaScript cannot forcibly cancel the in-flight * handler — the attempt is abandoned, not killed. * - `retryPolicy` re-runs failed attempts (including timeouts) with @@ -124,7 +125,7 @@ export async function runWithPolicy( options?: JobScheduleOptions, recorder?: JobAttemptRecorder, ): Promise { - const timeoutMs = options?.timeout; + const timeoutMs = options?.timeoutMs; const policy = options?.retryPolicy; // No policy ⇒ maxRetries 0 ⇒ the loop below runs exactly one attempt and diff --git a/packages/spec/liveness/hook.json b/packages/spec/liveness/hook.json index 43b503bc5e..0b4c62022a 100644 --- a/packages/spec/liveness/hook.json +++ b/packages/spec/liveness/hook.json @@ -55,13 +55,18 @@ "producer": "packages/objectql/src/hook-binder.ts#bindHooksToEngine (`wrapDeclarativeHook(hook, resolved, { logger, metrics })` — the binder hands the AUTHORED hook to the wrapper, so the wrapper reads the author's block rather than a caller-built options object)", "note": "{maxRetries,backoffMs} linear backoff. Producer-side re-verified 2026-08-09 (#4837 slice): the whole declarative group (condition / async / retryPolicy / timeout / onError) is fed from one call site, so one producer pointer covers it — this is the shape `Seed.env` failed, checked and passing here. 2026-08-28: RE-ANCHORED (#13003) — the PRODUCER citation was ACCURATE (`:221` is still the `wrapDeclarativeHook` call), so that half is the grammar migration. What this pass repairs is the EVIDENCE half, which was a bare path with no line at all and therefore unfalsifiable by the line bound BY CONSTRUCTION — the same silent class `hook.timeout` and `hook.onError` carried. Re-closed by hand against 8cb96ec41." }, - "timeout": { + "timeoutMs": { "status": "live", - "verifiedAt": "2026-08-28", + "verifiedAt": "2026-09-04", "evidenceScope": "in-repo", - "evidence": "packages/objectql/src/hook-wrappers.ts#wrapDeclarativeHook (step 4 of the wrapper's ladder — a wall-clock abort around the handler, independent of `body.timeoutMs`)", + "evidence": "packages/objectql/src/hook-wrappers.ts#wrapDeclarativeHook (step 4 of the wrapper's ladder — a wall-clock abort around the handler, read from `meta.timeoutMs`; independent of `body.timeoutMs`)", "producer": "packages/objectql/src/hook-binder.ts#bindHooksToEngine (the same `wrapDeclarativeHook` call as retryPolicy)", - "note": "wall-clock abort, independent of body.timeoutMs. 2026-08-28: RE-ANCHORED (#13003) — accurate producer line migrated; the path-only evidence pointer is now an anchor (see `retryPolicy`). Re-closed by hand against 8cb96ec41." + "note": "wall-clock abort, independent of body.timeoutMs. RENAMED 2026-09-04 (#14478) from `timeout`: the unit lived only in the describe while the body-level `timeoutMs` and `retryPolicy.backoffMs` spelled theirs; the reader moved from `meta.timeout` to `meta.timeoutMs` in the same PR at the same magnitude. Anchors carried over from the `timeout` row (re-anchored #13003)." + }, + "timeout": { + "status": "dead", + "verifiedAt": "2026-09-04", + "note": "REMOVED 2026-09-04 (#14478) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-18 conversion `hook-timeout-to-timeout-ms`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `timeoutMs` — rename the key, the value (milliseconds) is unchanged, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/data/hook.zod.ts#timeout." }, "onError": { "status": "live", diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json index 4c71a93655..0d3988e708 100644 --- a/packages/spec/liveness/job.json +++ b/packages/spec/liveness/job.json @@ -42,13 +42,18 @@ "evidence": "packages/runtime/src/app-plugin.ts#start (`(job.retryPolicy || job.timeout) ? { retryPolicy: job.retryPolicy, timeout: job.timeout } : undefined` — threaded into `svc.schedule` only when the author set one); packages/services/service-job/src/run-with-policy.ts#runWithPolicy (`const policy = options?.retryPolicy` → maxRetries / backoffMs / backoffMultiplier / maxRetryDelayMs / jitter drive the retry loop); packages/services/service-job/src/run-with-policy.ts#RETRY_DEFAULTS (what an OMITTED member means since 17.0.0 — maxRetries 0, i.e. no retry unless asked for, #4661)", "note": "maxRetries/backoffMs/backoffMultiplier all drive the exponential-backoff retry loop (delay = min(backoffMs * multiplier^(retry-1), maxRetryDelayMs), jittered when asked). Enforced since #3494. This is the `retryPolicy` the datasource ledger warns about confusing with its dead namesake. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED — `app-plugin.ts:838-841` had rotted onto `if (actions.length > 0 && typeof ql.registerAction === 'function')`, another ACTIONS line, and `run-with-policy.ts:58-65` onto the `JobAttemptRecorder` interface — a neighbouring type rather than the policy loop. The `RETRY_DEFAULTS` leg is new and is the one that decides what an author's silence means, which is the half a reader of this entry most needs. Re-closed by hand against 93ea19bca." }, - "timeout": { + "timeoutMs": { "status": "live", - "verifiedAt": "2026-08-28", + "verifiedAt": "2026-09-04", "evidenceScope": "in-repo", - "evidence": "packages/services/service-job/src/run-with-policy.ts#runWithPolicy (`const timeoutMs = options?.timeout` — applied PER ATTEMPT, and a timed-out attempt retries like any other failure); packages/services/service-job/src/run-with-policy.ts#withTimeout (the race itself); packages/services/service-job/src/run-with-policy.ts#JobTimeoutError (what an over-limit attempt rejects with — the in-flight handler is abandoned, not cancelled)", - "producer": "packages/runtime/src/app-plugin.ts#start — the scheduler threads `{ retryPolicy: job.retryPolicy, timeout: job.timeout }` into `svc.schedule`, and only when the author set one of them", - "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented. Producer side re-verified 2026-08-09 (#4837 slice): the seeded row cited the consumer only, which is the shape that carried `Seed.env` — here it holds, because #3494 wired the threading at the same time as the enforcement. 2026-08-28: RE-ANCHORED (#13003) and REPOINTED, BOTH LEGS — `run-with-policy.ts:25-33` had rotted onto `jitter: false` inside `RETRY_DEFAULTS`, a retryPolicy default rather than anything about `timeout`, and the PRODUCER pointer `app-plugin.ts:875-876` onto a bare `}`. The producer leg is worth naming separately: `producer` is checked by the same resolver as `evidence` precisely so a call-site claim cannot be unfalsifiable, and a line-only producer pointer decays exactly as fast as a consumer one. Re-closed by hand against 93ea19bca." + "evidence": "packages/services/service-job/src/run-with-policy.ts#runWithPolicy (`const timeoutMs = options?.timeoutMs` — applied PER ATTEMPT, and a timed-out attempt retries like any other failure); packages/services/service-job/src/run-with-policy.ts#withTimeout (the race itself); packages/services/service-job/src/run-with-policy.ts#JobTimeoutError (what an over-limit attempt rejects with — the in-flight handler is abandoned, not cancelled)", + "producer": "packages/runtime/src/app-plugin.ts#start — the scheduler threads `{ retryPolicy: job.retryPolicy, timeoutMs: job.timeoutMs }` into `svc.schedule`, and only when the author set one of them", + "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented. RENAMED 2026-09-04 (#14478) from `timeout`: the unit lived only in the describe while the sibling `retryPolicy.backoffMs` spelled its own; the contract key `JobScheduleOptions.timeoutMs`, the producer threading in app-plugin and the consumer read in runWithPolicy all moved in the same PR at the same magnitude. Producer side re-verified 2026-08-09 (#4837 slice); both legs re-anchored 2026-08-28 (#13003) — those anchors carried over unchanged." + }, + "timeout": { + "status": "dead", + "verifiedAt": "2026-09-04", + "note": "REMOVED 2026-09-04 (#14478) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and renamed out of sources by the protocol-18 conversion `job-timeout-to-timeout-ms`. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); use `timeoutMs` — rename the key, the value (milliseconds) is unchanged, and `os migrate meta --from 17` lists the mechanical edits. The tombstone is packages/spec/src/system/job.zod.ts#timeout." }, "enabled": { "status": "live", diff --git a/packages/spec/package.json b/packages/spec/package.json index c610d33150..f4eb307a51 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -287,6 +287,7 @@ "check:empty-state": "tsx scripts/liveness/check-empty-state.mts", "check:variant-docs": "tsx scripts/check-variant-docs.mts", "check:error-code-provenance": "tsx scripts/check-error-code-provenance.ts --self-test && tsx scripts/check-error-code-provenance.ts", + "check:duration-unit-keys": "tsx scripts/check-duration-unit-keys.ts --self-test && tsx scripts/check-duration-unit-keys.ts", "gen:strictness-ledger": "tsx scripts/build-strictness-ledger-counts.mts", "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index d486ef0a44..e72da19b92 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -235,6 +235,18 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ check: 'check:error-code-provenance', why: 'audits that packages stamping ledger-registered codes list them under their own owner key (or carry a recorded waiver) — no artifact', }, + // #14478, maintainer ruling 2026-09-02 ("ruled B" — no grandfathered + // baseline). A pure source audit over `src/**`: a duration-shaped + // `z.number()` key whose describe names a time unit must carry that unit in + // its NAME. Reads source text through tsx and writes nothing. There is no + // ledger, no `--update` and deliberately no `gen:` — the one command a + // reader would reach for ("record today's offenders as the baseline") is + // exactly the option the ruling rejected, so a red here is always a rename + // (with its ADR-0087 conversion) or a describe to fix, never a command. + { + check: 'check:duration-unit-keys', + why: 'audits src/** for a duration-shaped `z.number()` key whose unit lives only in its describe — zero offenders by ruling, no baseline, no artifact', + }, // `check:strictness-ledger` used to sit here — "the ledger it audits is a // hand-maintained doc, so there is no generator". #5107 gave it one (the ledger's // NUMBERS became an artifact; its VERDICTS stayed hand-written), so it moved to diff --git a/packages/spec/src/contracts/job-service.ts b/packages/spec/src/contracts/job-service.ts index 9263c64d99..363443e26c 100644 --- a/packages/spec/src/contracts/job-service.ts +++ b/packages/spec/src/contracts/job-service.ts @@ -143,7 +143,7 @@ export interface JobRetryPolicy { /** * Per-job execution options threaded from the authored JobSchema - * (`retryPolicy` / `timeout`) down to the executing adapter. + * (`retryPolicy` / `timeoutMs`) down to the executing adapter. * * Omitted options preserve the legacy behavior: one attempt, no time limit. */ @@ -154,8 +154,13 @@ export interface JobScheduleOptions { * Per-attempt time limit in milliseconds. A run that exceeds it is * recorded with status 'timeout'. Note: JavaScript cannot forcibly * cancel the in-flight handler — the attempt is abandoned, not killed. + * + * Renamed from `timeout` (#14478) in lockstep with `JobSchema.timeoutMs`, + * the authored key whose value this carries: a contract that re-spelled + * it without the unit would reintroduce one layer down exactly the + * ambiguity the rename removed. */ - timeout?: number; + timeoutMs?: number; } /** @@ -190,7 +195,7 @@ export interface IJobService { * @param name - Job name (snake_case) * @param schedule - Schedule configuration * @param handler - Job handler function - * @param options - Optional per-job retry policy / timeout + * @param options - Optional per-job retry policy / per-attempt time limit (`timeoutMs`) */ schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7004f3d27e..3984265710 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8530,6 +8530,98 @@ const connectorErrorMappingRemoved: MetadataConversion = { }, }; +/** + * `hook.timeout` → `hook.timeoutMs` (protocol 18, #14478; maintainer ruling + * 2026-09-02, recorded on the card as "ruled B"). + * + * The unit (milliseconds) lived only in the key's `.describe()` while the + * body-level `timeoutMs` and `retryPolicy.backoffMs` beside it spell theirs — + * one surface, two conventions, and an author who copied a seconds value in + * got a limit 1000× too short with no error anywhere. The spec-source gate + * `check:duration-unit-keys` now refuses a duration-shaped number whose unit + * lives in prose alone, and the ruling adopted NO grandfathering: every + * existing offender is renamed under a conversion like this one. **Retired + * from the load path** (no alias window — 「不考虑存量」, 「短期不考虑渐进」): + * the schema tombstones `timeout` with the rename, and this entry preserves + * the rewrite for `os migrate meta` and the stored-row rehydration seam. + * `renameKey` leaves an already-canonical `timeoutMs` alone and refuses a + * pair that disagrees (#4923). + */ +const hookTimeoutToTimeoutMs: MetadataConversion = { + id: 'hook-timeout-to-timeout-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'hook.timeout', + summary: "hook key 'timeout' → 'timeoutMs' (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'hooks', (hook, path) => { + const renamed = renameKey(hook, 'timeout', 'timeoutMs'); + if (!renamed) return hook; + emit({ from: 'timeout', to: 'timeoutMs', path: `${path}.timeoutMs` }); + return renamed; + }); + }, + fixture: { + before: { + hooks: [ + { name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeout: 5000 }, + // A hook that never authored the key keeps its identity (copy-on-write). + { name: 'score_lead', object: 'lead', events: ['afterUpdate'], handler: 'scoreLead' }, + ], + }, + after: { + hooks: [ + { name: 'audit_order', object: 'order', events: ['afterInsert'], handler: 'auditOrder', timeoutMs: 5000 }, + { name: 'score_lead', object: 'lead', events: ['afterUpdate'], handler: 'scoreLead' }, + ], + }, + expectedNotices: 1, + }, +}; + +/** + * `job.timeout` → `job.timeoutMs` (protocol 18, #14478) — the job half of the + * rename `hookTimeoutToTimeoutMs` documents. The sibling `retryPolicy.backoffMs` + * spelled its unit; the per-attempt limit did not. Same posture: retired from + * the load path, tombstoned at the schema, replayable here. The fixture stays + * clear of `retryPolicy` on purpose — `retry-policy-converged` is still in its + * live window on that block and would fire on it. + */ +const jobTimeoutToTimeoutMs: MetadataConversion = { + id: 'job-timeout-to-timeout-ms', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'job.timeout', + summary: "job key 'timeout' → 'timeoutMs' (#14478 — the unit lived only in the description; the value, milliseconds, is unchanged)", + apply(stack, emit) { + return mapCollection(stack, 'jobs', (job, path) => { + const renamed = renameKey(job, 'timeout', 'timeoutMs'); + if (!renamed) return job; + emit({ from: 'timeout', to: 'timeoutMs', path: `${path}.timeoutMs` }); + return renamed; + }); + }, + fixture: { + before: { + jobs: [{ + name: 'nightly_health_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweepProjectHealth', + timeout: 300000, + }], + }, + after: { + jobs: [{ + name: 'nightly_health_sweep', + schedule: { type: 'cron', expression: '0 1 * * *' }, + handler: 'sweepProjectHealth', + timeoutMs: 300000, + }], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -8619,6 +8711,8 @@ export const CONVERSIONS_BY_MAJOR: Readonly { }); }); }); + +// #14478 — `DriverOptions.timeout` said "Timeout in ms" in prose and nothing +// else; the unit now lives in the key name. Tombstoned (the shape is not +// strict, so a bare deletion would strip the old key in silence) and +// registered as `data/DriverOptions:timeout` under protocol 18. +describe('DriverOptions.timeout → DriverOptions.timeoutMs (#14478)', () => { + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = DriverOptionsSchema.safeParse({ timeout: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/`DriverOptions\.timeout` was removed.*Rename the key to `timeoutMs`/s); + }); + + it('accepts `timeoutMs` at the same magnitude, and strips nothing silently', () => { + const parsed = DriverOptionsSchema.parse({ timeoutMs: 5000, skipCache: true }); + expect(parsed.timeoutMs).toBe(5000); + expect(parsed.skipCache).toBe(true); + expect(parsed).not.toHaveProperty('timeout'); + }); + + it('tsc channel: `timeout` is unwritable on the DriverOptions input type', () => { + // @ts-expect-error — `timeout` is a tombstone (input type `never`); the key is `timeoutMs` + const bad: DriverOptions = { timeout: 5000 }; + expect(bad).toBeDefined(); + const good: DriverOptions = { timeoutMs: 5000 }; + expect(good.timeoutMs).toBe(5000); + }); +}); diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 4c4f935ee1..a8935f7803 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -19,8 +19,16 @@ export const DriverOptionsSchema = lazySchema(() => z.object({ /** * Operation timeout in milliseconds. - */ - timeout: z.number().optional().describe('Timeout in ms'), + * + * Renamed from `timeout` (#14478): the unit lived only in the description. + * Tombstoned rather than deleted because this shape is not `.strict()` — a + * plain deletion would strip the old key in silence. + */ + timeoutMs: z.number().optional().describe('Operation timeout in milliseconds'), + timeout: retiredKey( + '`DriverOptions.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) ' + + 'lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', + ), /** * Whether to bypass cache and force a fresh read. diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index 2ce9faa927..d39e4a8f01 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -68,7 +68,7 @@ export const hookForm = defineForm({ { label: 'Abort', value: 'abort' }, { label: 'Log', value: 'log' }, ] }, - { field: 'timeout', type: 'number', colSpan: 1, helpText: 'Abort the hook after N milliseconds' }, + { field: 'timeoutMs', type: 'number', colSpan: 1, helpText: 'Abort the hook after N milliseconds' }, { field: 'runAs', type: 'select', colSpan: 1, helpText: 'Identity for ctx.api data operations: inherit the triggering write (default), system (elevated), or user (the triggering user)', options: [ { label: 'Inherit (triggering write)', value: 'inherit' }, { label: 'System (elevated)', value: 'system' }, diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 3705ad8df8..355089b21e 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -1374,3 +1374,34 @@ describe('HookContext.api typing (#5945)', () => { expect(doc).toContain('transaction(cb)'); }); }); + +// #14478 — the unit of a duration-shaped number lives in the key name. The +// old `timeout` is a retiredKey tombstone on this strict shape, so the +// rejection carries the RENAME (the prescription is the payload) rather than a +// bare unrecognized-key error, and the value survives at the same magnitude. +describe('hook.timeout → hook.timeoutMs (#14478, ADR-0087 `hook-timeout-to-timeout-ms`)', () => { + const base = { name: 'audit_order', object: 'order', events: ['afterInsert' as const], handler: 'auditOrder' }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = HookSchema.safeParse({ ...base, timeout: 5000 }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'timeout'); + expect(issue).toBeDefined(); + expect(issue!.code).not.toBe('unrecognized_keys'); + expect(issue!.message).toMatch(/`hook\.timeout` was removed.*Rename the key to `timeoutMs`.*os migrate meta --from 17/s); + }); + + it('accepts `timeoutMs` at the same magnitude the retired key carried', () => { + const parsed = HookSchema.parse({ ...base, timeoutMs: 5000 }); + expect(parsed.timeoutMs).toBe(5000); + expect(parsed).not.toHaveProperty('timeout'); + }); + + it('tsc channel: `timeout` is unwritable on the Hook input type', () => { + // @ts-expect-error — `timeout` is a tombstone (input type `never`); the key is `timeoutMs` + const bad: Hook = { ...base, timeout: 5000 }; + expect(bad).toBeDefined(); + const good: Hook = { ...base, timeoutMs: 5000 }; + expect(good.timeoutMs).toBe(5000); + }); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index f8153955ed..8b8b595d45 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -28,10 +28,11 @@ import type { IScopedContext } from '../contracts/scoped-context'; * a breaking change for anyone parsing a context they were handed. Same * reasoning as `RLSUserContextSchema` / `FlowVersionHistorySchema`. * - * Two near-miss key names, both now aliased because they are genuinely easy to - * cross: - * - hook-level `timeout` vs body-level `timeoutMs` (see hook-body.zod.ts) + * One near-miss key name, aliased because it is genuinely easy to cross: * - hook `retryPolicy.backoffMs` vs datasource `retryPolicy.baseDelayMs` + * The other one — hook-level `timeout` vs body-level `timeoutMs` — was closed + * by #14478: both levels now spell `timeoutMs`, and the bare `timeout` is a + * tombstone carrying the rename. */ /* @@ -160,7 +161,6 @@ export const HookSchema = lazySchema(() => strictObject( when: 'condition', predicate: 'condition', retry: 'retryPolicy', - timeoutms: 'timeout', errorpolicy: 'onError', onfailure: 'onError', // [#14010] `run_as` / `run-as` / `RunAs` all probe-fold to this one entry. @@ -322,8 +322,20 @@ export const HookSchema = lazySchema(() => strictObject( /** * Execution Timeout + * + * Renamed from `timeout` (#14478): the unit (milliseconds) lived only in the + * description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` + * that spell theirs. Tombstoned rather than deleted so the rejection carries + * the rename (a bare unknown-key error would only carry the key). */ - timeout: z.number().optional().describe('Maximum execution time in milliseconds before the hook is aborted'), + timeoutMs: z.number().optional().describe('Maximum execution time in milliseconds before the hook is aborted'), + timeout: retiredKey( + '`hook.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived ' + + 'only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that ' + + 'spell theirs, so the same number read as two conventions on one surface. ' + + 'Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ), /** * Error Policy diff --git a/packages/spec/src/kernel/metadata-loader.test.ts b/packages/spec/src/kernel/metadata-loader.test.ts index c94e10c48a..b3964c914a 100644 --- a/packages/spec/src/kernel/metadata-loader.test.ts +++ b/packages/spec/src/kernel/metadata-loader.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { MetadataFallbackStrategySchema, MetadataManagerConfigSchema, + type MetadataManagerConfig, } from './metadata-loader.zod'; // The loader/persistence envelope vocabulary this file used to also cover @@ -57,7 +58,7 @@ describe('MetadataManagerConfig', () => { formats: ['typescript', 'json'] as const, cache: { enabled: true, - ttl: 7200, + ttlSeconds: 7200, maxSize: 10485760, // 10MB }, watch: true, @@ -78,7 +79,7 @@ describe('MetadataManagerConfig', () => { const validated = MetadataManagerConfigSchema.parse(config); expect(validated.datasource).toBe('postgres_main'); expect(validated.rootDir).toBe('/metadata'); - expect(validated.cache?.ttl).toBe(7200); + expect(validated.cache?.ttlSeconds).toBe(7200); expect(validated.watchOptions?.ignored).toHaveLength(2); expect(validated.loaderOptions?.encoding).toBe('utf-8'); }); @@ -97,10 +98,55 @@ describe('MetadataManagerConfig', () => { it('should reject negative TTL', () => { const config = { - cache: { enabled: true, ttl: -100 }, + cache: { enabled: true, ttlSeconds: -100 }, }; expect(() => MetadataManagerConfigSchema.parse(config)).toThrow(); }); }); }); + +// #14478 — the founding specimen of the duration-unit rule: two keys spelled +// `ttl` fourteen lines apart, the outer in SECONDS and the nested +// DatabaseLoader one in MILLISECONDS, each unit named only in prose. Both are +// retiredKey tombstones now; the unit lives in the key name. +describe('cache.ttl → cache.ttlSeconds, cache.databaseLoader.ttl → ttlMs (#14478)', () => { + it('REFUSES the outer `cache.ttl` with a rename naming `ttlSeconds`', () => { + const result = MetadataManagerConfigSchema.safeParse({ cache: { ttl: 3600 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cache.ttl'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/`cache\.ttl` was removed.*Rename the key to `ttlSeconds`/s); + }); + + it('REFUSES the nested `cache.databaseLoader.ttl` with a rename naming `ttlMs`', () => { + const result = MetadataManagerConfigSchema.safeParse({ cache: { databaseLoader: { ttl: 60_000 } } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'cache.databaseLoader.ttl'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/`cache\.databaseLoader\.ttl` was removed.*Rename the key to `ttlMs`/s); + }); + + it('accepts both suffixed keys at the magnitudes the retired keys carried, and keeps the 1000× defaults apart', () => { + const parsed = MetadataManagerConfigSchema.parse({ + cache: { ttlSeconds: 7200, databaseLoader: { ttlMs: 30_000 } }, + }); + expect(parsed.cache?.ttlSeconds).toBe(7200); + expect(parsed.cache?.databaseLoader?.ttlMs).toBe(30_000); + expect(parsed.cache).not.toHaveProperty('ttl'); + expect(parsed.cache?.databaseLoader).not.toHaveProperty('ttl'); + + const defaults = MetadataManagerConfigSchema.parse({ cache: { databaseLoader: {} } }); + expect(defaults.cache?.ttlSeconds).toBe(3600); + expect(defaults.cache?.databaseLoader?.ttlMs).toBe(60_000); + }); + + it('tsc channel: both retired spellings are unwritable on the input type', () => { + // @ts-expect-error — `cache.ttl` is a tombstone (input type `never`); the key is `ttlSeconds` + const outer: MetadataManagerConfig = { cache: { ttl: 3600 } }; + // @ts-expect-error — `cache.databaseLoader.ttl` is a tombstone; the key is `ttlMs` + const inner: MetadataManagerConfig = { cache: { databaseLoader: { ttl: 60_000 } } }; + const good: MetadataManagerConfig = { cache: { ttlSeconds: 3600, databaseLoader: { ttlMs: 60_000 } } }; + expect([outer, inner, good]).toHaveLength(3); + }); +}); diff --git a/packages/spec/src/kernel/metadata-loader.zod.ts b/packages/spec/src/kernel/metadata-loader.zod.ts index d4a5e70205..2f1dec16ef 100644 --- a/packages/spec/src/kernel/metadata-loader.zod.ts +++ b/packages/spec/src/kernel/metadata-loader.zod.ts @@ -79,7 +79,20 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ */ cache: z.object({ enabled: z.boolean().default(true).describe('Enable caching'), - ttl: z.number().int().min(0).default(3600).describe('Cache TTL in seconds'), + /** + * Renamed from `ttl` (#14478): the unit lived only in this description + * while `databaseLoader.ttl`, fourteen lines below, was in MILLISECONDS — + * one word, two magnitudes 1000× apart. The unit now lives in the key. + * Tombstoned rather than deleted because this nested object is not + * `.strict()` — a plain deletion would strip the old key in silence. + */ + ttlSeconds: z.number().int().min(0).default(3600).describe('Cache TTL in seconds'), + ttl: retiredKey( + '`cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — ' + + 'its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` ' + + 'spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. ' + + 'Rename the key to `ttlSeconds`; the value (seconds) is unchanged.', + ), maxSize: z.number().int().min(0).optional().describe('Max cache size in bytes'), /** * DatabaseLoader read-through cache. @@ -87,13 +100,19 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ * The DatabaseLoader caches `load`/`loadMany`/`list`/`stat` results in an * LRU keyed by `(type, name)`. All write paths invalidate the affected * entry, so reads always observe writes made through the same loader - * instance. External writes (out-of-band SQL) are honored within `ttl` + * instance. External writes (out-of-band SQL) are honored within `ttlMs` * milliseconds. */ databaseLoader: z.object({ enabled: z.boolean().default(true).describe('Enable DatabaseLoader cache'), maxSize: z.number().int().min(0).default(500).describe('Max cached entries'), - ttl: z.number().int().min(0).default(60_000).describe('Cache TTL in milliseconds'), + ttlMs: z.number().int().min(0).default(60_000).describe('Cache TTL in milliseconds'), + ttl: retiredKey( + '`cache.databaseLoader.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 ' + + '(#14478) — its unit (milliseconds) lived only in the description, while the outer `cache.ttl` ' + + 'spelled the same word in seconds, so one key name meant two magnitudes 1000× apart. ' + + 'Rename the key to `ttlMs`; the value (milliseconds) is unchanged.', + ), }).optional().describe('DatabaseLoader read-through cache'), }).optional().describe('Cache settings'), diff --git a/packages/spec/src/migrations/entries/retired-keys/18.data__DriverOptions__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.data__DriverOptions__timeout.ts new file mode 100644 index 0000000000..e8044cc2fb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.data__DriverOptions__timeout.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #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 +// prose and nothing else; renamed to `timeoutMs`, value unchanged. Tombstoned +// with `retiredKey()` because `DriverOptionsSchema` is not `.strict()` (a bare +// deletion would strip the old key in silence). No D2 conversion: a +// `DriverOptions` object is a per-call options argument to driver methods, +// never a stack collection member or a stored row, so the chain has no seam +// (the `kernel/Manifest:loading` precedent); the semantic entry +// `driver-options-timeout-to-timeout-ms` carries the prescription. Registered +// under 18 for the launch-window reason its neighbours state. +export const entry = 'data/DriverOptions:timeout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__Job__timeout.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__Job__timeout.ts new file mode 100644 index 0000000000..d51d97d098 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__Job__timeout.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14478 — maintainer ruling 2026-09-02 (recorded on the card as "ruled B"): +// a duration-shaped `z.number()` key carries its unit in its NAME, never only +// in its `.describe()` prose, and no existing offender is grandfathered. +// `job.timeout` said "in milliseconds" in prose while its sibling +// `retryPolicy.backoffMs` spelled its unit — one surface, two conventions, and +// a seconds value copied in became a limit 1000× too short with no error. +// Renamed to `timeoutMs`; the value is unchanged. Tombstoned with +// `retiredKey()` on the strict `JobSchema` (the `ObjectGridProps:defaultSort` +// route — the baseline line carries `[RETIRED]`, and the tombstone carries the +// rename where a bare unknown-key error would only carry the key); sources +// are rewritten by the D2 conversion `job-timeout-to-timeout-ms`, retired from +// the load path (no alias window). Registered under 18, not 17: v17.0.0 was +// cut before this landed, so the rename ships on the 17.x line +// (launch-window convention) and the prescription lives at the major boundary +// where `migrate meta` users look. +export const entry = 'system/Job:timeout'; diff --git a/packages/spec/src/migrations/entries/semantic/18.driver-options-timeout-to-timeout-ms.ts b/packages/spec/src/migrations/entries/semantic/18.driver-options-timeout-to-timeout-ms.ts new file mode 100644 index 0000000000..5f33f04ef7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.driver-options-timeout-to-timeout-ms.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'driver-options-timeout-to-timeout-ms', + surface: '`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method', + replacement: '`DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B — no grandfathered baseline): the unit of a ' + + 'duration-shaped `z.number()` key lives in the key NAME, never only in the description. ' + + '`timeout` said "Timeout in ms" in prose and nothing else. Tombstoned with retiredKey ' + + '(`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in ' + + 'silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a ' + + 'D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver ' + + 'method — it is not a stack collection member and is never stored, so the chain has no ' + + 'seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the ' + + 'engine\'s own per-call budget is a separate `timeoutMs` on its options), so callers move ' + + 'their spelling with no behaviour change.', + acceptanceCriteria: + 'No caller passes `{ timeout }` in a `DriverOptions` argument; a call spelling it fails to ' + + 'compile (input type `never`) and `DriverOptionsSchema.parse({ timeout: 5000 })` fails with ' + + 'the rename prescription naming `timeoutMs`; `{ timeoutMs: 5000 }` parses to the same ' + + 'number.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-cache-ttl-unit-in-key.ts new file mode 100644 index 0000000000..b0c5fd0a08 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-cache-ttl-unit-in-key.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'metadata-manager-config-cache-ttl-unit-in-key', + surface: 'MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)', + replacement: '`cache.ttlSeconds` (seconds, default 3600) and `cache.databaseLoader.ttlMs` ' + + '(milliseconds, default 60000) — rename each key; the values are unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B — no grandfathered baseline): the unit of a ' + + 'duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never ' + + 'only in the description. This block was the founding specimen: two keys spelled `ttl` ' + + 'fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in ' + + 'MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer ' + + 'number into the inner block got a 3.6-second cache with no error anywhere — the number was ' + + 'valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones ' + + '(the nested objects are not strict; a bare deletion would strip the old key in silence). ' + + 'Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime ' + + 'MetadataManager\'s constructor config, not a stack collection member and never a stored ' + + 'row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and ' + + '`metadata-plugin-additional-types-retired` precedent). The one in-repo reader, ' + + '`DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same ' + + 'magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ' + + 'ca46f8f12, and filed separately).', + acceptanceCriteria: + 'Every `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site spells ' + + '`cache.ttlSeconds` and `cache.databaseLoader.ttlMs`; authoring either old `ttl` fails to ' + + 'compile (input type `never`) and fails to parse with the rename prescription naming the ' + + 'suffixed key; a DatabaseLoader configured with `ttlMs: 60000` expires entries after 60 ' + + 'seconds exactly as `ttl: 60000` did.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts new file mode 100644 index 0000000000..68df3649f0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.tenant-timeouts-unit-in-key.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'tenant-timeouts-unit-in-key', + surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' + + '`accessControl.sessionTimeout` (system/tenant.zod.ts)', + replacement: '`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` ' + + '(default 3600) — rename each key; the values (seconds) are unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B), folding in #14519. Both keys carried their ' + + 'unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` ' + + 'publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one ' + + 'reader who most needs the unit, the reader of the published reference page, was the only ' + + 'reader who never saw it: 300 is a plausible number of seconds and a plausible number of ' + + 'milliseconds, and nothing on the page decided it. #14519 proposed adding the unit to the two ' + + 'descriptions; under the #14478 gate that exact fix is a violation (unit in prose, none in ' + + 'the name), so the keys are renamed instead — one breaking change per key, and the tree ' + + 'never passes through a state the gate refuses. Both are retiredKey tombstones (the nested ' + + 'objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a ' + + 'stack collection member or a stored row (they describe cloud tenancy configuration), so the ' + + 'chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ' + + 'ca46f8f12: no in-repo runtime reads either key.', + acceptanceCriteria: + 'Every tenant isolation / security-policy source spells `idleTimeoutSeconds` and ' + + '`sessionTimeoutSeconds`; authoring `idleTimeout` or `sessionTimeout` fails to compile and ' + + 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults ' + + 'are 300 and 3600 as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d70701822d..c998856791 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5338,7 +5338,16 @@ const step18: MigrationStep = { 'who wrote `triageDeadlineHours: 4` held a deadline the platform never kept. All ' + 'fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion ' + 'would be a silent strip) with no D2 conversion, for the additionalTypes reason: ' + - 'none of these schemas is a stack collection member, so the chain has no seam.', + 'none of these schemas is a stack collection member, so the chain has no seam. ' + + 'Finally, it moves the unit of every duration-shaped `z.number()` key whose unit lived ' + + 'only in its description into the key name (#14478, maintainer ruling 2026-09-02, ' + + 'no grandfathered baseline): `hook.timeout` and `job.timeout` become `timeoutMs` ' + + '(mechanical rename, retired from the load path), and the five keys with no stack ' + + 'seam — `MetadataManagerConfig.cache.ttl` / `cache.databaseLoader.ttl` (seconds and ' + + 'milliseconds fourteen lines apart under one name), `DriverOptions.timeout`, and the ' + + 'tenant `connectionPool.idleTimeout` / `accessControl.sessionTimeout` whose unit the ' + + 'reference pages never published (#14519) — are retiredKey tombstones with a ' + + 'semantic entry each, naming the suffixed key.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5356,6 +5365,8 @@ const step18: MigrationStep = { 'form-view-option-default-removed', 'field-reference-to-alias', 'connector-error-mapping-removed', + 'hook-timeout-to-timeout-ms', + 'job-timeout-to-timeout-ms', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -6265,6 +6276,27 @@ const step18: MigrationStep = { 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + 'parse reports the #9041 refusal.', }, + { + id: 'driver-options-timeout-to-timeout-ms', + surface: '`DriverOptions.timeout` (data/driver.zod.ts) — the per-call options argument of every `IDataDriver` method', + replacement: '`DriverOptions.timeoutMs` (milliseconds) — rename the key; the value is unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B — no grandfathered baseline): the unit of a ' + + 'duration-shaped `z.number()` key lives in the key NAME, never only in the description. ' + + '`timeout` said "Timeout in ms" in prose and nothing else. Tombstoned with retiredKey ' + + '(`DriverOptionsSchema` is not strict, so a bare deletion would strip the old key in ' + + 'silence) and registered as `data/DriverOptions:timeout`. Why a semantic entry and not a ' + + 'D2 conversion: a `DriverOptions` object is built at a call site and handed to a driver ' + + 'method — it is not a stack collection member and is never stored, so the chain has no ' + + 'seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the ' + + 'engine\'s own per-call budget is a separate `timeoutMs` on its options), so callers move ' + + 'their spelling with no behaviour change.', + acceptanceCriteria: + 'No caller passes `{ timeout }` in a `DriverOptions` argument; a call spelling it fails to ' + + 'compile (input type `never`) and `DriverOptionsSchema.parse({ timeout: 5000 })` fails with ' + + 'the rename prescription naming `timeoutMs`; `{ timeoutMs: 5000 }` parses to the same ' + + 'number.', + }, { id: 'driver-sql-unresolvable-where-column-refused', surface: @@ -7384,6 +7416,34 @@ const step18: MigrationStep = { + '`deleteMetaItem` still answer for pre-grammar residue rows, so any stored junk name ' + 'remains listable and clearable.', }, + { + id: 'metadata-manager-config-cache-ttl-unit-in-key', + surface: 'MetadataManagerConfig `cache.ttl` / `cache.databaseLoader.ttl` (kernel/metadata-loader.zod.ts)', + replacement: '`cache.ttlSeconds` (seconds, default 3600) and `cache.databaseLoader.ttlMs` ' + + '(milliseconds, default 60000) — rename each key; the values are unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B — no grandfathered baseline): the unit of a ' + + 'duration-shaped `z.number()` key lives in the key NAME or in a unit-carrying value, never ' + + 'only in the description. This block was the founding specimen: two keys spelled `ttl` ' + + 'fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in ' + + 'MILLISECONDS (60000), each unit named only in `.describe()`. An author who copied the outer ' + + 'number into the inner block got a 3.6-second cache with no error anywhere — the number was ' + + 'valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones ' + + '(the nested objects are not strict; a bare deletion would strip the old key in silence). ' + + 'Why a semantic entry and not a D2 conversion: `MetadataManagerConfig` is the runtime ' + + 'MetadataManager\'s constructor config, not a stack collection member and never a stored ' + + 'row, so the chain has no seam that ever runs on it (the `kernel/Manifest:loading` and ' + + '`metadata-plugin-additional-types-retired` precedent). The one in-repo reader, ' + + '`DatabaseLoader` (`packages/metadata`), reads `cache.databaseLoader.ttlMs` at the same ' + + 'magnitude it read `ttl`; the outer `cache.ttl` had no runtime reader (measured on ' + + 'ca46f8f12, and filed separately).', + acceptanceCriteria: + 'Every `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site spells ' + + '`cache.ttlSeconds` and `cache.databaseLoader.ttlMs`; authoring either old `ttl` fails to ' + + 'compile (input type `never`) and fails to parse with the rename prescription naming the ' + + 'suffixed key; a DatabaseLoader configured with `ttlMs: 60000` expires entries after 60 ' + + 'seconds exactly as `ttl: 60000` did.', + }, { id: 'metadata-plugin-additional-types-retired', surface: 'metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)', @@ -8263,6 +8323,32 @@ const step18: MigrationStep = { + 'parse-and-refuse accepts and rejects exactly the same sets before and after, ' + 'and no stored metadata or document needs editing.', }, + { + id: 'tenant-timeouts-unit-in-key', + surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' + + '`accessControl.sessionTimeout` (system/tenant.zod.ts)', + replacement: '`connectionPool.idleTimeoutSeconds` (default 300) and `accessControl.sessionTimeoutSeconds` ' + + '(default 3600) — rename each key; the values (seconds) are unchanged', + reason: + 'Maintainer ruling 2026-09-02 on #14478 (ruled B), folding in #14519. Both keys carried their ' + + 'unit (seconds) in a source JSDoc only; `.describe()` — the text `content/docs/references/**` ' + + 'publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one ' + + 'reader who most needs the unit, the reader of the published reference page, was the only ' + + 'reader who never saw it: 300 is a plausible number of seconds and a plausible number of ' + + 'milliseconds, and nothing on the page decided it. #14519 proposed adding the unit to the two ' + + 'descriptions; under the #14478 gate that exact fix is a violation (unit in prose, none in ' + + 'the name), so the keys are renamed instead — one breaking change per key, and the tree ' + + 'never passes through a state the gate refuses. Both are retiredKey tombstones (the nested ' + + 'objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a ' + + 'stack collection member or a stored row (they describe cloud tenancy configuration), so the ' + + 'chain has no seam that runs on them (the `kernel/Manifest:loading` precedent). Measured on ' + + 'ca46f8f12: no in-repo runtime reads either key.', + acceptanceCriteria: + 'Every tenant isolation / security-policy source spells `idleTimeoutSeconds` and ' + + '`sessionTimeoutSeconds`; authoring `idleTimeout` or `sessionTimeout` fails to compile and ' + + 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults ' + + 'are 300 and 3600 as before.', + }, { id: 'training-deadline-keys-retired', surface: @@ -9184,6 +9270,18 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // consumers through this tombstone plus the D3 semantic entry // `session-user-language-retired`. 'api/SessionUser:language', + // #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 + // prose and nothing else; renamed to `timeoutMs`, value unchanged. Tombstoned + // with `retiredKey()` because `DriverOptionsSchema` is not `.strict()` (a bare + // deletion would strip the old key in silence). No D2 conversion: a + // `DriverOptions` object is a per-call options argument to driver methods, + // never a stack collection member or a stored row, so the chain has no seam + // (the `kernel/Manifest:loading` precedent); the semantic entry + // `driver-options-timeout-to-timeout-ms` carries the prescription. Registered + // under 18 for the launch-window reason its neighbours state. + 'data/DriverOptions:timeout', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a @@ -10086,6 +10184,22 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // parse) and the D3 semantic entry named below. // D3 semantic entry: `incident-response-deadline-keys-retired`. 'system/IncidentResponsePolicy:triageDeadlineHours', + // #14478 — maintainer ruling 2026-09-02 (recorded on the card as "ruled B"): + // a duration-shaped `z.number()` key carries its unit in its NAME, never only + // in its `.describe()` prose, and no existing offender is grandfathered. + // `job.timeout` said "in milliseconds" in prose while its sibling + // `retryPolicy.backoffMs` spelled its unit — one surface, two conventions, and + // a seconds value copied in became a limit 1000× too short with no error. + // Renamed to `timeoutMs`; the value is unchanged. Tombstoned with + // `retiredKey()` on the strict `JobSchema` (the `ObjectGridProps:defaultSort` + // route — the baseline line carries `[RETIRED]`, and the tombstone carries the + // rename where a bare unknown-key error would only carry the key); sources + // are rewritten by the D2 conversion `job-timeout-to-timeout-ms`, retired from + // the load path (no alias window). Registered under 18, not 17: v17.0.0 was + // cut before this landed, so the rename ships on the 17.x line + // (launch-window convention) and the prescription lives at the major boundary + // where `migrate meta` users look. + 'system/Job:timeout', // #14477 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-02, ruled A: // retire per family). One of the hour/minute/day-shaped deadline keys of the // incident-response / training / change-management families: declared on the diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index 3933508c23..fd0077d790 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -343,12 +343,12 @@ describe('JobSchema', () => { backoffMs: 2000, backoffMultiplier: 2, }, - timeout: 300000, + timeoutMs: 300000, enabled: true, }; const parsed = JobSchema.parse(job); - expect(parsed.timeout).toBe(300000); + expect(parsed.timeoutMs).toBe(300000); expect(parsed.retryPolicy?.maxRetries).toBe(5); }); @@ -369,16 +369,49 @@ describe('JobSchema', () => { }); }); - it('should accept job with timeout', () => { + it('should accept job with timeoutMs', () => { const job = { name: 'long_running_job', schedule: { type: 'cron' as const, expression: '0 0 * * *' }, handler: 'jobs/handler.ts', - timeout: 600000, // 10 minutes + timeoutMs: 600000, // 10 minutes }; const parsed = JobSchema.parse(job); - expect(parsed.timeout).toBe(600000); + expect(parsed.timeoutMs).toBe(600000); + }); + + // #14478 — the unit of a duration-shaped number lives in the key name. The + // old `timeout` is a retiredKey tombstone on this strict shape: the + // rejection must carry the RENAME (the prescription is the payload), not a + // bare unrecognized-key error, and the value must survive the rename at the + // same magnitude. + describe('job.timeout → job.timeoutMs (#14478, ADR-0087 `job-timeout-to-timeout-ms`)', () => { + const base = { + name: 'long_running_job', + schedule: { type: 'cron' as const, expression: '0 0 * * *' }, + handler: 'jobs/handler.ts', + }; + + it('REFUSES the retired `timeout` spelling with the rename in the message', () => { + const result = JobSchema.safeParse({ ...base, timeout: 600000 }); + 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(/`job\.timeout` was removed.*Rename the key to `timeoutMs`.*os migrate meta --from 17/s); + }); + + it('accepts `timeoutMs` at the same magnitude the retired key carried', () => { + const parsed = JobSchema.parse({ ...base, timeoutMs: 600000 }); + expect(parsed.timeoutMs).toBe(600000); + expect(parsed).not.toHaveProperty('timeout'); + }); + + it('no longer aliases `timeoutMs` onto anything — it IS the key', () => { + const result = JobSchema.safeParse({ ...base, timeoutMs: 1 }); + expect(result.success).toBe(true); + }); }); it('should accept disabled job', () => { diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 8598c62675..187b601a37 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -9,6 +9,7 @@ import { CronExpressionInputSchema } from '../shared/expression.zod'; */ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { retiredKey } from '../shared/retired-key'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; export const CronScheduleSchema = lazySchema(() => z.object({ type: z.literal('cron'), @@ -125,11 +126,24 @@ const JOB_ID_RETIRED = + 'different identity. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; +/** + * `job.timeout` → `job.timeoutMs` (#14478). The unit lived only in the + * description; the sibling `retryPolicy.backoffMs` spells its own, so one + * surface carried two conventions and an author copying a seconds value in + * got a limit 1000× too short with no error anywhere. + */ +const JOB_TIMEOUT_RETIRED = + '`job.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only ' + + 'in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number ' + + 'read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) ' + + 'is unchanged. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + export const JobSchema = lazySchema(() => strictObject({ surface: 'this job', history: 'Until this shape was closed these were dropped silently — the item still registered, minus whatever the key was meant to configure.', - aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' }, + aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled' }, guidance: { id: JOB_ID_RETIRED }, }, { // `id` removed in 17.0.0 (#4667) — see JOB_ID_RETIRED. `name` is the identity. @@ -139,7 +153,11 @@ export const JobSchema = lazySchema(() => strictObject({ schedule: ScheduleSchema.describe('Job schedule configuration'), handler: z.string().describe('Handler function name (must match a key in `defineStack({ functions })`)'), retryPolicy: RetryPolicySchema.optional().describe('Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in.'), - timeout: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit.'), + // Renamed from `timeout` (#14478): the unit (milliseconds) lived only in the + // description while the sibling `retryPolicy.backoffMs` spells its own. + // Tombstoned rather than deleted so the rejection carries the rename. + timeoutMs: z.number().int().positive().optional().describe('Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit.'), + timeout: retiredKey(JOB_TIMEOUT_RETIRED), enabled: z.boolean().default(true).describe('Whether the job is enabled'), // ADR-0010 — runtime protection envelope (internal — set by the loader). diff --git a/packages/spec/src/system/tenant.test.ts b/packages/spec/src/system/tenant.test.ts index 3dd44b1440..3e9ba84414 100644 --- a/packages/spec/src/system/tenant.test.ts +++ b/packages/spec/src/system/tenant.test.ts @@ -324,7 +324,7 @@ describe('DatabaseLevelIsolationStrategySchema', () => { connectionPool: { poolSize: 10, maxActivePools: 100, - idleTimeout: 300, + idleTimeoutSeconds: 300, usePooler: true, }, backup: { @@ -448,7 +448,7 @@ describe('TenantSecurityPolicySchema', () => { requireMFA: true, requireSSO: true, ipWhitelist: ['192.168.1.0/24', '10.0.0.0/8'], - sessionTimeout: 3600, + sessionTimeoutSeconds: 3600, }, compliance: { standards: ['sox', 'hipaa', 'gdpr'], @@ -488,7 +488,7 @@ describe('TenantSecurityPolicySchema', () => { const parsed = TenantSecurityPolicySchema.parse(policy); expect(parsed.accessControl?.requireMFA).toBe(false); expect(parsed.accessControl?.requireSSO).toBe(false); - expect(parsed.accessControl?.sessionTimeout).toBe(3600); + expect(parsed.accessControl?.sessionTimeoutSeconds).toBe(3600); }); it('should accept compliance standards', () => { @@ -704,3 +704,53 @@ describe('QuotaEnforcementResultSchema', () => { expect(parsed.limit).toBe(50); }); }); + +// #14478 (folding in #14519) — both keys carried their unit (seconds) in a +// source JSDoc only; the published `.describe()` named none, so the +// reference-page reader could not tell 300 seconds from 300 milliseconds. +// Renamed with the unit in the key; the old spellings are retiredKey +// tombstones (the nested objects are not strict). +describe('tenant idleTimeout / sessionTimeout → *Seconds (#14478, #14519)', () => { + it('REFUSES `connectionPool.idleTimeout` with a rename naming `idleTimeoutSeconds`', () => { + const result = DatabaseLevelIsolationStrategySchema.safeParse({ + strategy: 'isolated_db', + connectionPool: { idleTimeout: 300 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'connectionPool.idleTimeout'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/`connectionPool\.idleTimeout` was removed.*Rename the key to `idleTimeoutSeconds`/s); + }); + + it('REFUSES `accessControl.sessionTimeout` with a rename naming `sessionTimeoutSeconds`', () => { + const result = TenantSecurityPolicySchema.safeParse({ accessControl: { sessionTimeout: 3600 } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'accessControl.sessionTimeout'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(/`accessControl\.sessionTimeout` was removed.*Rename the key to `sessionTimeoutSeconds`/s); + }); + + it('accepts both suffixed keys at the magnitudes the retired keys carried, with the same defaults', () => { + const pool = DatabaseLevelIsolationStrategySchema.parse({ + strategy: 'isolated_db', + connectionPool: { idleTimeoutSeconds: 600 }, + }); + expect(pool.connectionPool?.idleTimeoutSeconds).toBe(600); + expect(pool.connectionPool).not.toHaveProperty('idleTimeout'); + expect( + DatabaseLevelIsolationStrategySchema.parse({ strategy: 'isolated_db', connectionPool: {} }).connectionPool?.idleTimeoutSeconds, + ).toBe(300); + + const policy = TenantSecurityPolicySchema.parse({ accessControl: { sessionTimeoutSeconds: 1800 } }); + expect(policy.accessControl?.sessionTimeoutSeconds).toBe(1800); + expect(policy.accessControl).not.toHaveProperty('sessionTimeout'); + expect(TenantSecurityPolicySchema.parse({ accessControl: {} }).accessControl?.sessionTimeoutSeconds).toBe(3600); + }); + + it('publishes the unit in the describe — the text the reference pages render (#14519)', () => { + const pool = DatabaseLevelIsolationStrategySchema.shape.connectionPool.unwrap().shape.idleTimeoutSeconds; + const access = TenantSecurityPolicySchema.shape.accessControl.unwrap().shape.sessionTimeoutSeconds; + expect(pool.description).toBe('Idle pool timeout in seconds'); + expect(access.description).toBe('Session timeout in seconds'); + }); +}); diff --git a/packages/spec/src/system/tenant.zod.ts b/packages/spec/src/system/tenant.zod.ts index bad70792d7..869ad7cba9 100644 --- a/packages/spec/src/system/tenant.zod.ts +++ b/packages/spec/src/system/tenant.zod.ts @@ -20,6 +20,7 @@ import { z } from 'zod'; * Defines how tenant data is separated in the system */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const TenantIsolationLevel = z.enum([ 'shared_schema', // Shared DB, shared schema, row-level isolation (most economical) 'isolated_schema', // Shared DB, separate schema per tenant (balanced) @@ -547,9 +548,20 @@ export const DatabaseLevelIsolationStrategySchema = lazySchema(() => z.object({ maxActivePools: z.number().int().positive().default(100).describe('Max active pools'), /** - * Idle pool timeout in seconds - */ - idleTimeout: z.number().int().positive().default(300).describe('Idle pool timeout'), + * Idle pool timeout in seconds. + * + * Renamed from `idleTimeout` (#14478 / #14519): the unit lived in this + * JSDoc only, and `.describe()` — the text the reference pages publish — + * carried none. Tombstoned rather than deleted because this nested object + * is not `.strict()`. + */ + idleTimeoutSeconds: z.number().int().positive().default(300).describe('Idle pool timeout in seconds'), + idleTimeout: retiredKey( + '`connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in ' + + '@objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the ' + + 'published description named none, so a reader of the reference page could not tell 300 seconds ' + + 'from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged.', + ), /** * Whether to use connection pooler (PgBouncer, etc.) @@ -667,9 +679,20 @@ export const TenantSecurityPolicySchema = lazySchema(() => z.object({ ipWhitelist: z.array(z.string()).optional().describe('Allowed IP addresses'), /** - * Session timeout in seconds - */ - sessionTimeout: z.number().int().positive().default(3600).describe('Session timeout'), + * Session timeout in seconds. + * + * Renamed from `sessionTimeout` (#14478 / #14519): the unit lived in this + * JSDoc only, and `.describe()` — the text the reference pages publish — + * carried none. Tombstoned rather than deleted because this nested object + * is not `.strict()`. + */ + sessionTimeoutSeconds: z.number().int().positive().default(3600).describe('Session timeout in seconds'), + sessionTimeout: retiredKey( + '`accessControl.sessionTimeout` was removed from `TenantSecurityPolicy` in @objectstack/spec 17 ' + + '(#14478) — its unit (seconds) lived in a source comment only and the published description ' + + 'named none, so a reader of the reference page could not tell 3600 seconds from 3600 ' + + 'milliseconds. Rename the key to `sessionTimeoutSeconds`; the value (seconds) is unchanged.', + ), }).optional().describe('Access control requirements'), /** diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index b2f24f1ebe..f35f86633a 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -107,7 +107,7 @@ const myHook = defineHook({ onError: 'abort', // 'abort' | 'log' // Optional: Execution timeout (ms) - timeout: 5000, + timeoutMs: 5000, // Optional: Retry policy retryPolicy: { @@ -868,7 +868,7 @@ const maskSensitiveData = defineHook({ **1. Declarative — `defineStack({ hooks })`, the default.** `AppPlugin` auto-binds these at startup: no `register*Hook` boilerplate, and the declarative fields -(`condition`, `async`, `retryPolicy`, `timeout`, `onError`, `priority`) are honoured +(`condition`, `async`, `retryPolicy`, `timeoutMs`, `onError`, `priority`) are honoured **only** on this path. A string-named `handler` resolves through the stack's `functions` map. @@ -882,7 +882,7 @@ export default defineStack({ **2. Programmatic — `ctx.ql.registerHook()`, the plugin escape hatch.** Pass `packageId` so the hook can be unregistered cleanly. ⚠️ Hooks bound this way get -**none** of the declarative `condition` / `retryPolicy` / `timeout` / `onError` / +**none** of the declarative `condition` / `retryPolicy` / `timeoutMs` / `onError` / `async` semantics — those apply only through `defineStack({ hooks })` or `ql.bindHooks([...])`. From f5141c17febd3a3ddcac7cf8b8ea0cfb6ef33784 Mon Sep 17 00:00:00 2001 From: os-dev Date: Fri, 4 Sep 2026 23:34:34 +0000 Subject: [PATCH 3/6] wip(spec): regenerate artifacts, i18n bundles, remaining fixtures (#14478) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- content/docs/references/data/driver.mdx | 3 ++- .../docs/references/kernel/metadata-loader.mdx | 7 ++++--- .../docs/references/kernel/metadata-plugin.mdx | 2 +- content/docs/references/system/job.mdx | 3 ++- .../references/system/metadata-persistence.mdx | 7 ++++--- content/docs/references/system/tenant.mdx | 15 +++++++++------ .../translations/en.metadata-forms.generated.ts | 4 ++-- .../es-ES.metadata-forms.generated.ts | 4 ++-- .../translations/es-ES.source-hashes.generated.ts | 4 ++-- .../ja-JP.metadata-forms.generated.ts | 4 ++-- .../translations/ja-JP.source-hashes.generated.ts | 4 ++-- .../zh-CN.metadata-forms.generated.ts | 4 ++-- .../translations/zh-CN.source-hashes.generated.ts | 4 ++-- packages/spec/authorable-surface/data.json | 3 ++- packages/spec/authorable-surface/system.json | 3 ++- packages/spec/liveness/state-counts.md | 6 +++--- packages/spec/scripts/check-duration-unit-keys.ts | 11 +++++++++-- packages/spec/src/system/job.test.ts | 6 +++--- 18 files changed, 55 insertions(+), 39 deletions(-) diff --git a/content/docs/references/data/driver.mdx b/content/docs/references/data/driver.mdx index 886d33b580..8466796d00 100644 --- a/content/docs/references/data/driver.mdx +++ b/content/docs/references/data/driver.mdx @@ -135,7 +135,8 @@ const result = DriverCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **transaction** | `any` | optional | Transaction handle | -| **timeout** | `number` | optional | Timeout in ms | +| **timeoutMs** | `number` | optional | Operation timeout in milliseconds | +| **timeout** | `never` | optional | [REMOVED] `DriverOptions.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **skipCache** | `boolean` | optional | Bypass cache | | **traceContext** | `Record` | optional | OpenTelemetry context or request ID | | **tenantId** | `string` | optional | Tenant Isolation identifier | diff --git a/content/docs/references/kernel/metadata-loader.mdx b/content/docs/references/kernel/metadata-loader.mdx index 7fa1b1e164..7b71ca6b9c 100644 --- a/content/docs/references/kernel/metadata-loader.mdx +++ b/content/docs/references/kernel/metadata-loader.mdx @@ -49,7 +49,7 @@ const result = MetadataFallbackStrategySchema.parse(data); | **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable | | **rootDir** | `string` | optional | Root directory path | | **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats | -| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | | **watch** | `boolean` | optional (default: `false`) | Enable file watching | | **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options | | **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings | @@ -61,9 +61,10 @@ const result = MetadataFallbackStrategySchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable caching | -| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | | **maxSize** | `integer` | optional | Max cache size in bytes | -| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttl: integer }` | optional | DatabaseLoader read-through cache | +| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache | ### Nested Shape: `MetadataManagerConfig.watchOptions` diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 97c926676c..816e64f4a3 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -118,7 +118,7 @@ const result = MetadataBulkResultSchema.parse(data); | **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable | | **rootDir** | `string` | optional | Root directory path | | **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats | -| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | | **watch** | `boolean` | optional (default: `false`) | Enable file watching | | **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options | | **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings | diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index e60dea07a0..4c1f510475 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -58,7 +58,8 @@ const result = CronScheduleSchema.parse(data); | **schedule** | `{ type: 'cron'; expression: string \| object; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` | ✅ | Job schedule configuration | | **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) | | **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. | -| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | +| **timeoutMs** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | +| **timeout** | `never` | optional | [REMOVED] `job.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **enabled** | `boolean` | optional (default: `true`) | Whether the job is enabled | | **_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. | diff --git a/content/docs/references/system/metadata-persistence.mdx b/content/docs/references/system/metadata-persistence.mdx index 40b560fe9d..3ce55ded00 100644 --- a/content/docs/references/system/metadata-persistence.mdx +++ b/content/docs/references/system/metadata-persistence.mdx @@ -242,7 +242,7 @@ Metadata file format | **fallback** | `Enum<'filesystem' \| 'memory' \| 'none'>` | optional (default: `"none"`) | Fallback strategy when datasource is unavailable | | **rootDir** | `string` | optional | Root directory path | | **formats** | `Enum<'yaml' \| 'json' \| 'typescript' \| 'javascript'>[]` | optional (default: `["typescript","json","yaml"]`) | Enabled formats | -| **cache** | `{ enabled: boolean; ttl: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ enabled: boolean; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | | **watch** | `boolean` | optional (default: `false`) | Enable file watching | | **watchOptions** | `{ ignored?: string[]; persistent: boolean; ignoreInitial: boolean }` | optional | File watcher options | | **validation** | `{ strict: boolean; throwOnError: boolean }` | optional | Validation settings | @@ -254,9 +254,10 @@ Metadata file format | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable caching | -| **ttl** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | | **maxSize** | `integer` | optional | Max cache size in bytes | -| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttl: integer }` | optional | DatabaseLoader read-through cache | +| **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache | ### Nested Shape: `MetadataManagerConfig.watchOptions` diff --git a/content/docs/references/system/tenant.mdx b/content/docs/references/system/tenant.mdx index 4e5171bd7b..56ba002d6e 100644 --- a/content/docs/references/system/tenant.mdx +++ b/content/docs/references/system/tenant.mdx @@ -40,7 +40,7 @@ const result = DatabaseLevelIsolationStrategySchema.parse(data); | :--- | :--- | :--- | :--- | | **strategy** | `'isolated_db'` | ✅ | Database-level isolation strategy | | **database** | `{ namingPattern: string; serverStrategy: Enum<'shared' \| 'sharded' \| 'dedicated'>; separateCredentials: boolean; autoCreateDatabase: boolean }` | optional | Database configuration | -| **connectionPool** | `{ poolSize: integer; maxActivePools: integer; idleTimeout: integer; usePooler: boolean }` | optional | Connection pool configuration | +| **connectionPool** | `{ poolSize: integer; maxActivePools: integer; idleTimeoutSeconds: integer; usePooler: boolean }` | optional | Connection pool configuration | | **backup** | `{ strategy: Enum<'individual' \| 'consolidated' \| 'on_demand'>; frequencyHours: integer; retentionDays: integer }` | optional | Backup configuration | | **encryption** | `{ perTenantKeys: boolean; algorithm: string; keyManagement?: Enum<'aws_kms' \| 'azure_key_vault' \| 'gcp_kms' \| 'hashicorp_vault' \| 'custom'> }` | optional | Encryption configuration | @@ -59,7 +59,8 @@ const result = DatabaseLevelIsolationStrategySchema.parse(data); | :--- | :--- | :--- | :--- | | **poolSize** | `integer` | optional (default: `10`) | Connection pool size | | **maxActivePools** | `integer` | optional (default: `100`) | Max active pools | -| **idleTimeout** | `integer` | optional (default: `300`) | Idle pool timeout | +| **idleTimeoutSeconds** | `integer` | optional (default: `300`) | Idle pool timeout in seconds | +| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | | **usePooler** | `boolean` | optional (default: `true`) | Use connection pooler | ### Nested Shape: `DatabaseLevelIsolationStrategy.backup` @@ -313,7 +314,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **strategy** | `'isolated_db'` | ✅ | Database-level isolation strategy | | **database** | `{ namingPattern: string; serverStrategy: Enum<'shared' \| 'sharded' \| 'dedicated'>; separateCredentials: boolean; autoCreateDatabase: boolean }` | optional | Database configuration | -| **connectionPool** | `{ poolSize: integer; maxActivePools: integer; idleTimeout: integer; usePooler: boolean }` | optional | Connection pool configuration | +| **connectionPool** | `{ poolSize: integer; maxActivePools: integer; idleTimeoutSeconds: integer; usePooler: boolean }` | optional | Connection pool configuration | | **backup** | `{ strategy: Enum<'individual' \| 'consolidated' \| 'on_demand'>; frequencyHours: integer; retentionDays: integer }` | optional | Backup configuration | | **encryption** | `{ perTenantKeys: boolean; algorithm: string; keyManagement?: Enum<'aws_kms' \| 'azure_key_vault' \| 'gcp_kms' \| 'hashicorp_vault' \| 'custom'> }` | optional | Encryption configuration | @@ -332,7 +333,8 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **poolSize** | `integer` | optional (default: `10`) | Connection pool size | | **maxActivePools** | `integer` | optional (default: `100`) | Max active pools | -| **idleTimeout** | `integer` | optional (default: `300`) | Idle pool timeout | +| **idleTimeoutSeconds** | `integer` | optional (default: `300`) | Idle pool timeout in seconds | +| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | | **usePooler** | `boolean` | optional (default: `true`) | Use connection pooler | ### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].backup` @@ -391,7 +393,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **encryption** | `{ atRest: boolean; inTransit: boolean; fieldLevel: boolean }` | optional | Encryption requirements | -| **accessControl** | `{ requireMFA: boolean; requireSSO: boolean; ipWhitelist?: string[]; sessionTimeout: integer }` | optional | Access control requirements | +| **accessControl** | `{ requireMFA: boolean; requireSSO: boolean; ipWhitelist?: string[]; sessionTimeoutSeconds: integer }` | optional | Access control requirements | | **compliance** | `{ standards?: Enum<'sox' \| 'hipaa' \| 'gdpr' \| 'pci_dss' \| 'iso_27001' \| 'fedramp'>[]; requireAuditLog: boolean; auditRetentionDays: integer; dataResidency?: object }` | optional | Compliance requirements | ### Nested Shape: `TenantSecurityPolicy.encryption` @@ -409,7 +411,8 @@ This schema accepts one of the following structures: | **requireMFA** | `boolean` | optional (default: `false`) | Require MFA | | **requireSSO** | `boolean` | optional (default: `false`) | Require SSO | | **ipWhitelist** | `string[]` | optional | Allowed IP addresses | -| **sessionTimeout** | `integer` | optional (default: `3600`) | Session timeout | +| **sessionTimeoutSeconds** | `integer` | optional (default: `3600`) | Session timeout in seconds | +| **sessionTimeout** | `never` | optional | [REMOVED] `accessControl.sessionTimeout` was removed from `TenantSecurityPolicy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 3600 seconds from 3600 milliseconds. Rename the key to `sessionTimeoutSeconds`; the value (seconds) is unchanged. | ### Nested Shape: `TenantSecurityPolicy.compliance` diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 434cd685ca..27a331d934 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -547,8 +547,8 @@ export const enMetadataForms: NonNullable = { onError: { label: "On Error" }, - timeout: { - label: "Timeout", + timeoutMs: { + label: "Timeout Ms", helpText: "Abort the hook after N milliseconds" }, runAs: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 4c37198c4d..29f91b9f47 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -547,8 +547,8 @@ export const esESMetadataForms: NonNullable = onError: { label: "Al error" }, - timeout: { - label: "Timeout", + timeoutMs: { + label: "Timeout Ms", helpText: "Abort the hook after N milliseconds" }, runAs: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index cd88187980..55fa9a287c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -85,8 +85,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.hook.fields.retryPolicy.label": "7ec5dad15f687f05", "metadataForms.hook.fields.retryPolicy.maxRetries.helpText": "f7e133cfc5e64240", "metadataForms.hook.fields.retryPolicy.maxRetries.label": "422d764e2265642c", - "metadataForms.hook.fields.timeout.helpText": "31e114f26389aec7", - "metadataForms.hook.fields.timeout.label": "4e36a2d29b385090", + "metadataForms.hook.fields.timeoutMs.helpText": "31e114f26389aec7", + "metadataForms.hook.fields.timeoutMs.label": "2b887c62238d3532", "metadataForms.mapping.description": "654a322ed6e264bb", "metadataForms.mapping.label": "9baba989f46cd1e8", "metadataForms.object.fields.enable.activities.label": "1091422f7f3133d3", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 630f3918f9..5809957c88 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -547,8 +547,8 @@ export const jaJPMetadataForms: NonNullable = onError: { label: "エラー時" }, - timeout: { - label: "Timeout", + timeoutMs: { + label: "Timeout Ms", helpText: "Abort the hook after N milliseconds" }, runAs: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index e6e9a32814..883e5d3879 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -85,8 +85,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.hook.fields.retryPolicy.label": "7ec5dad15f687f05", "metadataForms.hook.fields.retryPolicy.maxRetries.helpText": "f7e133cfc5e64240", "metadataForms.hook.fields.retryPolicy.maxRetries.label": "422d764e2265642c", - "metadataForms.hook.fields.timeout.helpText": "31e114f26389aec7", - "metadataForms.hook.fields.timeout.label": "4e36a2d29b385090", + "metadataForms.hook.fields.timeoutMs.helpText": "31e114f26389aec7", + "metadataForms.hook.fields.timeoutMs.label": "2b887c62238d3532", "metadataForms.mapping.description": "654a322ed6e264bb", "metadataForms.mapping.label": "9baba989f46cd1e8", "metadataForms.object.fields.enable.activities.label": "1091422f7f3133d3", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 47f5089fe9..ececc1dd3f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -547,8 +547,8 @@ export const zhCNMetadataForms: NonNullable = onError: { label: "错误处理" }, - timeout: { - label: "Timeout", + timeoutMs: { + label: "Timeout Ms", helpText: "Abort the hook after N milliseconds" }, runAs: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index d3542beb59..cbf9bf9ccd 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -85,8 +85,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.hook.fields.retryPolicy.label": "7ec5dad15f687f05", "metadataForms.hook.fields.retryPolicy.maxRetries.helpText": "f7e133cfc5e64240", "metadataForms.hook.fields.retryPolicy.maxRetries.label": "422d764e2265642c", - "metadataForms.hook.fields.timeout.helpText": "31e114f26389aec7", - "metadataForms.hook.fields.timeout.label": "4e36a2d29b385090", + "metadataForms.hook.fields.timeoutMs.helpText": "31e114f26389aec7", + "metadataForms.hook.fields.timeoutMs.label": "2b887c62238d3532", "metadataForms.mapping.description": "654a322ed6e264bb", "metadataForms.mapping.label": "9baba989f46cd1e8", "metadataForms.object.fields.enable.activities.label": "1091422f7f3133d3", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 7718349fee..6a2c4222ea 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -273,7 +273,8 @@ "data/DriverOptions:skipCache", "data/DriverOptions:tenantId", "data/DriverOptions:tenantIds", - "data/DriverOptions:timeout", + "data/DriverOptions:timeout [RETIRED]", + "data/DriverOptions:timeoutMs", "data/DriverOptions:timezone", "data/DriverOptions:traceContext", "data/DriverOptions:transaction", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index a223202d98..82dd537b92 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -535,7 +535,8 @@ "system/Job:name", "system/Job:retryPolicy", "system/Job:schedule", - "system/Job:timeout", + "system/Job:timeout [RETIRED]", + "system/Job:timeoutMs", "system/JobExecution:completedAt", "system/JobExecution:durationMs", "system/JobExecution:error", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 1c86358a7d..12c2822aff 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -31,7 +31,7 @@ for both corollaries. | `field` | 89 | 0 | 0 | 1 | 3 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | | `action` | 41 | 0 | 0 | 3 | 4 | 48 | -| `hook` | 19 | 0 | 0 | 2 | 0 | 21 | +| `hook` | 19 | 0 | 0 | 3 | 0 | 22 | | `permission` | 36 | 0 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 0 | 12 | | `agent` | 21 | 4 | 0 | 1 | 0 | 26 | @@ -49,7 +49,7 @@ for both corollaries. | `book` | 20 | 0 | 0 | 1 | 0 | 21 | | `doc` | 15 | 0 | 0 | 0 | 0 | 15 | | `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | -| `job` | 15 | 0 | 0 | 0 | 0 | 15 | +| `job` | 15 | 0 | 0 | 1 | 0 | 16 | | `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | | `seed` | 12 | 0 | 0 | 0 | 0 | 12 | | `translation` | 23 | 0 | 0 | 0 | 2 | 25 | @@ -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** | **844** | **5** | **1** | **90** | **13** | **953** | +| **total** | **844** | **5** | **1** | **92** | **13** | **955** | diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index 6c02974adc..ae222eb17e 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -9,6 +9,7 @@ * tsx scripts/check-duration-unit-keys.ts # gate: exit 1 on any offender * tsx scripts/check-duration-unit-keys.ts --self-test # prove the detector still detects * tsx scripts/check-duration-unit-keys.ts --list # every duration-shaped number key it sees + * tsx scripts/check-duration-unit-keys.ts --root # judge another tree (ablation / demo), same rule * * ## The defect class * @@ -98,7 +99,7 @@ */ import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; @@ -445,7 +446,13 @@ function selfTest(): number { function main(argv: string[]): number { if (argv.includes('--self-test')) return selfTest(); - const { sites, findings, files } = scanTree(); + const rootIdx = argv.indexOf('--root'); + const root = rootIdx >= 0 ? argv[rootIdx + 1] : undefined; + if (rootIdx >= 0 && !root) { + console.error('--root needs a directory'); + return 2; + } + const { sites, findings, files } = scanTree(root ? resolve(root) : undefined); const durationSites = sites.filter((s) => s.proseUnits.length > 0 || s.durationShaped || s.keyUnits.length > 0); if (argv.includes('--list')) { diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index fd0077d790..3071ce599b 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -601,7 +601,7 @@ describe('Job Scheduling Integration', () => { backoffMs: 5000, backoffMultiplier: 2, }, - timeout: 1800000, // 30 minutes + timeoutMs: 1800000, // 30 minutes enabled: true, }; @@ -616,7 +616,7 @@ describe('Job Scheduling Integration', () => { intervalMs: 3600000, // 1 hour }, handler: 'jobs/cleanup.ts', - timeout: 60000, // 1 minute + timeoutMs: 60000, // 1 minute }; expect(() => JobSchema.parse(job)).not.toThrow(); @@ -633,7 +633,7 @@ describe('Job Scheduling Integration', () => { retryPolicy: { maxRetries: 0, // No retries for migrations }, - timeout: 7200000, // 2 hours + timeoutMs: 7200000, // 2 hours }; expect(() => JobSchema.parse(job)).not.toThrow(); From 99999540a73d124ff7a87c2b57a860ef4a1a474e Mon Sep 17 00:00:00 2001 From: os-dev Date: Sat, 5 Sep 2026 00:01:49 +0000 Subject: [PATCH 4/6] wip(spec): strip issue ids from tombstones, declare the gate population, regen docs (#14478) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .changeset/metadata-database-loader-ttl-ms.md | 2 +- content/docs/references/data/driver.mdx | 2 +- .../references/kernel/metadata-loader.mdx | 2 +- content/docs/references/system/job.mdx | 2 +- .../system/metadata-persistence.mdx | 2 +- content/docs/references/system/tenant.mdx | 6 +++--- .../spec/scripts/check-duration-unit-keys.ts | 19 +++++++++++++++++++ packages/spec/src/data/driver.zod.ts | 2 +- packages/spec/src/data/hook.zod.ts | 2 +- .../spec/src/kernel/metadata-loader.zod.ts | 4 ++-- packages/spec/src/system/job.zod.ts | 2 +- packages/spec/src/system/tenant.zod.ts | 4 ++-- 12 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.changeset/metadata-database-loader-ttl-ms.md b/.changeset/metadata-database-loader-ttl-ms.md index 272045eb9b..75b3841a93 100644 --- a/.changeset/metadata-database-loader-ttl-ms.md +++ b/.changeset/metadata-database-loader-ttl-ms.md @@ -4,7 +4,7 @@ feat(metadata)!: `DatabaseLoaderOptions.cache.ttl` → `cache.ttlMs` — the read-through cache TTL carries its unit in the key name (#14478) - + **BREAKING** rename on the exported `DatabaseLoaderOptions.cache` shape (`DatabaseLoaderCacheOptions.ttl` → `ttlMs`), shipped as `minor` under the diff --git a/content/docs/references/data/driver.mdx b/content/docs/references/data/driver.mdx index 8466796d00..c2bbe00bd0 100644 --- a/content/docs/references/data/driver.mdx +++ b/content/docs/references/data/driver.mdx @@ -136,7 +136,7 @@ const result = DriverCapabilitiesSchema.parse(data); | :--- | :--- | :--- | :--- | | **transaction** | `any` | optional | Transaction handle | | **timeoutMs** | `number` | optional | Operation timeout in milliseconds | -| **timeout** | `never` | optional | [REMOVED] `DriverOptions.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | +| **timeout** | `never` | optional | [REMOVED] `DriverOptions.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. | | **skipCache** | `boolean` | optional | Bypass cache | | **traceContext** | `Record` | optional | OpenTelemetry context or request ID | | **tenantId** | `string` | optional | Tenant Isolation identifier | diff --git a/content/docs/references/kernel/metadata-loader.mdx b/content/docs/references/kernel/metadata-loader.mdx index 7b71ca6b9c..fed1b272bf 100644 --- a/content/docs/references/kernel/metadata-loader.mdx +++ b/content/docs/references/kernel/metadata-loader.mdx @@ -62,7 +62,7 @@ const result = MetadataFallbackStrategySchema.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable caching | | **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | -| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | | **maxSize** | `integer` | optional | Max cache size in bytes | | **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache | diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index 4c1f510475..a0e2f82379 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -59,7 +59,7 @@ const result = CronScheduleSchema.parse(data); | **handler** | `string` | ✅ | Handler function name (must match a key in `defineStack({ functions })`) | | **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; maxRetryDelayMs?: integer; … }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = min(backoffMs * backoffMultiplier^(retry-1), maxRetryDelayMs), optionally jittered) up to maxRetries retries after the initial attempt. Omit the block for a single attempt; declaring it without `maxRetries` also means no retry since 17.0.0 — state a count to opt in. | | **timeoutMs** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout". The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. | -| **timeout** | `never` | optional | [REMOVED] `job.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | +| **timeout** | `never` | optional | [REMOVED] `job.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **enabled** | `boolean` | optional (default: `true`) | Whether the job is enabled | | **_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. | diff --git a/content/docs/references/system/metadata-persistence.mdx b/content/docs/references/system/metadata-persistence.mdx index 3ce55ded00..acefa3ca13 100644 --- a/content/docs/references/system/metadata-persistence.mdx +++ b/content/docs/references/system/metadata-persistence.mdx @@ -255,7 +255,7 @@ Metadata file format | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional (default: `true`) | Enable caching | | **ttlSeconds** | `integer` | optional (default: `3600`) | Cache TTL in seconds | -| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. Rename the key to `ttlSeconds`; the value (seconds) is unchanged. | | **maxSize** | `integer` | optional | Max cache size in bytes | | **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache | diff --git a/content/docs/references/system/tenant.mdx b/content/docs/references/system/tenant.mdx index 56ba002d6e..e2e4477339 100644 --- a/content/docs/references/system/tenant.mdx +++ b/content/docs/references/system/tenant.mdx @@ -60,7 +60,7 @@ const result = DatabaseLevelIsolationStrategySchema.parse(data); | **poolSize** | `integer` | optional (default: `10`) | Connection pool size | | **maxActivePools** | `integer` | optional (default: `100`) | Max active pools | | **idleTimeoutSeconds** | `integer` | optional (default: `300`) | Idle pool timeout in seconds | -| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | +| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | | **usePooler** | `boolean` | optional (default: `true`) | Use connection pooler | ### Nested Shape: `DatabaseLevelIsolationStrategy.backup` @@ -334,7 +334,7 @@ This schema accepts one of the following structures: | **poolSize** | `integer` | optional (default: `10`) | Connection pool size | | **maxActivePools** | `integer` | optional (default: `100`) | Max active pools | | **idleTimeoutSeconds** | `integer` | optional (default: `300`) | Idle pool timeout in seconds | -| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | +| **idleTimeout** | `never` | optional | [REMOVED] `connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in @objectstack/spec 17 — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 300 seconds from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged. | | **usePooler** | `boolean` | optional (default: `true`) | Use connection pooler | ### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].backup` @@ -412,7 +412,7 @@ This schema accepts one of the following structures: | **requireSSO** | `boolean` | optional (default: `false`) | Require SSO | | **ipWhitelist** | `string[]` | optional | Allowed IP addresses | | **sessionTimeoutSeconds** | `integer` | optional (default: `3600`) | Session timeout in seconds | -| **sessionTimeout** | `never` | optional | [REMOVED] `accessControl.sessionTimeout` was removed from `TenantSecurityPolicy` in @objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 3600 seconds from 3600 milliseconds. Rename the key to `sessionTimeoutSeconds`; the value (seconds) is unchanged. | +| **sessionTimeout** | `never` | optional | [REMOVED] `accessControl.sessionTimeout` was removed from `TenantSecurityPolicy` in @objectstack/spec 17 — its unit (seconds) lived in a source comment only and the published description named none, so a reader of the reference page could not tell 3600 seconds from 3600 milliseconds. Rename the key to `sessionTimeoutSeconds`; the value (seconds) is unchanged. | ### Nested Shape: `TenantSecurityPolicy.compliance` diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index ae222eb17e..5d7306265c 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -107,6 +107,18 @@ import ts from 'typescript'; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const SRC_ROOT = join(pkgRoot, 'src'); +/** + * The dispatch-gates declaration — the `ROOT_DIR_WATCH_HINTS` idiom (#12310). + * `scripts/pm/dispatch-gates.mjs` derives which gates a card must run from the + * path literals in each gate's source, and `check:declared-population-live` + * refuses a gate whose only path-shaped literal names nothing in the tree. + * This gate walks exactly one subtree — `packages/spec/src/`, tests excluded — + * so that is what it declares, as a LITERAL (the extractor reads source text; + * a value computed from `SRC_ROOT` would produce no hint). The self-test holds + * the literal against the constant the scan actually reads from. + */ +export const ROOT_DIR_WATCH_HINTS = ['packages/spec/src/**']; + /** Canonical unit → every spelling the describe prose or a key token may use. */ const UNIT_SPELLINGS: Readonly> = { ms: ['ms', 'msec', 'msecs', 'millis', 'millisecond', 'milliseconds'], @@ -438,6 +450,13 @@ 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 declared population must be the population the scan reads (the + // ROOT_DIR_WATCH_HINTS idiom's coupling, held from this side). + const repoRoot = join(pkgRoot, '..', '..'); + const declared = `${relative(repoRoot, SRC_ROOT).split('\\').join('/')}/**`; + expect(`declared population \`${ROOT_DIR_WATCH_HINTS.join(', ')}\` is the subtree the scan walks (\`${declared}\`)`, + ROOT_DIR_WATCH_HINTS.length === 1 && ROOT_DIR_WATCH_HINTS[0] === declared); + console.log(failures === 0 ? '\nself-test: all cases pass' : `\nself-test: ${failures} case(s) FAILED`); return failures === 0 ? 0 : 1; } diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index a8935f7803..6e0ffbb2e0 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -26,7 +26,7 @@ export const DriverOptionsSchema = lazySchema(() => z.object({ */ timeoutMs: z.number().optional().describe('Operation timeout in milliseconds'), timeout: retiredKey( - '`DriverOptions.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) ' + + '`DriverOptions.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) ' + 'lived only in the description. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged.', ), diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 8b8b595d45..c92bd21baa 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -330,7 +330,7 @@ export const HookSchema = lazySchema(() => strictObject( */ timeoutMs: z.number().optional().describe('Maximum execution time in milliseconds before the hook is aborted'), timeout: retiredKey( - '`hook.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived ' + + '`hook.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived ' + 'only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that ' + 'spell theirs, so the same number read as two conventions on one surface. ' + 'Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. ' + diff --git a/packages/spec/src/kernel/metadata-loader.zod.ts b/packages/spec/src/kernel/metadata-loader.zod.ts index 2f1dec16ef..38b642780b 100644 --- a/packages/spec/src/kernel/metadata-loader.zod.ts +++ b/packages/spec/src/kernel/metadata-loader.zod.ts @@ -88,7 +88,7 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ */ ttlSeconds: z.number().int().min(0).default(3600).describe('Cache TTL in seconds'), ttl: retiredKey( - '`cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (#14478) — ' + + '`cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — ' + 'its unit (seconds) lived only in the description, while the nested `cache.databaseLoader.ttl` ' + 'spelled the same word in milliseconds, so one key name meant two magnitudes 1000× apart. ' + 'Rename the key to `ttlSeconds`; the value (seconds) is unchanged.', @@ -109,7 +109,7 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ ttlMs: z.number().int().min(0).default(60_000).describe('Cache TTL in milliseconds'), ttl: retiredKey( '`cache.databaseLoader.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 ' + - '(#14478) — its unit (milliseconds) lived only in the description, while the outer `cache.ttl` ' + + '— its unit (milliseconds) lived only in the description, while the outer `cache.ttl` ' + 'spelled the same word in seconds, so one key name meant two magnitudes 1000× apart. ' + 'Rename the key to `ttlMs`; the value (milliseconds) is unchanged.', ), diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 187b601a37..b8a146ae63 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -133,7 +133,7 @@ const JOB_ID_RETIRED = * got a limit 1000× too short with no error anywhere. */ const JOB_TIMEOUT_RETIRED = - '`job.timeout` was removed in @objectstack/spec 17 (#14478) — its unit (milliseconds) lived only ' + '`job.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only ' + 'in the description while the sibling `retryPolicy.backoffMs` spells its own, so the same number ' + 'read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) ' + 'is unchanged. ' diff --git a/packages/spec/src/system/tenant.zod.ts b/packages/spec/src/system/tenant.zod.ts index 869ad7cba9..6077c8bce6 100644 --- a/packages/spec/src/system/tenant.zod.ts +++ b/packages/spec/src/system/tenant.zod.ts @@ -558,7 +558,7 @@ export const DatabaseLevelIsolationStrategySchema = lazySchema(() => z.object({ idleTimeoutSeconds: z.number().int().positive().default(300).describe('Idle pool timeout in seconds'), idleTimeout: retiredKey( '`connectionPool.idleTimeout` was removed from `DatabaseLevelIsolationStrategy` in ' + - '@objectstack/spec 17 (#14478) — its unit (seconds) lived in a source comment only and the ' + + '@objectstack/spec 17 — its unit (seconds) lived in a source comment only and the ' + 'published description named none, so a reader of the reference page could not tell 300 seconds ' + 'from 300 milliseconds. Rename the key to `idleTimeoutSeconds`; the value (seconds) is unchanged.', ), @@ -689,7 +689,7 @@ export const TenantSecurityPolicySchema = lazySchema(() => z.object({ sessionTimeoutSeconds: z.number().int().positive().default(3600).describe('Session timeout in seconds'), sessionTimeout: retiredKey( '`accessControl.sessionTimeout` was removed from `TenantSecurityPolicy` in @objectstack/spec 17 ' + - '(#14478) — its unit (seconds) lived in a source comment only and the published description ' + + '— its unit (seconds) lived in a source comment only and the published description ' + 'named none, so a reader of the reference page could not tell 3600 seconds from 3600 ' + 'milliseconds. Rename the key to `sessionTimeoutSeconds`; the value (seconds) is unchanged.', ), From e68ae2b5822f7358f181df6c54bbd94f2e9d9ad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 01:03:54 +0000 Subject: [PATCH 5/6] fix(driver-turso): the DriverOptions door pin writes timeoutMs (#14478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `turso-driver-options-door.test.ts` builds a `Parameters[3]` literal — that argument IS `DriverOptions`, so the `timeout` key it wrote is the one renamed to `timeoutMs` here, not a driver-local key. Same magnitude (milliseconds), no value conversion. `TursoDriverConfig.timeout` in `turso-driver.ts` and the `timeout` in `src/spec/turso.zod.ts` are a different key on the driver's own connection schema and stay as they are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- .../drivers/driver-turso/src/turso-driver-options-door.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts b/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts index 63cac2a0f2..62c22b9c04 100644 --- a/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-options-door.test.ts @@ -116,7 +116,7 @@ describe('[#6402] TursoDriver `options` doors are DriverOptions, all 17 of them' bypassTenantAudit: true, tenantId: 'org_1', skipCache: true, - timeout: 5_000, + timeoutMs: 5_000, }; expect(declared.tenantId).toBe('org_1'); }); From 140e0b266578c1325d96d95294513232520c72ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:05:57 +0000 Subject: [PATCH 6/6] chore(spec): regenerate liveness state counts on the merged tree (#14478) `packages/spec/liveness/state-counts.md` is routed to `merge=os-regen`, so the merge of origin/main took it without a text merge and left it owing a regeneration. Regenerated with `pnpm --filter @objectstack/spec gen:liveness-counts` from the merged tree, which is the union of both sides: main's `field` row (89 live / 3 planned -> 90 live / 2 planned) lands on top of this branch's `hook` and `job` rows, and the totals follow. No hand edits: the file is generator output. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4 --- packages/spec/liveness/state-counts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 12c2822aff..397795fae6 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| | `object` | 51 | 0 | 0 | 0 | 1 | 52 | -| `field` | 89 | 0 | 0 | 1 | 3 | 93 | +| `field` | 90 | 0 | 0 | 1 | 2 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | | `action` | 41 | 0 | 0 | 3 | 4 | 48 | | `hook` | 19 | 0 | 0 | 3 | 0 | 22 | @@ -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** | **844** | **5** | **1** | **92** | **13** | **955** | +| **total** | **845** | **5** | **1** | **92** | **12** | **955** |