From 94b976b78e19f85a4d6a598c42f64beaad8a522a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 13:46:00 +0000 Subject: [PATCH 1/3] wip(spec): retire MetadataManagerConfig.cache.{enabled,ttlSeconds,maxSize} tombstones + kit (pre-build) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .changeset/duration-unit-in-key-name.md | 6 +- ...tadata-manager-inert-cache-keys-retired.md | 85 ++++++++++++ .../docs/protocol/kernel/metadata-service.mdx | 11 +- packages/metadata/README.md | 6 +- .../spec/src/kernel/metadata-loader.test.ts | 84 ++++++++--- .../spec/src/kernel/metadata-loader.zod.ts | 57 +++++--- ...l__MetadataManagerConfig__cache.enabled.ts | 23 ++++ ...l__MetadataManagerConfig__cache.maxSize.ts | 13 ++ ...ernel__MetadataManagerConfig__cache.ttl.ts | 16 +++ ...MetadataManagerConfig__cache.ttlSeconds.ts | 15 ++ ...ta-manager-config-cache-ttl-unit-in-key.ts | 19 ++- ...manager-config-inert-cache-keys-retired.ts | 46 +++++++ packages/spec/src/migrations/registry.ts | 130 ++++++++++++++++-- .../src/system/metadata-persistence.test.ts | 2 +- 14 files changed, 459 insertions(+), 54 deletions(-) create mode 100644 .changeset/metadata-manager-inert-cache-keys-retired.md create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.enabled.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.maxSize.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttl.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttlSeconds.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-inert-cache-keys-retired.ts diff --git a/.changeset/duration-unit-in-key-name.md b/.changeset/duration-unit-in-key-name.md index 872f8343d6..df96f095b1 100644 --- a/.changeset/duration-unit-in-key-name.md +++ b/.changeset/duration-unit-in-key-name.md @@ -2,7 +2,7 @@ "@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) +feat(spec)!: a duration-shaped `z.number()` key carries its unit in the key name — `hook.timeout` / `job.timeout` / `DriverOptions.timeout` → `timeoutMs`, `MetadataManagerConfig.cache.databaseLoader.ttl` → `ttlMs` (the outer `cache.ttl` leaves outright under #15624 — nothing read it), tenant `idleTimeout` / `sessionTimeout` → `*Seconds`; new gate `check:duration-unit-keys` (#14478, #14519) @@ -38,7 +38,7 @@ comment and published a bare `300` / `3600` to the reference page (#14519). | `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.ttl` | *(deleted — its respelling `ttlSeconds` was retired before it shipped, #15624; the outer `cache` block was read by nothing, and the live TTL is `cache.databaseLoader.ttlMs`)* | — | | `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) | @@ -52,7 +52,7 @@ 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 } } }); +new MetadataManager({ cache: { databaseLoader: { ttlMs: 60_000 } } }); // the outer `ttl` is deleted, not renamed (#15624) ``` **Migration.** Rename each key; no value changes. Authoring an old spelling diff --git a/.changeset/metadata-manager-inert-cache-keys-retired.md b/.changeset/metadata-manager-inert-cache-keys-retired.md new file mode 100644 index 0000000000..10d5e7b27a --- /dev/null +++ b/.changeset/metadata-manager-inert-cache-keys-retired.md @@ -0,0 +1,85 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata": patch +--- + +feat(spec)!: retire the three inert outer keys of `MetadataManagerConfig.cache` — `enabled`, `ttlSeconds` (formerly `ttl`) and `maxSize` — read by nothing; `cache.databaseLoader` is the only live half (#15624, ADR-0049) + + + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`; the migration prescription is +registered under protocol major 18, where `os migrate meta` users will look). +ADR-0049 enforce-or-remove decides it: a declared-but-unenforced key with zero +measured readers comes off, and the published reference page stops teaching it. + +`MetadataManagerConfig.cache` declared three outer knobs — `enabled` (default +`true`), `ttlSeconds` (default 3600; spelled `ttl` until #14478) and `maxSize` +("Max cache size in bytes") — beside the nested `databaseLoader` block, and +**nothing read the outer three**. The only runtime consumer of the block is +`MetadataManager` (`packages/metadata`), which hands `cache.databaseLoader` and +nothing else to `new DatabaseLoader({ cache })`; a reader census over +`packages/**` (tests and changelogs excluded) found no runtime reader of any +outer key, while the same grep shape found the nested `cache?.databaseLoader` +read twice — the control that makes the zero a measurement. An author writing +`cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse +and a cache that behaved exactly as before, with no error and no warning, and +the published reference page (`references/kernel/metadata-loader`) documented +all three as if they configured something. + +**What is refused:** authoring `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` +or `cache.maxSize` on `MetadataManagerConfig`, with any value — directly, through +`MetadataManagerOptions`, or through `MetadataPluginConfig.storage`. The nested +object is not `.strict()`, so each key is a `retiredKey()` tombstone rather than +a bare deletion (a deletion would have stripped it in silence — the same no-op +one layer down): authoring it is a `tsc` error (`never`) and a parse error +carrying the prescription, which names the live nested knob. + +**What stays, byte-identical:** the DatabaseLoader read-through cache under +`cache.databaseLoader` — `enabled` (default `true`), `maxSize` (an entry count, +default 500) and `ttlMs` (milliseconds, default 60000) — and every runtime +path. Parsed configs no longer carry the two former defaults (`enabled: true`, +`ttlSeconds: 3600`) that were materialized and never consulted. + +**The #14478 rename is folded in.** `cache.ttl` → `cache.ttlSeconds` was +registered under this same unreleased major and never reached a published +release, so it is absorbed by the removal: `cache.ttl`'s tombstone now +prescribes deletion (naming `cache.databaseLoader.ttlMs`) instead of a rename to +a key that is itself retired — an author upgrading from a published 17.x sees +one hop. The nested `cache.databaseLoader.ttl` → `ttlMs` half of that rename is +unchanged. + +## FROM → TO + +```ts +// before — parsed green; no runtime ever read the three outer numbers +new MetadataManager({ + datasource: 'default', + cache: { enabled: true, ttlSeconds: 3600, maxSize: 10_485_760, databaseLoader: { ttlMs: 60_000 } }, +}); + +// after — delete the outer keys; the nested block is the cache that runs +new MetadataManager({ + datasource: 'default', + cache: { databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 } }, +}); +``` + +**Migration.** Delete `cache.enabled`, `cache.ttlSeconds` / `cache.ttl` and +`cache.maxSize`; nothing replaces them, because nothing ever consumed them. If +you meant to switch the cache off, cap it or set its TTL, write +`cache.databaseLoader.enabled` / `.maxSize` (entries) / `.ttlMs` (milliseconds) +— those are honoured. No `os migrate meta` conversion runs on this surface: a +`MetadataManager` config is not a stack collection member and never a stored +row, so the chain has no seam for it; the D3 semantic entry +`metadata-manager-config-inert-cache-keys-retired` carries the prescription +into `spec-changes.json`, the upgrade guide and the `spec_changes` MCP tool. + +The retirement kit: `retiredKey()` tombstones on all three (and the absorbed +`ttl`), `RETIRED_KEYS_BY_MAJOR[18]` entries for each, the D3 semantic entry +above (the #14478 entry's outer half is re-worded from a rename to a deletion), +negative pins asserting each prescription and a positive pin asserting the +parse output no longer materializes the retired defaults, the published +reference pages regenerated, and the hand-written docs page and this package's +README (`@objectstack/metadata` ships `README.md`, hence its `patch`) no longer +authoring `cache.enabled`. diff --git a/content/docs/protocol/kernel/metadata-service.mdx b/content/docs/protocol/kernel/metadata-service.mdx index a3a1fbb6a8..fae4134375 100644 --- a/content/docs/protocol/kernel/metadata-service.mdx +++ b/content/docs/protocol/kernel/metadata-service.mdx @@ -254,12 +254,21 @@ parse-time error carrying the prescription.) new MetadataManager({ datasource: 'default', cache: { - enabled: true, databaseLoader: { enabled: true, maxSize: 500, ttlMs: 60_000 }, }, }); ``` +`cache.databaseLoader` is the only live member of the `cache` block. The outer +`cache.enabled`, `cache.ttlSeconds` (formerly `cache.ttl`) and `cache.maxSize` +were removed in #15624 (ADR-0049 enforce-or-remove): they were declared and +documented but read by nothing — `MetadataManager` hands `cache.databaseLoader` +and nothing else to the loader — so `cache: { enabled: false }` never switched +anything off. Authoring any of them is now a compile-time and parse-time error +carrying the prescription; the switch, TTL and cap that are honoured are +`databaseLoader.enabled`, `databaseLoader.ttlMs` (milliseconds) and +`databaseLoader.maxSize` (an entry count). + The cache exposes `LRUCache.stats()` (`size` / `hits` / `misses` / `hitRate`) for metrics. ### 4. Single-Source Schema Discipline diff --git a/packages/metadata/README.md b/packages/metadata/README.md index 943b9b218e..f79404702e 100644 --- a/packages/metadata/README.md +++ b/packages/metadata/README.md @@ -167,13 +167,15 @@ 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 `ttlMs` milliseconds. -Configuration lives under `cache.databaseLoader`: +Configuration lives under `cache.databaseLoader` — the only live member of the +`cache` block (the outer `cache.enabled` / `cache.ttlSeconds` / `cache.maxSize` +were removed in #15624: nothing ever read them, and authoring one is now a +compile-time and parse-time error naming this nested half): ```typescript new MetadataManager({ datasource: 'default', cache: { - enabled: true, databaseLoader: { enabled: true, maxSize: 500, // Max cached (type, name) entries diff --git a/packages/spec/src/kernel/metadata-loader.test.ts b/packages/spec/src/kernel/metadata-loader.test.ts index b3964c914a..47de684742 100644 --- a/packages/spec/src/kernel/metadata-loader.test.ts +++ b/packages/spec/src/kernel/metadata-loader.test.ts @@ -57,9 +57,7 @@ describe('MetadataManagerConfig', () => { rootDir: '/metadata', formats: ['typescript', 'json'] as const, cache: { - enabled: true, - ttlSeconds: 7200, - maxSize: 10485760, // 10MB + databaseLoader: { enabled: true, maxSize: 250, ttlMs: 30_000 }, }, watch: true, watchOptions: { @@ -79,7 +77,7 @@ describe('MetadataManagerConfig', () => { const validated = MetadataManagerConfigSchema.parse(config); expect(validated.datasource).toBe('postgres_main'); expect(validated.rootDir).toBe('/metadata'); - expect(validated.cache?.ttlSeconds).toBe(7200); + expect(validated.cache?.databaseLoader?.ttlMs).toBe(30_000); expect(validated.watchOptions?.ignored).toHaveLength(2); expect(validated.loaderOptions?.encoding).toBe('utf-8'); }); @@ -96,9 +94,9 @@ describe('MetadataManagerConfig', () => { expect(() => MetadataManagerConfigSchema.parse({ fallback: 'redis' })).toThrow(); }); - it('should reject negative TTL', () => { + it('should reject a negative DatabaseLoader TTL', () => { const config = { - cache: { enabled: true, ttlSeconds: -100 }, + cache: { databaseLoader: { ttlMs: -100 } }, }; expect(() => MetadataManagerConfigSchema.parse(config)).toThrow(); @@ -109,14 +107,19 @@ describe('MetadataManagerConfig', () => { // #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`', () => { +// retiredKey tombstones; the nested one renames to `ttlMs`. The outer one no +// longer renames: #15624 retired its respelling `ttlSeconds` before it shipped +// (nothing read the outer block), so `cache.ttl` now prescribes deletion — an +// author upgrading from a published 17.x sees ONE hop, never a rename to a key +// that is itself a tombstone. +describe('cache.ttl → deleted (absorbed by #15624), cache.databaseLoader.ttl → ttlMs (#14478)', () => { + it('REFUSES the outer `cache.ttl` with a DELETION naming the live `cache.databaseLoader.ttlMs` — not a rename to the retired `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); + expect(issue!.message).toMatch(/`cache\.ttl` was removed.*Delete the key.*`cache\.databaseLoader\.ttlMs`/s); + expect(issue!.message).not.toMatch(/Rename the key to `ttlSeconds`/); }); it('REFUSES the nested `cache.databaseLoader.ttl` with a rename naming `ttlMs`', () => { @@ -127,26 +130,75 @@ describe('cache.ttl → cache.ttlSeconds, cache.databaseLoader.ttl → ttlMs (#1 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', () => { + it('accepts the nested `ttlMs` at the magnitude the retired key carried, and keeps its 60000 default', () => { const parsed = MetadataManagerConfigSchema.parse({ - cache: { ttlSeconds: 7200, databaseLoader: { ttlMs: 30_000 } }, + cache: { 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` + // @ts-expect-error — `cache.ttl` is a tombstone (input type `never`); the outer block is retired whole 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 } } }; + const good: MetadataManagerConfig = { cache: { databaseLoader: { ttlMs: 60_000 } } }; expect([outer, inner, good]).toHaveLength(3); }); }); + +// #15624 — ADR-0049 enforce-or-remove. The outer `cache` block advertised three +// knobs (`enabled`, `ttlSeconds`, `maxSize`) that no runtime read: the only +// consumer of the block is `MetadataManager`, which hands `cache.databaseLoader` +// and nothing else to `new DatabaseLoader({ cache })`. All three are retiredKey +// tombstones; the prescription names the live nested half. Nothing in the +// runtime changed — the pins below are about the ACCEPT face only. +describe('cache.{enabled, ttlSeconds, maxSize} are retired; cache.databaseLoader is the only live half (#15624)', () => { + const RETIRED = [ + { key: 'enabled', value: false, live: /`cache\.databaseLoader`; its `enabled` is the switch that is honoured/s }, + { key: 'ttlSeconds', value: 60, live: /`cache\.databaseLoader\.ttlMs` \(milliseconds, default 60000\)/s }, + { key: 'maxSize', value: 10_485_760, live: /`cache\.databaseLoader\.maxSize` \(an entry count, default 500\)/s }, + ] as const; + + for (const { key, value, live } of RETIRED) { + it(`REFUSES \`cache.${key}\` with the prescription — removed, delete the key, and the live nested knob named`, () => { + const result = MetadataManagerConfigSchema.safeParse({ cache: { [key]: value } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === `cache.${key}`); + expect(issue).toBeDefined(); + expect(issue!.message).toMatch(new RegExp(`\`cache\\.${key}\` was removed.*ADR-0049.*Delete the key`, 's')); + expect(issue!.message).toMatch(live); + }); + } + + it('the parse output no longer materializes the two former defaults (`enabled: true`, `ttlSeconds: 3600`) — the only cache output is the nested half', () => { + const parsed = MetadataManagerConfigSchema.parse({ cache: { databaseLoader: {} } }); + expect(parsed.cache).not.toHaveProperty('enabled'); + expect(parsed.cache).not.toHaveProperty('ttlSeconds'); + expect(parsed.cache).not.toHaveProperty('maxSize'); + expect(parsed.cache).not.toHaveProperty('ttl'); + // The live half is byte-for-byte what it was: its defaults are untouched. + expect(parsed.cache?.databaseLoader).toEqual({ enabled: true, maxSize: 500, ttlMs: 60_000 }); + }); + + it('a config that never wrote the outer keys parses exactly as before, and an EMPTY cache block is still accepted', () => { + expect(MetadataManagerConfigSchema.parse({ cache: {} }).cache).toEqual({}); + expect(MetadataManagerConfigSchema.parse({}).cache).toBeUndefined(); + }); + + it('tsc channel: every retired outer key is unwritable on the input type, and the nested half still is', () => { + // @ts-expect-error — `cache.enabled` is a tombstone (input type `never`); the switch is `cache.databaseLoader.enabled` + const enabled: MetadataManagerConfig = { cache: { enabled: false } }; + // @ts-expect-error — `cache.ttlSeconds` is a tombstone; the TTL is `cache.databaseLoader.ttlMs` + const ttlSeconds: MetadataManagerConfig = { cache: { ttlSeconds: 60 } }; + // @ts-expect-error — `cache.maxSize` is a tombstone; the cap is `cache.databaseLoader.maxSize` + const maxSize: MetadataManagerConfig = { cache: { maxSize: 1 } }; + const good: MetadataManagerConfig = { cache: { databaseLoader: { enabled: false, maxSize: 1, ttlMs: 1 } } }; + expect([enabled, ttlSeconds, maxSize, good]).toHaveLength(4); + }); +}); diff --git a/packages/spec/src/kernel/metadata-loader.zod.ts b/packages/spec/src/kernel/metadata-loader.zod.ts index 38b642780b..1ba584567f 100644 --- a/packages/spec/src/kernel/metadata-loader.zod.ts +++ b/packages/spec/src/kernel/metadata-loader.zod.ts @@ -76,26 +76,51 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ /** * Cache configuration + * + * Only `databaseLoader` is live. The three outer keys (`enabled`, `ttlSeconds` + * — formerly `ttl` — and `maxSize`) were REMOVED (#15624, ADR-0049 + * enforce-or-remove): they were declared, defaulted and documented, and read + * by nothing — the sole runtime consumer of this block is + * `MetadataManager`, which hands `cache.databaseLoader` (and only that) to + * `new DatabaseLoader({ cache })`. An author writing `cache: { enabled: false }` + * got a clean parse and a cache that behaved exactly as before. Tombstoned + * rather than deleted because this nested object is not `.strict()` — a plain + * deletion would strip the keys in silence, which is the same no-op one layer + * down. The #14478 respelling `ttl` → `ttlSeconds` never shipped, so it is + * folded into the removal: both spellings now prescribe deletion. */ cache: z.object({ - enabled: z.boolean().default(true).describe('Enable caching'), - /** - * 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'), + enabled: retiredKey( + '`cache.enabled` was removed from `MetadataManagerConfig` in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared ' + + 'and documented but consumed by no runtime, so `enabled: false` switched nothing off. ' + + 'Delete the key. The cache that actually runs is the DatabaseLoader read-through LRU under ' + + '`cache.databaseLoader`; its `enabled` is the switch that is honoured.', + ), + ttlSeconds: retiredKey( + '`cache.ttlSeconds` was removed from `MetadataManagerConfig` in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared ' + + 'and documented but consumed by no runtime, so the number expired nothing. Delete the key. ' + + 'The TTL that is honoured is `cache.databaseLoader.ttlMs` (milliseconds, default 60000) on ' + + 'the DatabaseLoader read-through cache.', + ), ttl: retiredKey( - '`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.', + '`cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — nothing ' + + 'ever read it: the outer `cache` block was declared and documented but consumed by no ' + + 'runtime, and its unit-suffixed respelling `ttlSeconds` was retired with it before it shipped ' + + '(ADR-0049 enforce-or-remove). Delete the key. The TTL that is honoured is ' + + '`cache.databaseLoader.ttlMs` (milliseconds, default 60000) on the DatabaseLoader ' + + 'read-through cache.', + ), + maxSize: retiredKey( + '`cache.maxSize` was removed from `MetadataManagerConfig` in @objectstack/spec 17 ' + + '(ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared ' + + 'and documented but consumed by no runtime, so the byte cap capped nothing. Delete the key. ' + + 'The cap that is honoured is `cache.databaseLoader.maxSize` (an entry count, default 500) on ' + + 'the DatabaseLoader read-through cache.', ), - maxSize: z.number().int().min(0).optional().describe('Max cache size in bytes'), /** - * DatabaseLoader read-through cache. + * DatabaseLoader read-through cache — the only live member of `cache`. * * The DatabaseLoader caches `load`/`loadMany`/`list`/`stat` results in an * LRU keyed by `(type, name)`. All write paths invalidate the affected @@ -114,7 +139,7 @@ export const MetadataManagerConfigSchema = lazySchema(() => z.object({ 'Rename the key to `ttlMs`; the value (milliseconds) is unchanged.', ), }).optional().describe('DatabaseLoader read-through cache'), - }).optional().describe('Cache settings'), + }).optional().describe('Cache settings — only `databaseLoader` is read at runtime; the outer keys are retired'), /** * Watch for file changes diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.enabled.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.enabled.ts new file mode 100644 index 0000000000..e581c152f4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.enabled.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15624 — ADR-0049 enforce-or-remove (PM ruling on the card, conditioned on +// the measurement it carries). `MetadataManagerConfig.cache.enabled` was +// declared, defaulted (`true`), documented on the published reference page — +// and read by nothing: the only runtime consumer of the `cache` block is +// `MetadataManager`, which hands `cache.databaseLoader` (and only that) to +// `new DatabaseLoader({ cache })`. `enabled: false` switched nothing off. +// Tombstoned with `retiredKey()` (the nested object is not strict; a bare +// deletion would strip the key in silence — the same no-op one layer down). +// The switch that is honoured is `cache.databaseLoader.enabled`. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// tombstone ships on the 17.x line (launch-window convention) and the +// prescription lives at the major boundary where `migrate meta` users look. +// +// Registered here but NOT in `src/conversions/registry.ts` — the +// `kernel/MetadataManagerConfig:persistence.overlayWritable` reasoning: a +// metadata-manager config is not a stack collection member, so a +// MetadataConversion would be a transform with no seam that ever runs. The +// prescription reaches authors through the tombstone (`tsc` + the parse) and +// the D3 semantic entry `metadata-manager-config-inert-cache-keys-retired`. +export const entry = 'kernel/MetadataManagerConfig:cache.enabled'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.maxSize.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.maxSize.ts new file mode 100644 index 0000000000..2fdc65d82d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.maxSize.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.maxSize` +// ("Max cache size in bytes") was declared and documented, and read by nothing +// — the outer `cache` block has no runtime consumer; only +// `cache.databaseLoader` reaches `DatabaseLoader`, whose own `maxSize` is an +// ENTRY COUNT (default 500), not bytes. Tombstoned with `retiredKey()`. The +// cap that is honoured is `cache.databaseLoader.maxSize`. +// +// No D2 conversion, the `persistence.overlayWritable` reasoning: a +// metadata-manager config is not a stack collection member. D3 semantic entry: +// `metadata-manager-config-inert-cache-keys-retired`. +export const entry = 'kernel/MetadataManagerConfig:cache.maxSize'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttl.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttl.ts new file mode 100644 index 0000000000..99883b8e02 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttl.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14478 tombstoned `MetadataManagerConfig.cache.ttl` as a RENAME to +// `ttlSeconds` (D3 `metadata-manager-config-cache-ttl-unit-in-key`) without +// registering the key here — a nested key the authorable-surface walk does not +// reach, so gate (b) never asked for it. #15624 then retired `ttlSeconds` +// itself before the rename ever shipped (ADR-0049 enforce-or-remove: the +// outer `cache` block was read by nothing), so the rename is folded into the +// removal and `cache.ttl`'s tombstone now prescribes deletion — an author +// upgrading from a published 17.x sees one hop, not two. Registered as the +// deletion it now is. The TTL that is honoured is +// `cache.databaseLoader.ttlMs`. +// +// No D2 conversion (a metadata-manager config is not a stack collection +// member). D3 semantic entry: `metadata-manager-config-inert-cache-keys-retired`. +export const entry = 'kernel/MetadataManagerConfig:cache.ttl'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttlSeconds.ts b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttlSeconds.ts new file mode 100644 index 0000000000..e7991d2ca6 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.kernel__MetadataManagerConfig__cache.ttlSeconds.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.ttlSeconds` +// (the #14478 respelling of `cache.ttl`, registered under this same major and +// never shipped) was declared, defaulted (3600) and documented, and read by +// nothing — the outer `cache` block has no runtime consumer; only +// `cache.databaseLoader` reaches `DatabaseLoader`. Tombstoned with +// `retiredKey()`; the rename is folded into the removal (`cache.ttl`'s own +// tombstone now prescribes deletion too — see the sibling entry). The TTL that +// is honoured is `cache.databaseLoader.ttlMs` (milliseconds). +// +// No D2 conversion, the `persistence.overlayWritable` reasoning: a +// metadata-manager config is not a stack collection member. D3 semantic entry: +// `metadata-manager-config-inert-cache-keys-retired`. +export const entry = 'kernel/MetadataManagerConfig:cache.ttlSeconds'; 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 index b0c5fd0a08..25f3cbd663 100644 --- 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 @@ -5,8 +5,10 @@ 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', + replacement: '`cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the ' + + 'value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` ' + + 'was retired before it shipped (#15624, see `metadata-manager-config-inert-cache-keys-retired`) ' + + '— delete the key; nothing ever read it', 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 ' @@ -22,11 +24,14 @@ export const entry: SemanticMigration = { + '`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).', + + 'ca46f8f12, filed as #15624 and retired there under ADR-0049 before this rename shipped — ' + + 'so this entry\'s outer half is a deletion, not a rename, and the `ttlSeconds` spelling ' + + 'never reached a published release).', 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.', + + '`cache.databaseLoader.ttlMs` and no outer TTL at all; authoring either old `ttl` fails to ' + + 'compile (input type `never`) and fails to parse with a prescription — the nested one naming ' + + '`ttlMs`, the outer one prescribing deletion and naming `cache.databaseLoader.ttlMs`; 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.metadata-manager-config-inert-cache-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-inert-cache-keys-retired.ts new file mode 100644 index 0000000000..e24e4b28b5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-manager-config-inert-cache-keys-retired.ts @@ -0,0 +1,46 @@ +// 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-inert-cache-keys-retired', + surface: 'MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / ' + + '`cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)', + replacement: 'nothing to re-declare — delete the three outer keys. The cache that actually runs ' + + 'is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` ' + + '(default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` ' + + '(an entry count, default 500) the cap', + reason: + 'ADR-0049 enforce-or-remove (#15624, PM ruling on the card, conditioned on the measurement ' + + 'it carries and re-taken on the merged ref): the outer `cache` block of ' + + '`MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` ' + + '(default 3600; `ttl` until #14478) and `maxSize` ("bytes") — that no runtime read. The ' + + 'only consumer of the block is `MetadataManager` (`packages/metadata`), which hands ' + + '`cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a ' + + 'repo-wide reader census over `packages/**` (tests and changelogs excluded) found no ' + + 'runtime reader of any outer key, while the same grep shape found the nested ' + + '`cache?.databaseLoader` read twice (the positive control). An author writing ' + + '`cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a ' + + 'cache that behaved exactly as before, and the published reference page documented ' + + 'all three as if they configured something. All three are retiredKey tombstones (the ' + + 'nested object is not strict; a bare deletion would strip them in silence — the same ' + + 'no-op one layer down). The #14478 `ttl` → `ttlSeconds` rename, registered under this ' + + 'same major and never shipped, is folded into the removal: `cache.ttl`\'s tombstone now ' + + 'prescribes deletion rather than a rename to a key that is itself retired, so a 17.x ' + + 'author sees one hop. 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/MetadataManagerConfig:persistence.overlayWritable` precedent). The other ' + + 'candidate — wiring readers for a second cache layer — was not taken: no consumer for ' + + 'one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses.', + acceptanceCriteria: + 'No `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site ' + + 'spells `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` or `cache.maxSize` (TypeScript ' + + 'authors get the refusal at compile time — the keys are typed `never` — and a value ' + + 'reaching the parse is refused with the prescription at the key\'s path, naming ' + + '`cache.databaseLoader`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' + + 'verified as such: the DatabaseLoader read-through cache configured under ' + + '`cache.databaseLoader` (`enabled` / `maxSize` / `ttlMs`) behaves exactly as before, and a ' + + 'config that never wrote the outer keys parses to the same output minus the two former ' + + 'defaults (`enabled: true`, `ttlSeconds: 3600`) that were materialized and never consulted.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3e3f213b02..6f13b2b6af 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5387,7 +5387,15 @@ const step18: MigrationStep = { 'and `NoSQLQueryOptions.timeout`, a per-call driver argument — are retiredKey ' + 'tombstones with a semantic entry each. That remainder is what takes ' + '`check:duration-unit-keys` to zero offenders over `packages/spec/src/**`; the gate ' + - 'goes red again by design when its declared population widens beyond that subtree.', + 'goes red again by design when its declared population widens beyond that subtree. ' + + 'It also retires the three outer keys of `MetadataManagerConfig.cache` — `enabled`, ' + + '`ttlSeconds` (the #14478 respelling of `ttl`, never shipped) and `maxSize` — that the ' + + 'rename above surfaced (#15624, ADR-0049 enforce-or-remove): declared, defaulted and ' + + 'published, read by nothing — `MetadataManager` hands only `cache.databaseLoader` to the ' + + 'loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey ' + + 'tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 ' + + 'conversion (a manager config is no stack collection member); the rename is folded into ' + + 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -8205,8 +8213,10 @@ const step18: MigrationStep = { { 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', + replacement: '`cache.databaseLoader.ttlMs` (milliseconds, default 60000) — rename the nested key; the ' + + 'value is unchanged. The outer `cache.ttl` has NO replacement: its respelling `ttlSeconds` ' + + 'was retired before it shipped (#15624, see `metadata-manager-config-inert-cache-keys-retired`) ' + + '— delete the key; nothing ever read it', 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 ' @@ -8222,13 +8232,58 @@ const step18: MigrationStep = { + '`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).', + + 'ca46f8f12, filed as #15624 and retired there under ADR-0049 before this rename shipped — ' + + 'so this entry\'s outer half is a deletion, not a rename, and the `ttlSeconds` spelling ' + + 'never reached a published release).', 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.', + + '`cache.databaseLoader.ttlMs` and no outer TTL at all; authoring either old `ttl` fails to ' + + 'compile (input type `never`) and fails to parse with a prescription — the nested one naming ' + + '`ttlMs`, the outer one prescribing deletion and naming `cache.databaseLoader.ttlMs`; a ' + + 'DatabaseLoader configured with `ttlMs: 60000` expires entries after 60 seconds exactly as ' + + '`ttl: 60000` did.', + }, + { + id: 'metadata-manager-config-inert-cache-keys-retired', + surface: 'MetadataManagerConfig `cache.enabled` / `cache.ttlSeconds` (formerly `cache.ttl`) / ' + + '`cache.maxSize` (kernel/metadata-loader.zod.ts; tombstoned, see `RETIRED_KEYS_BY_MAJOR[18]`)', + replacement: 'nothing to re-declare — delete the three outer keys. The cache that actually runs ' + + 'is the DatabaseLoader read-through LRU under `cache.databaseLoader`: its `enabled` ' + + '(default true) is the switch, `ttlMs` (milliseconds, default 60000) the TTL and `maxSize` ' + + '(an entry count, default 500) the cap', + reason: + 'ADR-0049 enforce-or-remove (#15624, PM ruling on the card, conditioned on the measurement ' + + 'it carries and re-taken on the merged ref): the outer `cache` block of ' + + '`MetadataManagerConfig` advertised three knobs — `enabled` (default true), `ttlSeconds` ' + + '(default 3600; `ttl` until #14478) and `maxSize` ("bytes") — that no runtime read. The ' + + 'only consumer of the block is `MetadataManager` (`packages/metadata`), which hands ' + + '`cache.databaseLoader` and nothing else to `new DatabaseLoader({ cache })`; a ' + + 'repo-wide reader census over `packages/**` (tests and changelogs excluded) found no ' + + 'runtime reader of any outer key, while the same grep shape found the nested ' + + '`cache?.databaseLoader` read twice (the positive control). An author writing ' + + '`cache: { enabled: false }` or `cache: { ttlSeconds: 60 }` got a clean parse and a ' + + 'cache that behaved exactly as before, and the published reference page documented ' + + 'all three as if they configured something. All three are retiredKey tombstones (the ' + + 'nested object is not strict; a bare deletion would strip them in silence — the same ' + + 'no-op one layer down). The #14478 `ttl` → `ttlSeconds` rename, registered under this ' + + 'same major and never shipped, is folded into the removal: `cache.ttl`\'s tombstone now ' + + 'prescribes deletion rather than a rename to a key that is itself retired, so a 17.x ' + + 'author sees one hop. 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/MetadataManagerConfig:persistence.overlayWritable` precedent). The other ' + + 'candidate — wiring readers for a second cache layer — was not taken: no consumer for ' + + 'one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses.', + acceptanceCriteria: + 'No `new MetadataManager({ cache: … })` / `MetadataManagerConfigSchema.parse(…)` site ' + + 'spells `cache.enabled`, `cache.ttlSeconds`, `cache.ttl` or `cache.maxSize` (TypeScript ' + + 'authors get the refusal at compile time — the keys are typed `never` — and a value ' + + 'reaching the parse is refused with the prescription at the key\'s path, naming ' + + '`cache.databaseLoader`). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be ' + + 'verified as such: the DatabaseLoader read-through cache configured under ' + + '`cache.databaseLoader` (`enabled` / `maxSize` / `ttlMs`) behaves exactly as before, and a ' + + 'config that never wrote the outer keys parses to the same output minus the two former ' + + 'defaults (`enabled: true`, `ttlSeconds: 3600`) that were materialized and never consulted.', }, { id: 'metadata-plugin-additional-types-retired', @@ -11268,6 +11323,65 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `contributes.kinds` (metadata kinds), `navigationContributions` // (ADR-0029 D7), and plugin code itself (`init`/`start`). 'kernel/Manifest:extensions', + // #15624 — ADR-0049 enforce-or-remove (PM ruling on the card, conditioned on + // the measurement it carries). `MetadataManagerConfig.cache.enabled` was + // declared, defaulted (`true`), documented on the published reference page — + // and read by nothing: the only runtime consumer of the `cache` block is + // `MetadataManager`, which hands `cache.databaseLoader` (and only that) to + // `new DatabaseLoader({ cache })`. `enabled: false` switched nothing off. + // Tombstoned with `retiredKey()` (the nested object is not strict; a bare + // deletion would strip the key in silence — the same no-op one layer down). + // The switch that is honoured is `cache.databaseLoader.enabled`. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // tombstone ships on the 17.x line (launch-window convention) and the + // prescription lives at the major boundary where `migrate meta` users look. + // + // Registered here but NOT in `src/conversions/registry.ts` — the + // `kernel/MetadataManagerConfig:persistence.overlayWritable` reasoning: a + // metadata-manager config is not a stack collection member, so a + // MetadataConversion would be a transform with no seam that ever runs. The + // prescription reaches authors through the tombstone (`tsc` + the parse) and + // the D3 semantic entry `metadata-manager-config-inert-cache-keys-retired`. + 'kernel/MetadataManagerConfig:cache.enabled', + // #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.maxSize` + // ("Max cache size in bytes") was declared and documented, and read by nothing + // — the outer `cache` block has no runtime consumer; only + // `cache.databaseLoader` reaches `DatabaseLoader`, whose own `maxSize` is an + // ENTRY COUNT (default 500), not bytes. Tombstoned with `retiredKey()`. The + // cap that is honoured is `cache.databaseLoader.maxSize`. + // + // No D2 conversion, the `persistence.overlayWritable` reasoning: a + // metadata-manager config is not a stack collection member. D3 semantic entry: + // `metadata-manager-config-inert-cache-keys-retired`. + 'kernel/MetadataManagerConfig:cache.maxSize', + // #14478 tombstoned `MetadataManagerConfig.cache.ttl` as a RENAME to + // `ttlSeconds` (D3 `metadata-manager-config-cache-ttl-unit-in-key`) without + // registering the key here — a nested key the authorable-surface walk does not + // reach, so gate (b) never asked for it. #15624 then retired `ttlSeconds` + // itself before the rename ever shipped (ADR-0049 enforce-or-remove: the + // outer `cache` block was read by nothing), so the rename is folded into the + // removal and `cache.ttl`'s tombstone now prescribes deletion — an author + // upgrading from a published 17.x sees one hop, not two. Registered as the + // deletion it now is. The TTL that is honoured is + // `cache.databaseLoader.ttlMs`. + // + // No D2 conversion (a metadata-manager config is not a stack collection + // member). D3 semantic entry: `metadata-manager-config-inert-cache-keys-retired`. + 'kernel/MetadataManagerConfig:cache.ttl', + // #15624 — ADR-0049 enforce-or-remove. `MetadataManagerConfig.cache.ttlSeconds` + // (the #14478 respelling of `cache.ttl`, registered under this same major and + // never shipped) was declared, defaulted (3600) and documented, and read by + // nothing — the outer `cache` block has no runtime consumer; only + // `cache.databaseLoader` reaches `DatabaseLoader`. Tombstoned with + // `retiredKey()`; the rename is folded into the removal (`cache.ttl`'s own + // tombstone now prescribes deletion too — see the sibling entry). The TTL that + // is honoured is `cache.databaseLoader.ttlMs` (milliseconds). + // + // No D2 conversion, the `persistence.overlayWritable` reasoning: a + // metadata-manager config is not a stack collection member. D3 semantic entry: + // `metadata-manager-config-inert-cache-keys-retired`. + 'kernel/MetadataManagerConfig:cache.ttlSeconds', // #13135 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-29 on // #12057, adopting retirement; re-charter #13135 executes the widened // surface). `persistence.overlayWritable` gated exactly one method — diff --git a/packages/spec/src/system/metadata-persistence.test.ts b/packages/spec/src/system/metadata-persistence.test.ts index 38a98371fd..ace613b0ad 100644 --- a/packages/spec/src/system/metadata-persistence.test.ts +++ b/packages/spec/src/system/metadata-persistence.test.ts @@ -475,7 +475,7 @@ describe('MetadataManagerConfigSchema', () => { const config = MetadataManagerConfigSchema.parse({ rootDir: '/app', watch: true, - cache: { enabled: true }, + cache: { databaseLoader: { enabled: true } }, formats: ['json', 'yaml'], }); From 0663ee68a4a3732b8a95e7e5bcac27b6ce75c2e0 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 14:20:37 +0000 Subject: [PATCH 2/3] wip(spec): regenerate reference docs for the retired MetadataManagerConfig.cache keys Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- content/docs/references/kernel/metadata-loader.mdx | 10 +++++----- content/docs/references/kernel/metadata-plugin.mdx | 2 +- .../docs/references/system/metadata-persistence.mdx | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/content/docs/references/kernel/metadata-loader.mdx b/content/docs/references/kernel/metadata-loader.mdx index fed1b272bf..33e5193e50 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; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ databaseLoader?: object }` | optional | Cache settings — only `databaseLoader` is read at runtime; the outer keys are retired | | **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 | @@ -60,10 +60,10 @@ const result = MetadataFallbackStrategySchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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 — 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 | +| **enabled** | `never` | optional | [REMOVED] `cache.enabled` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so `enabled: false` switched nothing off. Delete the key. The cache that actually runs is the DatabaseLoader read-through LRU under `cache.databaseLoader`; its `enabled` is the switch that is honoured. | +| **ttlSeconds** | `never` | optional | [REMOVED] `cache.ttlSeconds` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so the number expired nothing. Delete the key. The TTL that is honoured is `cache.databaseLoader.ttlMs` (milliseconds, default 60000) on the DatabaseLoader read-through cache. | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, and its unit-suffixed respelling `ttlSeconds` was retired with it before it shipped (ADR-0049 enforce-or-remove). Delete the key. The TTL that is honoured is `cache.databaseLoader.ttlMs` (milliseconds, default 60000) on the DatabaseLoader read-through cache. | +| **maxSize** | `never` | optional | [REMOVED] `cache.maxSize` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so the byte cap capped nothing. Delete the key. The cap that is honoured is `cache.databaseLoader.maxSize` (an entry count, default 500) on the 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 816e64f4a3..be3b619479 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; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ databaseLoader?: object }` | optional | Cache settings — only `databaseLoader` is read at runtime; the outer keys are retired | | **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/metadata-persistence.mdx b/content/docs/references/system/metadata-persistence.mdx index acefa3ca13..6add79ed77 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; ttlSeconds: integer; maxSize?: integer; databaseLoader?: object }` | optional | Cache settings | +| **cache** | `{ databaseLoader?: object }` | optional | Cache settings — only `databaseLoader` is read at runtime; the outer keys are retired | | **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 | @@ -253,10 +253,10 @@ Metadata file format | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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 — 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 | +| **enabled** | `never` | optional | [REMOVED] `cache.enabled` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so `enabled: false` switched nothing off. Delete the key. The cache that actually runs is the DatabaseLoader read-through LRU under `cache.databaseLoader`; its `enabled` is the switch that is honoured. | +| **ttlSeconds** | `never` | optional | [REMOVED] `cache.ttlSeconds` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so the number expired nothing. Delete the key. The TTL that is honoured is `cache.databaseLoader.ttlMs` (milliseconds, default 60000) on the DatabaseLoader read-through cache. | +| **ttl** | `never` | optional | [REMOVED] `cache.ttl` was removed from `MetadataManagerConfig` in @objectstack/spec 17 — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, and its unit-suffixed respelling `ttlSeconds` was retired with it before it shipped (ADR-0049 enforce-or-remove). Delete the key. The TTL that is honoured is `cache.databaseLoader.ttlMs` (milliseconds, default 60000) on the DatabaseLoader read-through cache. | +| **maxSize** | `never` | optional | [REMOVED] `cache.maxSize` was removed from `MetadataManagerConfig` in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the outer `cache` block was declared and documented but consumed by no runtime, so the byte cap capped nothing. Delete the key. The cap that is honoured is `cache.databaseLoader.maxSize` (an entry count, default 500) on the DatabaseLoader read-through cache. | | **databaseLoader** | `{ enabled: boolean; maxSize: integer; ttlMs: integer }` | optional | DatabaseLoader read-through cache | ### Nested Shape: `MetadataManagerConfig.watchOptions` From 03bf78c4426fb16b9080c1861be532c70bef9ed7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 7 Sep 2026 15:01:48 +0000 Subject: [PATCH 3/3] docs(spec,metadata): the absorbed rename's migration sentence names the outer deletion; ROADMAP drops the retired overlayWritable gate Patch round on #15624 after the contract review (VERDICT: PASS): the duration-unit changeset's "Rename each key" sentence now states the one exception its own table already carried (the outer cache.ttl is deleted, not renamed), and packages/metadata/ROADMAP.md stops listing persistence.overlayWritable, retired in #13135, as a live write gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf --- .changeset/duration-unit-in-key-name.md | 5 ++++- packages/metadata/ROADMAP.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/duration-unit-in-key-name.md b/.changeset/duration-unit-in-key-name.md index df96f095b1..275d26b793 100644 --- a/.changeset/duration-unit-in-key-name.md +++ b/.changeset/duration-unit-in-key-name.md @@ -55,7 +55,10 @@ defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * new MetadataManager({ cache: { databaseLoader: { ttlMs: 60_000 } } }); // the outer `ttl` is deleted, not renamed (#15624) ``` -**Migration.** Rename each key; no value changes. Authoring an old spelling +**Migration.** Rename each key; no value changes — with one exception: the outer +`MetadataManagerConfig.cache.ttl` is DELETED, not renamed (its respelling `ttlSeconds` +was retired before it shipped, #15624; nothing ever read the outer `cache` block, and +the nested `cache.databaseLoader.ttl → ttlMs` rename above is unchanged). 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`, diff --git a/packages/metadata/ROADMAP.md b/packages/metadata/ROADMAP.md index 461ff408f1..1a2f45c64c 100644 --- a/packages/metadata/ROADMAP.md +++ b/packages/metadata/ROADMAP.md @@ -29,7 +29,7 @@ | Structural validation | Basic name/type/label validation | | **DatabaseLoader read-through cache** | Generic `LRUCache` (lazy TTL, promote-on-get, write invalidation) wrapping `load`/`loadMany`/`list`/`stat`. Configured via `cache.databaseLoader`. | | **Bootstrap modes** | `MetadataPluginConfig.bootstrap` = `eager` \| `lazy` \| `artifact-only` — supports edge / serverless / read-only deployments. | -| **Persistence write gates** | `MetadataManagerConfig.persistence.{ writable, overlayWritable }` — runtime freeze for sealed kernels. | +| **Persistence write gates** | `MetadataManagerConfig.persistence.writable` — runtime freeze for sealed kernels (`overlayWritable` was retired in #13135 with the paper metadata-customization protocol; authoring it is a compile-time and parse-time error). | | **Single-source schema discipline** | Canonical `MetadataManagerConfigSchema` / `MetadataFallbackStrategySchema` live in `kernel/metadata-loader.zod.ts` and are re-exported from `system/metadata-persistence.zod.ts`. | | **Remote artifact boot** | `MetadataPlugin` boots from a compiled artifact via `artifactSource: { mode: 'local-file', path }`, where `path` may be an `http(s)` URL — e.g. the control plane's public `/pub/v1/environments/:id/artifact[?commit=…]` route. Wired across `eager` / `lazy` / `artifact-only` bootstrap modes. Configurable timeout via `fetchTimeoutMs` or `OS_ARTIFACT_FETCH_TIMEOUT_MS` (default 60 s). A dedicated `artifact-api` mode (Bearer-authenticated control-plane pull) was removed in #4246 — zero consumers in any repo; the cloud runtime uses its own `ArtifactApiClient`. |