diff --git a/.changeset/17784-tenant-schema-cache-ttl-seconds.md b/.changeset/17784-tenant-schema-cache-ttl-seconds.md new file mode 100644 index 0000000000..faf3b8e088 --- /dev/null +++ b/.changeset/17784-tenant-schema-cache-ttl-seconds.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: the `system/tenant.zod.ts` schema-cache TTL key carries its unit in the key name (#17784, ruling A on #15939) + + + +**BREAKING** — the schema-cache TTL on the `isolated_schema` tenant isolation strategy carries +its unit in the key name. + +| | before | after | +|:--|:--|:--| +| authored key | `performance.schemaCacheTTL: 3600` | `performance.schemaCacheTtlSeconds: 3600` | +| published describe | `Schema cache TTL` | `Schema cache TTL in seconds` | +| value + default | seconds, `3600` | **unchanged** | + +## Migration + +```diff + performance: { +- schemaCacheTTL: 3600, ++ schemaCacheTtlSeconds: 3600, + } +``` + +Rename the key. The value is the same number of seconds it always was, and the `3600` default is +unchanged; nothing else on `SchemaLevelIsolationStrategy` moves. + +## Why + +The key named its unit in a source JSDoc — "Schema cache TTL in seconds" — and nowhere else. The +`.describe()` that `content/docs/references/system/tenant.mdx` renders said "Schema cache TTL" and +named no unit at all, so the one reader who most needs it, the reader of the published reference +page, was the only reader who never saw it: `3600` is a plausible number of seconds and a plausible +number of milliseconds, and nothing on the page decided between them. Executes director-seat ruling +A on #15939 (2026-09-11, maintainer 「同意」, decision batch #115), the per-file remediation of the +#14478 rule — under that rule, moving the unit into the describe alone is itself a violation (unit +in prose, none in the name), so the key is renamed and the describe is corrected together. + +The new spelling is `Ttl`, not `TTL`: counted on this tree, every member of the suffixed family +already spells it that way — `cacheTtlSeconds` (11), `ttlSeconds` (3), `defaultCacheTtlSeconds` (1). + +## The kit + +- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching the + parse raises the rename prescription instead of being silently stripped (the nested `performance` + object is not `.strict()`) +- the ADR-0087 D3 semantic entry `tenant-schema-cache-ttl-unit-in-key` and the + `RETIRED_KEYS_BY_MAJOR[18]` row `system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL`. + No D2 conversion: `stack.zod.ts` declares no tenancy collection and a tenant isolation strategy is + not a stored metadata row, so the chain has no seam that runs on it — the same reading + `tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file +- pin tests on `SchemaLevelIsolationStrategySchema`: the refusal carries the rename prescription, the + suffixed key parses at the magnitude the retired one carried with the same `3600` default, and the + describe publishes the unit +- no authorable-surface row moves — that ratchet records top-level keys per def, and this one is + nested under `performance` (measured: 0 hits for the key across `authorable-surface/` and + `authorable-surface.base.json`, against 4 for the `system/MigrationPlan:` control) diff --git a/content/docs/references/system/tenant.mdx b/content/docs/references/system/tenant.mdx index e2e4477339..d8c5d01ab0 100644 --- a/content/docs/references/system/tenant.mdx +++ b/content/docs/references/system/tenant.mdx @@ -151,7 +151,7 @@ Quota enforcement check result | **strategy** | `'isolated_schema'` | ✅ | Schema-level isolation strategy | | **schema** | `{ namingPattern: string; includePublicSchema: boolean; sharedSchema: string; autoCreateSchema: boolean }` | optional | Schema configuration | | **migrations** | `{ strategy: Enum<'parallel' \| 'sequential' \| 'on_demand'>; maxConcurrent: integer; rollbackOnError: boolean }` | optional | Migration configuration | -| **performance** | `{ poolPerSchema: boolean; schemaCacheTTL: integer }` | optional | Performance settings | +| **performance** | `{ poolPerSchema: boolean; schemaCacheTtlSeconds: integer }` | optional | Performance settings | ### Nested Shape: `SchemaLevelIsolationStrategy.schema` @@ -175,7 +175,8 @@ Quota enforcement check result | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **poolPerSchema** | `boolean` | optional (default: `false`) | Separate pool per schema | -| **schemaCacheTTL** | `integer` | optional (default: `3600`) | Schema cache TTL | +| **schemaCacheTtlSeconds** | `integer` | optional (default: `3600`) | Schema cache TTL in seconds | +| **schemaCacheTTL** | `never` | optional | [REMOVED] `performance.schemaCacheTTL` was renamed to `schemaCacheTtlSeconds` on `SchemaLevelIsolationStrategy` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a source 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 `schemaCacheTtlSeconds`; the value (seconds) is unchanged. | --- @@ -278,7 +279,7 @@ This schema accepts one of the following structures: | **strategy** | `'isolated_schema'` | ✅ | Schema-level isolation strategy | | **schema** | `{ namingPattern: string; includePublicSchema: boolean; sharedSchema: string; autoCreateSchema: boolean }` | optional | Schema configuration | | **migrations** | `{ strategy: Enum<'parallel' \| 'sequential' \| 'on_demand'>; maxConcurrent: integer; rollbackOnError: boolean }` | optional | Migration configuration | -| **performance** | `{ poolPerSchema: boolean; schemaCacheTTL: integer }` | optional | Performance settings | +| **performance** | `{ poolPerSchema: boolean; schemaCacheTtlSeconds: integer }` | optional | Performance settings | ### Nested Shape: `TenantIsolationConfig[strategy='isolated_schema'].schema` @@ -302,7 +303,8 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **poolPerSchema** | `boolean` | optional (default: `false`) | Separate pool per schema | -| **schemaCacheTTL** | `integer` | optional (default: `3600`) | Schema cache TTL | +| **schemaCacheTtlSeconds** | `integer` | optional (default: `3600`) | Schema cache TTL in seconds | +| **schemaCacheTTL** | `never` | optional | [REMOVED] `performance.schemaCacheTTL` was renamed to `schemaCacheTtlSeconds` on `SchemaLevelIsolationStrategy` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a source 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 `schemaCacheTtlSeconds`; the value (seconds) is unchanged. | --- diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts new file mode 100644 index 0000000000..b9e5b7cde7 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__SchemaLevelIsolationStrategy__performance.schemaCacheTTL.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15939 ruling A (per-file remediation of #14478). `performance.schemaCacheTTL` +// said "Schema cache TTL in seconds" in a source JSDoc and "Schema cache TTL" in +// the `.describe()` the reference pages publish, so the published channel named +// no unit at all. Renamed to `schemaCacheTtlSeconds` — `Ttl`, not `TTL`, because +// that is how every member of the suffixed family on this tree already spells it +// (`cacheTtlSeconds`, `ttlSeconds`, `defaultCacheTtlSeconds`). The value and the +// 3600 default are unchanged. Tombstoned with `retiredKey()`: the nested +// `performance` object is not strict, so a bare deletion would silently strip the +// key. No D2 conversion: not a stack collection member, not a stored row. See +// `tenant-schema-cache-ttl-unit-in-key`. +export const entry = 'system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL'; diff --git a/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts new file mode 100644 index 0000000000..725d8d7d80 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'tenant-schema-cache-ttl-unit-in-key', + surface: 'SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)', + replacement: '`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value ' + + '(seconds) is unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. The key carried its unit (seconds) in a ' + + 'source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text ' + + '`content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. ' + + 'So the reader who most needs the unit, the reader of the published reference page, was the ' + + 'only reader who never saw it: 3600 is a plausible number of seconds and a plausible number ' + + 'of milliseconds, and nothing on the page decided it. Under the #14478 gate, moving the unit ' + + 'into the describe alone is itself a violation (unit in prose, none in the name), so the key ' + + 'is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: ' + + 'counted on this tree, the suffixed family already spells it that way in every member ' + + '(`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position ' + + '`TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested ' + + '`performance` object is not strict, so a bare deletion would silently strip the key. Why a ' + + 'semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a ' + + 'tenant isolation strategy is not a stored metadata row (it describes cloud tenancy ' + + 'configuration), so the chain has no seam that runs on it — the same reading ' + + '`tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on ' + + 'bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` ' + + 'and its test the only occurrences are the four generated rows in ' + + '`content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned ' + + 'objectui checkout — `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — spells it 0 ' + + 'times across 6409 tracked files, against lit controls `TTL` 112 and `tenant` 819 on the ' + + 'same corpus.', + acceptanceCriteria: + 'Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; ' + + 'authoring `performance.schemaCacheTTL` fails to compile and fails to parse with the rename ' + + 'prescription naming the suffixed key; the parsed default is 3600 as before, and the ' + + 'published describe reads "Schema cache TTL in seconds".', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4ab46c7fb5..4eb03331fc 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10479,6 +10479,41 @@ const step18: MigrationStep = { + '{ max: 100, duration: 60000 } was, and the positive-integer bound rides along with the ' + 'renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.', }, + { + id: 'tenant-schema-cache-ttl-unit-in-key', + surface: 'SchemaLevelIsolationStrategy `performance.schemaCacheTTL` (system/tenant.zod.ts)', + replacement: '`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value ' + + '(seconds) is unchanged', + reason: + 'Director-seat ruling A on #15939, 2026-09-11, carrying the maintainer\'s 「同意」 (decision ' + + 'batch #115), executing the #14478 rule per file. The key carried its unit (seconds) in a ' + + 'source JSDoc only — "Schema cache TTL in seconds" — while `.describe()`, the text ' + + '`content/docs/references/**` publishes, said "Schema cache TTL" and named no unit at all. ' + + 'So the reader who most needs the unit, the reader of the published reference page, was the ' + + 'only reader who never saw it: 3600 is a plausible number of seconds and a plausible number ' + + 'of milliseconds, and nothing on the page decided it. Under the #14478 gate, moving the unit ' + + 'into the describe alone is itself a violation (unit in prose, none in the name), so the key ' + + 'is renamed and the describe is corrected in the same stroke. Spelled `Ttl` and not `TTL`: ' + + 'counted on this tree, the suffixed family already spells it that way in every member ' + + '(`cacheTtlSeconds` 11, `ttlSeconds` 3, `defaultCacheTtlSeconds` 1) and no key-position ' + + '`TtlSeconds` variant spells it otherwise. Tombstoned with `retiredKey()` because the nested ' + + '`performance` object is not strict, so a bare deletion would silently strip the key. Why a ' + + 'semantic entry and not a D2 conversion: `stack.zod.ts` declares no tenancy collection and a ' + + 'tenant isolation strategy is not a stored metadata row (it describes cloud tenancy ' + + 'configuration), so the chain has no seam that runs on it — the same reading ' + + '`tenant-timeouts-unit-in-key` recorded for the two sibling keys on this file. Measured on ' + + 'bd25e897dc: no in-repo runtime reads the key — outside `packages/spec/src/system/tenant.zod.ts` ' + + 'and its test the only occurrences are the four generated rows in ' + + '`content/docs/references/system/tenant.mdx`, which this rename regenerates; and the pinned ' + + 'objectui checkout — `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` — spells it 0 ' + + 'times across 6409 tracked files, against lit controls `TTL` 112 and `tenant` 819 on the ' + + 'same corpus.', + acceptanceCriteria: + 'Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; ' + + 'authoring `performance.schemaCacheTTL` fails to compile and fails to parse with the rename ' + + 'prescription naming the suffixed key; the parsed default is 3600 as before, and the ' + + 'published describe reads "Schema cache TTL in seconds".', + }, { id: 'tenant-timeouts-unit-in-key', surface: 'DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy ' @@ -13198,6 +13233,17 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `api/BatchEndpointsConfig:operations.upsertMany` are. // D3 semantic entry: `change-management-duration-keys-retired`. 'system/RollbackPlan:steps.estimatedMinutes', + // #15939 ruling A (per-file remediation of #14478). `performance.schemaCacheTTL` + // said "Schema cache TTL in seconds" in a source JSDoc and "Schema cache TTL" in + // the `.describe()` the reference pages publish, so the published channel named + // no unit at all. Renamed to `schemaCacheTtlSeconds` — `Ttl`, not `TTL`, because + // that is how every member of the suffixed family on this tree already spells it + // (`cacheTtlSeconds`, `ttlSeconds`, `defaultCacheTtlSeconds`). The value and the + // 3600 default are unchanged. Tombstoned with `retiredKey()`: the nested + // `performance` object is not strict, so a bare deletion would silently strip the + // key. No D2 conversion: not a stack collection member, not a stored row. See + // `tenant-schema-cache-ttl-unit-in-key`. + 'system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL', // #15679 (stack card 4/6 of #14478) — ruling B. The second of the two // byte-identical `window.size` declarations in `metrics.zod.ts`; it carries the // same prose and takes the same new name, `durationSeconds`, for the reason diff --git a/packages/spec/src/system/tenant.test.ts b/packages/spec/src/system/tenant.test.ts index 3e9ba84414..274e1d5ad7 100644 --- a/packages/spec/src/system/tenant.test.ts +++ b/packages/spec/src/system/tenant.test.ts @@ -271,7 +271,7 @@ describe('SchemaLevelIsolationStrategySchema', () => { }, performance: { poolPerSchema: false, - schemaCacheTTL: 3600, + schemaCacheTtlSeconds: 3600, }, }; @@ -754,3 +754,41 @@ describe('tenant idleTimeout / sessionTimeout → *Seconds (#14478, #14519)', () expect(access.description).toBe('Session timeout in seconds'); }); }); + +// #15939 ruling A (executing #14478) — the third duration key on this file to +// carry its unit in a source JSDoc only. `.describe()` said "Schema cache TTL", +// so the reference-page reader could not tell 3600 seconds from 3600 +// milliseconds. Renamed with the unit in the key; the old spelling is a +// retiredKey tombstone (the nested `performance` object is not strict). +describe('tenant schemaCacheTTL → schemaCacheTtlSeconds (#15939, #14478)', () => { + it('REFUSES `performance.schemaCacheTTL` with a rename naming `schemaCacheTtlSeconds`', () => { + const result = SchemaLevelIsolationStrategySchema.safeParse({ + strategy: 'isolated_schema', + performance: { schemaCacheTTL: 3600 }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'performance.schemaCacheTTL'); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch( + /`performance\.schemaCacheTTL` was renamed.*Rename the key to `schemaCacheTtlSeconds`/s, + ); + }); + + it('accepts the suffixed key at the magnitude the retired key carried, with the same default', () => { + const parsed = SchemaLevelIsolationStrategySchema.parse({ + strategy: 'isolated_schema', + performance: { schemaCacheTtlSeconds: 7200 }, + }); + expect(parsed.performance?.schemaCacheTtlSeconds).toBe(7200); + expect(parsed.performance).not.toHaveProperty('schemaCacheTTL'); + expect( + SchemaLevelIsolationStrategySchema.parse({ strategy: 'isolated_schema', performance: {} }) + .performance?.schemaCacheTtlSeconds, + ).toBe(3600); + }); + + it('publishes the unit in the describe — the text the reference pages render', () => { + const cache = SchemaLevelIsolationStrategySchema.shape.performance.unwrap().shape.schemaCacheTtlSeconds; + expect(cache.description).toBe('Schema cache TTL in seconds'); + }); +}); diff --git a/packages/spec/src/system/tenant.zod.ts b/packages/spec/src/system/tenant.zod.ts index 6077c8bce6..bc44c55777 100644 --- a/packages/spec/src/system/tenant.zod.ts +++ b/packages/spec/src/system/tenant.zod.ts @@ -441,9 +441,24 @@ export const SchemaLevelIsolationStrategySchema = lazySchema(() => z.object({ poolPerSchema: z.boolean().default(false).describe('Separate pool per schema'), /** - * Schema cache TTL in seconds - */ - schemaCacheTTL: z.number().int().positive().default(3600).describe('Schema cache TTL'), + * Schema cache TTL in seconds. + * + * Renamed from `schemaCacheTTL` (#15939 ruling A, executing #14478): the unit + * lived in this JSDoc only, and `.describe()` — the text the reference pages + * publish — carried none. Spelled `Ttl`, not `TTL`, because that is how the + * suffixed family already spells it (`cacheTtlSeconds`, `ttlSeconds`, + * `defaultCacheTtlSeconds`). Tombstoned rather than deleted because this + * nested object is not `.strict()`. + */ + schemaCacheTtlSeconds: z.number().int().positive().default(3600).describe('Schema cache TTL in seconds'), + schemaCacheTTL: retiredKey( + '`performance.schemaCacheTTL` was renamed to `schemaCacheTtlSeconds` on ' + + '`SchemaLevelIsolationStrategy` in @objectstack/spec 17 — the unit of a duration-shaped ' + + 'number lives in the key name, not only in the describe prose. Its unit (seconds) lived in a ' + + 'source 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 ' + + '`schemaCacheTtlSeconds`; the value (seconds) is unchanged.', + ), }).optional().describe('Performance settings'), }));