From b8fad3540b6e3ee9a38edd721d3b8e335edb9d61 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:05:27 +0000 Subject: [PATCH 1/9] feat(spec): a retired key's tsc diagnostic names the retirement retiredKey() now declares its input type as an object whose one required property is named by the retirement sentence and typed never, so writing a tombstoned key prints that the key was removed from @objectstack/spec and that os validate prints its migration, instead of a bare 'not assignable to type undefined'. The runtime schema is unchanged (an upcast of the same z.never().optional()). Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/src/shared/retired-key.ts | 70 +++++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index f367f8ba014..9adf4e3083c 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -31,8 +31,10 @@ * lands in the two channels an upgrading author — very often an AI (ADR-0033) — * actually reads: * - * 1. **`tsc`.** The input type becomes `never`, so assigning anything to the - * key fails to compile at the authoring site, before anything runs. + * 1. **`tsc`.** The input type admits nothing but absence, so assigning + * anything to the key fails to compile at the authoring site, before + * anything runs — and the diagnostic itself says the key was removed and + * where its migration is printed ({@link RetiredKeySchema}). * 2. **The parse.** A value reaching the runtime raises the prescription * itself — not a generic "unrecognized key". This matters because upgrades * do not happen in the order we imagine: someone jumping several majors at @@ -88,12 +90,60 @@ import { z } from 'zod'; +/** + * The declared type of every {@link retiredKey} tombstone: optional, never + * produces a value, and its INPUT is an object type whose one required + * property is named by the retirement sentence and typed `never`. + * + * The input type is what `tsc` and the editor show an author who writes the + * key, so it is spelled to be read there. Writing any value prints the + * sentence, whatever the value's kind: + * + * TS2322: Type 'string' is not assignable to type '{ readonly '[REMOVED] This key …': never; }'. + * TS2741: Property ''[REMOVED] This key …'' is missing in type 'string[]' but required in type '{ … }'. + * TS2353: Object literal may only specify known properties, and 'field' does not exist in type '{ … }'. + * + * and the hover reads `key?: { readonly '[REMOVED] This key …': never; } | undefined`. + * Before this type the input was `undefined`, and the diagnostic read + * `Type 'string[]' is not assignable to type 'undefined'` — audible, but it + * said nothing about a retirement, so an upgrading author read it as a typing + * bug. + * + * Three choices here are measured, not stylistic: + * + * - **The object type is written INLINE and anonymous.** A named alias or + * interface is printed BY NAME in the diagnostic (the sentence is lost for + * every value that is not an array), and the name would appear in the + * declared type of every tombstoned schema — a new type every consumer's + * declaration emit must be able to name through an entry point. Inline, + * each site's declared type references `zod` alone. + * - **The sentence is fixed, not the key's own prescription.** A type can + * carry a prescription only as a string LITERAL type, and TypeScript types + * `'…' + '…'`, a constant built that way and a helper's return all as + * `string` — the form every guidance in this tree takes. So a type + * generic over `guidance` would carry the text at no site at all. The + * sentence names what happened and the door that prints the per-key + * prescription (`os validate`, or the parse itself); it never says + * "delete", because some retirements are renames. + * - **No value satisfies it.** The property's type is `never`, so no + * authored value — array, object or primitive — is assignable without a + * cast, exactly as `undefined` refused everything but absence. The + * runtime is untouched: the schema IS the `z.never().optional()` below + * (an upcast, `never` being assignable to anything), so the parse, the + * prescription and every walker reading `_zod.def` (`acceptsNothing`, + * the JSON-schema build) see what they saw before. + */ +type RetiredKeySchema = z.ZodOptional>; + /** * Declare a key that has been REMOVED from the spec. * * Accepts only `undefined` — i.e. absence. Any authored value is rejected with - * `guidance`, and `z.input` types the key as `never` so the same mistake fails - * `tsc` first. + * `guidance`, and `z.input` types the key so the same mistake fails `tsc` + * first, with a diagnostic that says the key was removed + * ({@link RetiredKeySchema}). * * @param guidance - The upgrade prescription. State what replaced the key, the * version that removed it, and the one-line fix — this string IS the migration @@ -110,7 +160,7 @@ import { z } from 'zod'; * ), * ``` */ -export function retiredKey(guidance: string) { +export function retiredKey(guidance: string): RetiredKeySchema { return z.never({ error: () => guidance }).optional().describe(`[REMOVED] ${guidance}`); } @@ -133,8 +183,8 @@ export type RetiredDefaultResidue = Readonly Date: Wed, 30 Sep 2026 23:19:56 +0000 Subject: [PATCH 2/9] test(spec): pin that a retired key's tsc diagnostic says [REMOVED] and names os validate Compiles in-memory defineStack probes against the spec source and reads the diagnostics: page.assignedProfiles (strictObject, array), a field's conditionalRequired (strictObject, string) and an index's type (non-strict z.object, string), with a clean control probe for anti-vacuity. compose-stacks.test.ts authored the retired App.objects key in two fixtures that assert nothing about it; the key is deleted per its own prescription, which retires the two test-typecheck debt entries whose signature this change rewrote. Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/src/compose-stacks.test.ts | 4 +- .../shared/retired-key-tsc-diagnostic.test.ts | 166 ++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts index 4537b504a93..5d627cc3fe4 100644 --- a/packages/spec/src/compose-stacks.test.ts +++ b/packages/spec/src/compose-stacks.test.ts @@ -88,10 +88,10 @@ describe('composeStacks', () => { it('should concatenate apps from multiple stacks', () => { const crm = makeStack({ - apps: [{ name: 'sales', label: 'Sales', objects: ['account'] }], + apps: [{ name: 'sales', label: 'Sales' }], }); const todo = makeStack({ - apps: [{ name: 'tasks', label: 'Tasks', objects: ['task'] }], + apps: [{ name: 'tasks', label: 'Tasks' }], }); const result = composeStacks([crm, todo]); diff --git a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts new file mode 100644 index 00000000000..c8af6fa59d9 --- /dev/null +++ b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A retired key's `tsc` diagnostic names the retirement. + * + * `retiredKey()` makes a removal audible in two channels: the parse (the + * prescription) and `tsc`. Until this pin the `tsc` channel only said + * `Type 'string[]' is not assignable to type 'undefined'` — audible, but it + * named no retirement, and an upgrading author read forty of them as typing + * bugs. The tombstone's input type now carries a fixed sentence + * (`RetiredKeySchema` in `retired-key.ts`), and this file measures what `tsc` + * actually prints for an authored retired key. + * + * ## Why the compiler API + * + * A `@ts-expect-error` proves only THAT the line fails, never what the + * diagnostic says — and the text is the whole payload here. So each probe + * below is compiled with `ts.createProgram` against this package's real + * source (`@objectstack/spec` is mapped to `src/` through `paths`) and the + * assertion reads the diagnostic itself. The probes are in-memory overlays; + * nothing is written to disk. + * + * ## What is asserted, and what is not + * + * The diagnostic code, the line (the retired key's own), and the two named + * subjects the sentence exists to deliver: the house `[REMOVED]` marker and the + * door that prints the per-key prescription, `os validate`. The sentence's + * remaining wording is deliberately not pinned. + * + * The cases span both object modes and both value kinds, because `tsc` words + * them differently: an array value reads as a missing property (TS2741), a + * string as a plain non-assignable type (TS2322). `PageSchema` and + * `FieldSchema` are `strictObject`s; `IndexSchema` is a plain `z.object`. + * + * ⚠️ Anti-vacuity: a harness that resolves nothing reports import errors or + * nothing at all, and both look like "no retirement text". The control probe + * authors the same stack WITHOUT the retired keys and must compile with ZERO + * diagnostics, which proves `@objectstack/spec` resolved and `defineStack` + * type-checks the literal. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PROBE_DIR = resolve(SRC_DIR, 'shared/__retired_key_tsc_probes__'); + +/** The stack every probe authors; `retired` splices retired keys into it. */ +function stackProbe(retired: { page?: string; field?: string; index?: string }): string { + return [ + "import { defineStack } from '@objectstack/spec';", + '', + 'export const stack = defineStack({', + " manifest: { id: 'com.example.probe', name: 'probe', version: '1.0.0', type: 'app' },", + ' objects: [', + ' {', + " name: 'deal',", + " label: 'Deal',", + ' fields: {', + " amount: { type: 'number', label: 'Amount' },", + ` stage: { type: 'text', label: 'Stage'${retired.field ?? ''} },`, + ' },', + ` indexes: [{ fields: ['amount']${retired.index ?? ''} }],`, + ' },', + ' ],', + ` pages: [{ name: 'home_page', label: 'Home', type: 'home'${retired.page ?? ''} }],`, + '});', + '', + ].join('\n'); +} + +const PROBES = { + control: stackProbe({}), + // HotCRM's measured case: `page.assignedProfiles` (strictObject, array value). + page: stackProbe({ page: ", assignedProfiles: ['sales_rep']" }), + // A rename retirement on a strictObject, string value. + field: stackProbe({ field: ", conditionalRequired: 'amount > 0'" }), + // A non-strict `z.object` tombstone, string value. + index: stackProbe({ index: ", type: 'btree'" }), +} as const; + +type ProbeName = keyof typeof PROBES; + +function compileProbes(): Map { + const paths = new Map(); + for (const [name, text] of Object.entries(PROBES)) paths.set(resolve(PROBE_DIR, `${name}.ts`), text); + + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + types: ['node'], + paths: { + '@objectstack/spec': [resolve(SRC_DIR, 'index.ts')], + '@objectstack/spec/*': [resolve(SRC_DIR, '*/index.ts')], + }, + }; + + const host = ts.createCompilerHost(options, true); + const realGetSourceFile = host.getSourceFile.bind(host); + const realFileExists = host.fileExists.bind(host); + const realReadFile = host.readFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const overlay = paths.get(resolve(fileName)); + return overlay === undefined + ? realGetSourceFile(fileName, languageVersion, onError, shouldCreate) + : ts.createSourceFile(fileName, overlay, languageVersion, true); + }; + host.fileExists = (fileName) => paths.has(resolve(fileName)) || realFileExists(fileName); + host.readFile = (fileName) => paths.get(resolve(fileName)) ?? realReadFile(fileName); + + const program = ts.createProgram([...paths.keys()], options, host); + const out = new Map(); + for (const name of Object.keys(PROBES) as ProbeName[]) { + const file = program.getSourceFile(resolve(PROBE_DIR, `${name}.ts`)); + if (file === undefined) throw new Error(`probe ${name} did not enter the program`); + out.set(name, [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]); + } + return out; +} + +/** `L TS: `, one per diagnostic. */ +function render(diagnostics: readonly ts.Diagnostic[]): string[] { + return diagnostics.map((d) => { + const line = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : 0; + return `L${line} TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`; + }); +} + +/** The 1-based line of `needle` in a probe's text. */ +function lineOf(name: ProbeName, needle: string): number { + const lines = PROBES[name].split('\n'); + const index = lines.findIndex((l) => l.includes(needle)); + if (index < 0) throw new Error(`${needle} is not in probe ${name}`); + return index + 1; +} + +describe('a retired key names its retirement in the tsc diagnostic', () => { + const diagnostics = compileProbes(); + + it('control: the same stack without retired keys compiles clean (the harness resolves the spec)', () => { + expect(render(diagnostics.get('control')!)).toEqual([]); + }); + + const cases: ReadonlyArray<[ProbeName, string, number]> = [ + ['page', 'assignedProfiles', 2741], + ['field', 'conditionalRequired', 2322], + ['index', "type: 'btree'", 2322], + ]; + + it.each(cases)('%s: writing %s fails with TS%i, and the message says [REMOVED] and names `os validate`', (name, key, code) => { + const rendered = render(diagnostics.get(name)!); + expect(rendered, 'exactly one diagnostic: the retired key').toHaveLength(1); + const [only] = rendered; + expect(only.startsWith(`L${lineOf(name, key)} TS${code}: `), only).toBe(true); + expect(only).toContain('[REMOVED]'); + expect(only).toContain('`os validate`'); + // The form this replaced named no retirement at all. + expect(only).not.toContain("to type 'undefined'"); + }); +}); From b9b13c4599c0c94dcd0df324f7454032d7749ec4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:25:16 +0000 Subject: [PATCH 3/9] fix(spec): the tombstone mark rides both sides, so ADR-0122 isomorphism holds A mark on the input alone split 21 otherwise-isomorphic schemas pinned in type-alias-convention.pin.test.ts into two shapes that differ only by the diagnostic device, which ADR-0122 would answer with a synonym XParsed each. The output now carries the same uninhabitable mark. The sentence is shorter because the declaration emitter spells the mark out at every site. Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/src/shared/retired-key.ts | 40 +++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index 9adf4e3083c..4b65214cd92 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -91,32 +91,33 @@ import { z } from 'zod'; /** - * The declared type of every {@link retiredKey} tombstone: optional, never - * produces a value, and its INPUT is an object type whose one required - * property is named by the retirement sentence and typed `never`. + * The declared type of every {@link retiredKey} tombstone: optional, and both + * its INPUT and its OUTPUT are an object type whose one property is named by + * the retirement sentence and typed `never` — a mark no value can carry. * * The input type is what `tsc` and the editor show an author who writes the * key, so it is spelled to be read there. Writing any value prints the * sentence, whatever the value's kind: * - * TS2322: Type 'string' is not assignable to type '{ readonly '[REMOVED] This key …': never; }'. - * TS2741: Property ''[REMOVED] This key …'' is missing in type 'string[]' but required in type '{ … }'. + * TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: …': never; }'. + * TS2741: Property ''[REMOVED] Key retired: …'' is missing in type 'string[]' but required in type '{ … }'. * TS2353: Object literal may only specify known properties, and 'field' does not exist in type '{ … }'. * - * and the hover reads `key?: { readonly '[REMOVED] This key …': never; } | undefined`. + * and a hover on the key reads `key?: { '[REMOVED] Key retired: …': never; } | undefined`. * Before this type the input was `undefined`, and the diagnostic read * `Type 'string[]' is not assignable to type 'undefined'` — audible, but it * said nothing about a retirement, so an upgrading author read it as a typing * bug. * - * Three choices here are measured, not stylistic: + * Four choices here are measured, not stylistic: * - * - **The object type is written INLINE and anonymous.** A named alias or + * - **The mark is written INLINE and anonymous.** A named alias or * interface is printed BY NAME in the diagnostic (the sentence is lost for * every value that is not an array), and the name would appear in the * declared type of every tombstoned schema — a new type every consumer's * declaration emit must be able to name through an entry point. Inline, - * each site's declared type references `zod` alone. + * each site's declared type references `zod` alone. The price is bytes: + * the declaration emitter spells the mark out at every site. * - **The sentence is fixed, not the key's own prescription.** A type can * carry a prescription only as a string LITERAL type, and TypeScript types * `'…' + '…'`, a constant built that way and a helper's return all as @@ -125,6 +126,14 @@ import { z } from 'zod'; * sentence names what happened and the door that prints the per-key * prescription (`os validate`, or the parse itself); it never says * "delete", because some retirements are renames. + * - **The OUTPUT carries the same mark.** ADR-0122 declares an `XParsed` + * alias exactly for the schemas whose `z.input` and `z.infer` differ, and + * pins the rest isomorphic (`type-alias-convention.pin.test.ts`). A mark + * on the input alone would split every tombstoned schema that is + * otherwise isomorphic into two "shapes" that differ by a diagnostic + * device, and demand a synonym `XParsed` for each. Parsed data never + * holds the key, and no value can be the mark, so the output stays + * exactly as empty as `undefined` was. * - **No value satisfies it.** The property's type is `never`, so no * authored value — array, object or primitive — is assignable without a * cast, exactly as `undefined` refused everything but absence. The @@ -133,9 +142,16 @@ import { z } from 'zod'; * prescription and every walker reading `_zod.def` (`acceptsNothing`, * the JSON-schema build) see what they saw before. */ -type RetiredKeySchema = z.ZodOptional>; +type RetiredKeySchema = Tombstone<{ + '[REMOVED] Key retired: run `os validate` for its migration.': never; +}>; + +/** + * Spells the mark once in source for both sides. A generic alias over a + * `zod` reference is not preserved at the call sites — they print the `zod` + * types with the mark inline, which is the point (see above). + */ +type Tombstone = z.ZodOptional>; /** * Declare a key that has been REMOVED from the spec. From 1ad091cc4efac850cecc4824ac1632dfea4cee2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:39:25 +0000 Subject: [PATCH 4/9] =?UTF-8?q?chore(spec):=20re-record=20test-typecheck?= =?UTF-8?q?=20debt=20=E2=80=94=20two=20compose-stacks=20entries=20retired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/test-typecheck-debt.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index 86a571ae928..23abb9a04ab 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -34,7 +34,6 @@ "TS7006: Parameter 'w' implicitly has an 'any' type.": 2 }, "src/compose-stacks.test.ts": { - "TS2322: Type '…' is not assignable to type 'undefined'.": 2, "TS2322: Type '…' is not assignable to type '…'.": 2, "TS2353: Object literal may only specify known properties, and 'object' does not exist in type '…'.": 2, "TS2353: Object literal may only specify known properties, and 'route' does not exist in type '…'.": 2 From 2c82e3bd5bb34ac2b7b9af74bd6aeb2c1cef3a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:40:31 +0000 Subject: [PATCH 5/9] =?UTF-8?q?chore(changeset):=20spec=20minor=20?= =?UTF-8?q?=E2=80=94=20a=20retired=20key's=20tsc=20error=20names=20the=20r?= =?UTF-8?q?etirement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- .../20621-retired-key-tsc-names-retirement.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .changeset/20621-retired-key-tsc-names-retirement.md diff --git a/.changeset/20621-retired-key-tsc-names-retirement.md b/.changeset/20621-retired-key-tsc-names-retirement.md new file mode 100644 index 00000000000..6fb725e7080 --- /dev/null +++ b/.changeset/20621-retired-key-tsc-names-retirement.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): writing a retired key now fails `tsc` with an error that says the key was retired and where its migration is printed (#20621) + +Clause-②: yes + +**What an author sees.** Every key a `retiredKey()` tombstone declares used to be typed `undefined`, so writing one failed `tsc` with an error that named no retirement: + +``` +error TS2322: Type 'string[]' is not assignable to type 'undefined'. +``` + +Upgrading authors read those errors as typing bugs. The same line now fails like this, for arrays, objects and primitive values alike: + +``` +error TS2741: Property ''[REMOVED] Key retired: run `os validate` for its migration.'' is missing in type 'string[]' but required in type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. +error TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. +``` + +A hover on the key shows the same text. `os validate` and the parse print the key's own prescription, which says what replaced the key and gives the one-line fix. + +**What changed.** The declared type of each tombstoned key, on both the input side (`z.input`, the bare `X` aliases) and the parsed side (`z.infer`, the `XParsed` aliases), is now `{ '[REMOVED] Key retired: run `os validate` for its migration.': never } | undefined` instead of `undefined`. No value can have that object type, because its one property is typed `never`. So `tsc` still accepts only absence, as before. Both sides carry the same type, which keeps the ADR-0122 isomorphism pins true. + +**What did not change.** Runtime behaviour is the same. Each tombstone is still `z.never().optional()`. The parse error and its prescription, the text `os validate` prints, the ADR-0087 conversions, the JSON schemas and the authorable-surface artifacts are all unchanged. No export was added or removed. + +**Who might notice.** Code that assigns a tombstoned key's value to a slot typed exactly `undefined` (for example `const x: undefined = page.assignedProfiles`) no longer compiles. The key never holds a value, so delete the read. The published declaration files are about 7.5% larger, because the declaration emitter writes the type out at every site of the 287 `retiredKey()` calls. From 2d7bc9f7fe150a33655941a53b786b3c729f567b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:40:41 +0000 Subject: [PATCH 6/9] chore(changeset): code span with an inner backtick Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- .changeset/20621-retired-key-tsc-names-retirement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20621-retired-key-tsc-names-retirement.md b/.changeset/20621-retired-key-tsc-names-retirement.md index 6fb725e7080..914ba4a0e80 100644 --- a/.changeset/20621-retired-key-tsc-names-retirement.md +++ b/.changeset/20621-retired-key-tsc-names-retirement.md @@ -21,7 +21,7 @@ error TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: A hover on the key shows the same text. `os validate` and the parse print the key's own prescription, which says what replaced the key and gives the one-line fix. -**What changed.** The declared type of each tombstoned key, on both the input side (`z.input`, the bare `X` aliases) and the parsed side (`z.infer`, the `XParsed` aliases), is now `{ '[REMOVED] Key retired: run `os validate` for its migration.': never } | undefined` instead of `undefined`. No value can have that object type, because its one property is typed `never`. So `tsc` still accepts only absence, as before. Both sides carry the same type, which keeps the ADR-0122 isomorphism pins true. +**What changed.** The declared type of each tombstoned key, on both the input side (`z.input`, the bare `X` aliases) and the parsed side (`z.infer`, the `XParsed` aliases), is now `` { '[REMOVED] Key retired: run `os validate` for its migration.': never } | undefined `` instead of `undefined`. No value can have that object type, because its one property is typed `never`. So `tsc` still accepts only absence, as before. Both sides carry the same type, which keeps the ADR-0122 isomorphism pins true. **What did not change.** Runtime behaviour is the same. Each tombstone is still `z.never().optional()`. The parse error and its prescription, the text `os validate` prints, the ADR-0087 conversions, the JSON schemas and the authorable-surface artifacts are all unchanged. No export was added or removed. From 911206487388ed54a6acad0da6c3f6f31419a308 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:27:00 +0000 Subject: [PATCH 7/9] test(spec): two tsc-channel pins read the tombstone mark instead of 'undefined' DriverCapabilities' retired bits and the connector example's three retired keys pinned the old diagnostic, which named no retirement. They now assert the tombstone mark ([REMOVED], os validate) that retiredKey() declares. Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/src/data/driver.test.ts | 11 +++++++---- .../integration/connector-author-shape.test.ts | 17 ++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/spec/src/data/driver.test.ts b/packages/spec/src/data/driver.test.ts index 5135220bd00..ceb41bb9c20 100644 --- a/packages/spec/src/data/driver.test.ts +++ b/packages/spec/src/data/driver.test.ts @@ -199,14 +199,17 @@ describe('[#4634] tsc channel: the retired bits are unwritable in DriverCapabili const typeAt = (name: string) => checker.typeToString(checker.getTypeOfSymbolAtLocation(props.get(name)!, decl!)); + // `retiredKey()` types the property as its tombstone mark or `undefined`: + // an object whose one property is named by the `[REMOVED]` sentence and + // typed `never`, so there is no value an author can write. A resurrected + // live bit reads `boolean` (or wider) here and turns this red. + const TOMBSTONE = /^\{ '\[REMOVED\] [^']*': never; \} \| undefined$/; for (const bit of RETIRED_BITS) { - // `retiredKey()` is `z.never().optional()`: the property type collapses - // to `undefined` — there is no value an author can write. A resurrected - // live bit reads `boolean` (or wider) here and turns this red. - expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toBe('undefined'); + expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toMatch(TOMBSTONE); } for (const bit of LIVE_BITS) { expect(typeAt(bit), `live bit ${bit} must stay writable`).not.toBe('undefined'); + expect(typeAt(bit), `live bit ${bit} must not be a tombstone`).not.toMatch(TOMBSTONE); } }); }); diff --git a/packages/spec/src/integration/connector-author-shape.test.ts b/packages/spec/src/integration/connector-author-shape.test.ts index 341684d5f19..eafdce8fca6 100644 --- a/packages/spec/src/integration/connector-author-shape.test.ts +++ b/packages/spec/src/integration/connector-author-shape.test.ts @@ -269,16 +269,19 @@ describe('[#5515] the spellings the example used to carry are rejected', () => { const results = compileProbes(probes); // ⚠ Measured, not assumed — and it is the one place the two tombstone - // channels are NOT equally good. `retiredKey()` is `z.never().optional()`, so - // its `z.input` type is `undefined`, and tsc reports the assignment failure - // against that type: "Type '{ … }' is not assignable to type 'undefined'". - // The compile channel therefore REFUSES the key but does not NAME it, while - // the parse channel (the runtime block below) carries the full prescription. + // channels are NOT equally good. `retiredKey()` types the key as its + // tombstone mark (an object whose one property is named by the `[REMOVED]` + // sentence and typed `never`), so tsc reports the assignment failure against + // that mark, worded by the value's kind: TS2741 for the two arrays, TS2353 + // for the `syncConfig` object. The compile channel therefore REFUSES the key + // and says it was retired, pointing at `os validate`, while only the parse + // channel (the runtime block below) carries the key's own prescription. for (const probe of ['field-mappings-retired', 'sync-config-retired', 'webhooks-retired'] as const) { it(`\`${probe}\`: the key no longer type-checks, whatever it holds`, () => { const message = render(results.get(probe)!); - expect(message).toContain('TS2322'); - expect(message).toContain("not assignable to type 'undefined'"); + expect(message).toMatch(/^TS(2741|2353): /); + expect(message).toContain('[REMOVED]'); + expect(message).toContain('`os validate`'); }); } From e6889c8311788dbbb8f35f8cffe6453aa89c6e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:56:43 +0000 Subject: [PATCH 8/9] docs(spec): rewrap two retired-key.ts paragraphs Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- packages/spec/src/shared/retired-key.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index 4b65214cd92..0b3d44fb78e 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -200,11 +200,12 @@ export type RetiredDefaultResidue = Readonly Date: Thu, 1 Oct 2026 02:38:38 +0000 Subject: [PATCH 9/9] chore(changeset): declare the compile break and its ADR-0087 disposition A read of a tombstoned key into a slot typed undefined, or typed with the key's old type, no longer compiles. The changeset now says so with a BREAKING banner, and states not-required (no-migration-prescription). Claude-Session: https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6 Co-authored-by: Claude --- .changeset/20621-retired-key-tsc-names-retirement.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/20621-retired-key-tsc-names-retirement.md b/.changeset/20621-retired-key-tsc-names-retirement.md index 914ba4a0e80..4b37bff5626 100644 --- a/.changeset/20621-retired-key-tsc-names-retirement.md +++ b/.changeset/20621-retired-key-tsc-names-retirement.md @@ -6,6 +6,8 @@ feat(spec): writing a retired key now fails `tsc` with an error that says the ke Clause-②: yes + + **What an author sees.** Every key a `retiredKey()` tombstone declares used to be typed `undefined`, so writing one failed `tsc` with an error that named no retirement: ``` @@ -25,4 +27,6 @@ A hover on the key shows the same text. `os validate` and the parse print the ke **What did not change.** Runtime behaviour is the same. Each tombstone is still `z.never().optional()`. The parse error and its prescription, the text `os validate` prints, the ADR-0087 conversions, the JSON schemas and the authorable-surface artifacts are all unchanged. No export was added or removed. +**BREAKING**: a read of a tombstoned key into a slot typed `undefined`, or typed with the key's old type, no longer compiles, because the key's declared type is now its tombstone mark. The key never holds a value, so delete the dead read. It ships as `minor` under the launch-window convention. + **Who might notice.** Code that assigns a tombstoned key's value to a slot typed exactly `undefined` (for example `const x: undefined = page.assignedProfiles`) no longer compiles. The key never holds a value, so delete the read. The published declaration files are about 7.5% larger, because the declaration emitter writes the type out at every site of the 287 `retiredKey()` calls.