Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/17784-tenant-schema-cache-ttl-seconds.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered tenant-schema-cache-ttl-unit-in-key -->

**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)
10 changes: 6 additions & 4 deletions content/docs/references/system/tenant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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. |


---
Expand Down Expand Up @@ -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`

Expand All @@ -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. |

---

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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".',
};
46 changes: 46 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
Expand Down Expand Up @@ -13198,6 +13233,17 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly<Record<number, readonly string[]>>
// `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
Expand Down
40 changes: 39 additions & 1 deletion packages/spec/src/system/tenant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ describe('SchemaLevelIsolationStrategySchema', () => {
},
performance: {
poolPerSchema: false,
schemaCacheTTL: 3600,
schemaCacheTtlSeconds: 3600,
},
};

Expand Down Expand Up @@ -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');
});
});
21 changes: 18 additions & 3 deletions packages/spec/src/system/tenant.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
}));

Expand Down
Loading