Skip to content

Commit 4a6ef3e

Browse files
dmealingclaude
andcommitted
feat(metadata): a raw-document vocabulary rewriter, driven by the retirement map
Plan: docs/superpowers/plans/2026-08-21-meta-upgrade-vocabulary-rewriter.md. The engine behind `meta upgrade` (CLI wiring lands next). Adds `rewrite` to the retirement map and `rewriteDocument()`, which fixes what can be fixed mechanically and REFUSES the rest. ONE MAP, NOT TWO. The loader already reads RETIRED_VOCABULARY to explain a failure; the rewriter reads the same entries to fix it. So the message and the fix cannot drift — the error says "use @mutability" and the tool writes @mutability for the same reason. A parameterised `--from X --to Y` would have let those diverge and made the adopter supply the mapping they came here to be told. IT CANNOT USE THE LOADER, and that shapes the whole design. Once an attribute is deregistered, metadata carrying it FAILS THE LOAD — that is what a retirement is. So load → transform → canonical-serialize is impossible: the input does not load, and the canonical serializer needs a loaded model. This works on RAW TEXT, which is also the only reason an upgrade path exists: the adopter installs the new CLI and runs this against metadata the new CLI refuses. A fixer needing a successful load is a chicken-and-egg with no exit. SURGICAL, NOT PARSE-AND-REPRINT. Adopters author JSONC with comments and meaningful key order. A JSON.parse/stringify round-trip destroys both while reporting success, so every edit is a span replacement against the original text, collected and applied right-to-left in one pass — rewriting incrementally would invalidate every later offset. A document with nothing to change comes back BYTE-IDENTICAL, and that is pinned. REFUSES WHAT IT CANNOT KNOW. `@status: abandoned` has no `rewrite`, so it is reported with its guide and left alone. Deleting the node, retyping it, and fixing the residue it describes are all defensible and only a human knows which; a wrong guess emits metadata that LOADS and means something different, which is strictly worse than refusing because the adopter would believe the migration finished. Refusals are reported ALONGSIDE the mechanical changes, so a partial upgrade is visible rather than silent. VALUE RETIREMENTS FIRE ONLY ON THE RETIRED VALUES. `@dbColumnType: uuid_array` goes; `@dbColumnType: jsonb` on the same attribute is live vocabulary and is untouched — removing it would silently change a column type. Type scoping likewise: `@unique` is retired on identity.secondary and live on a field, so the rewriter takes an explicit type scope rather than matching bare names. A TEST FOUND A REAL DEAD END while I was writing it: the `@dbColumnType` array kinds had neither a rewrite nor a migration guide, so an adopter hitting them was told the vocabulary was retired and given nowhere to go. Now mechanically dropped, since the attribute only ever restated array-ness that `isArray` already carries. Verified on real output rather than substring assertions: two retired attrs dropped with no dangling comma and no blank line, comment and key order intact, the judgment case refused with its guide, line numbers correct. metadata 2463 pass / 0 fail, workspace typecheck 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ef0e7f9 commit 4a6ef3e

6 files changed

Lines changed: 560 additions & 1 deletion

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# `meta upgrade` — a vocabulary rewriter driven by the retirement map
2+
3+
Retiring vocabulary currently costs every adopter a manual sweep. This adds `meta upgrade`,
4+
which rewrites what can be rewritten and **refuses** what needs judgment, driven by the same
5+
map the loader already uses to explain a retirement.
6+
7+
## The constraint that shapes everything
8+
9+
**The rewriter cannot use the loader.** Once an attribute is deregistered, metadata carrying
10+
it fails the load — that is the entire point of the retirement. So load → transform →
11+
canonical-serialize is impossible: the input does not load, and the canonical serializer
12+
needs a loaded model.
13+
14+
It operates on the **raw document**. That is also what makes the upgrade path exist: an
15+
adopter installs the new CLI, runs the fixer against metadata the new CLI *refuses*, and only
16+
then does anything else work. A fixer needing a successful load would be a chicken-and-egg
17+
with no exit.
18+
19+
Second-order: **surgical text edits, never parse-and-reprint.** Adopters author JSONC with
20+
comments and meaningful key order; a round-trip through `JSON.parse`/`stringify` destroys
21+
both. Same distinction the repo already draws between ts-poet (greenfield emit) and ts-morph
22+
(in-place edit).
23+
24+
## One map, not two
25+
26+
`RETIRED_VOCABULARY` (shipped, `metadata/src/retired-vocabulary.ts`) already declares
27+
`since` / `why` / `replacedBy` / `migration`, and the loader reads it to explain a failure.
28+
The rewriter reads the **same** entries, so the message and the fix cannot drift: the error
29+
says "use `@counterexample`" and the fixer writes `@counterexample` for the same reason.
30+
31+
## Mechanical vs judgment
32+
33+
Not every retirement is automatable, and the tool must not pretend:
34+
35+
| retirement | transform | automatable |
36+
|---|---|---|
37+
| `@violation``@counterexample` | key rename | yes |
38+
| `@readOnly: true``@mutability: "readOnly"` | key + value | yes |
39+
| `@verifiedBy` | drop the attr | yes |
40+
| `@status: abandoned` | delete node / retype / fix residue | **no — judgment** |
41+
42+
`RetiredEntry` gains an optional `rewrite`. Present ⇒ the fixer applies it. **Absent ⇒ the
43+
fixer refuses that node and prints the migration guide**, the same detect-and-refuse posture
44+
used for the D1 primary-key move rather than emitting something un-appliable.
45+
46+
## Tasks
47+
48+
### 1. `rewrite` on `RetiredEntry`
49+
`{ kind: "renameAttr", to }` · `{ kind: "dropAttr" }` · `{ kind: "renameAttrValue", toAttr, toValue }`.
50+
Absent ⇒ judgment. Tests: every entry either carries a `rewrite` or names a `migration`;
51+
no entry claims a rewrite it cannot perform.
52+
53+
### 2. The rewriter
54+
`rewriteDocument(text, opts) → { text, changes[], refusals[] }`, pure, no filesystem.
55+
- surgical: unchanged regions byte-identical, comments and key order preserved
56+
- JSON and YAML (YAML authoring is sigil-free — a rename there is the bare key)
57+
- reports every change with line numbers, and every refusal with its guide
58+
59+
### 3. `meta upgrade`
60+
Dry-run + diff by DEFAULT (repo convention); `--apply` writes. `--to <version>` bounds which
61+
retirements apply. Non-zero exit when refusals remain, so CI cannot call a partial upgrade
62+
done.
63+
64+
### 4. Dogfood
65+
Run it against this repo's own metadata and fixtures before shipping. Any refuse-case we hit
66+
is one an adopter would have hit.
67+
68+
### 5. The rename — `@violation``@counterexample`
69+
Five ports, spec files, embedded definitions, `expected-registry.json`, fixtures, docs,
70+
skills, agent-context trees. **Breaking**, so it needs a MINOR and a `metamodelVersion` move.
71+
`registry-conformance` fails any port that misses it — mechanical work with a hard backstop.
72+
73+
**Sequencing note:** `0.24.0-rc.5` is soaking. Tasks 1–4 are additive and land safely; task 5
74+
is breaking and belongs to the next MINOR, not to 0.24.0.
75+
76+
## Verification
77+
78+
- the rewriter is proven on a document with comments, odd key order, and both formats
79+
- a judgment case is **refused**, not silently skipped, and the exit code reflects it
80+
- our own corpus round-trips: rewrite → load → canonical-serialize is byte-identical to
81+
loading the hand-migrated original
82+
- the gate is proven able to fail

server/typescript/packages/metadata/src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,17 @@ export {
287287
stripPackage,
288288
} from "./naming.js";
289289
export type { EntityNameMap, ColumnNamingStrategy } from "./naming.js";
290+
291+
// Retired vocabulary: the map the loader reads to explain a retirement, and the raw-document
292+
// rewriter `meta upgrade` drives from the SAME entries — so the error message and the fix
293+
// cannot drift apart.
294+
export {
295+
RETIRED_VOCABULARY,
296+
retiredAttr,
297+
retiredAttrValue,
298+
retiredSubType,
299+
retirementHint,
300+
} from "./retired-vocabulary.js";
301+
export type { RetirementNote, RetiredEntry, VocabularyRewrite } from "./retired-vocabulary.js";
302+
export { rewriteDocument } from "./vocabulary-rewrite.js";
303+
export type { RewriteResult, RewriteChange, RewriteRefusal, RewriteOpts } from "./vocabulary-rewrite.js";

server/typescript/packages/metadata/src/retired-vocabulary.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,23 @@ export interface RetirementNote {
4242
readonly migration?: string;
4343
}
4444

45-
/** One retirement. `subTypes: "*"` means every subtype of `type`. */
45+
/**
46+
* The mechanical fix, when one exists.
47+
*
48+
* ABSENT MEANS JUDGMENT — `@status: abandoned` can be resolved by deleting the node, by
49+
* retyping it, or by fixing the residue it describes, and only a human knows which. A tool
50+
* that guessed would emit metadata that LOADS and means something different, which is worse
51+
* than refusing: the adopter would believe the migration finished.
52+
*/
53+
export type VocabularyRewrite =
54+
/** The attribute name changed; the value is untouched. */
55+
| { readonly kind: "renameAttr"; readonly to: string }
56+
/** The attribute went away with no replacement — drop it. */
57+
| { readonly kind: "dropAttr" }
58+
/** Both the name and the value changed (`@readOnly: true` → `@mutability: "readOnly"`). */
59+
| { readonly kind: "renameAttrValue"; readonly toAttr: string; readonly fromValue: unknown; readonly toValue: unknown };
60+
61+
/** One retirement. `subType: "*"` means every subtype of `type`. */
4662
export interface RetiredEntry extends RetirementNote {
4763
readonly type: string;
4864
/** `*` for every subtype of `type`, else the exact subtype. */
@@ -53,6 +69,9 @@ export interface RetiredEntry extends RetirementNote {
5369
readonly attrValues?: readonly string[];
5470
/** Set when the SUBTYPE itself was retired (`attr`/`attrValues` absent). */
5571
readonly isSubTypeRetirement?: boolean;
72+
/** How `meta upgrade` fixes it. Absent ⇒ the human decides; the tool refuses and
73+
* prints `migration`. */
74+
readonly rewrite?: VocabularyRewrite;
5675
}
5776

5877
const REQUIREMENT_MIGRATION = "docs/features/migrations/verified-by-retirement.md";
@@ -65,13 +84,17 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
6584
why: "it asked you to name a test, and verify could only check that the NAME occurred " +
6685
"somewhere in your test sources — never that the named test verified the claim",
6786
migration: REQUIREMENT_MIGRATION,
87+
// Nothing replaced it, so the fix is removal. Safe to automate: the attribute drove no
88+
// behaviour anyone else can observe.
89+
rewrite: { kind: "dropAttr" },
6890
},
6991
{
7092
type: "requirement", subType: "*", attr: "supersededBy",
7193
since: "0.24.0",
7294
why: "a requirement is prescriptive — it states what should be true and is never a " +
7395
"journal of what happened",
7496
migration: REQUIREMENT_MIGRATION,
97+
rewrite: { kind: "dropAttr" },
7598
},
7699
{
77100
type: "requirement", subType: "*", attr: "status",
@@ -89,6 +112,11 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
89112
why: "a boolean could not express write-once, so the axis became an enum",
90113
replacedBy: "@mutability",
91114
migration: "docs/features/migrations/readonly-to-mutability.md",
115+
// Key AND value: `@readOnly: true` becomes `@mutability: "readOnly"`. Only the `true`
116+
// arm is mechanical — `@readOnly: false` was the default and simply goes away, which
117+
// the rewriter treats as a drop rather than inventing a mutability the author never
118+
// stated.
119+
rewrite: { kind: "renameAttrValue", toAttr: "mutability", fromValue: true, toValue: "readOnly" },
92120
},
93121

94122
// ── FR-037 R2: origin.collection retires to reserved-not-registered (0.24.0) ──
@@ -119,6 +147,16 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
119147
why: "array-ness is DERIVED from `isArray`, so an array-flavoured physical type restated " +
120148
"something the model already knew",
121149
replacedBy: "isArray: true on the field",
150+
// On a VALUE-scoped entry, `dropAttr` means "drop it when the value is one of the
151+
// retired ones" — never unconditionally. `@dbColumnType: jsonb` is live vocabulary on
152+
// the same attribute, and removing it would silently change the column type.
153+
//
154+
// Safe because the attribute said nothing the model did not already know: the field
155+
// carrying `uuid_array` necessarily has `isArray: true`, which is where array-ness now
156+
// comes from. There is no guide for this one, so without the rewrite an adopter would
157+
// be told it is retired and given nowhere to go — which is what the dead-end test that
158+
// caught this exists to prevent.
159+
rewrite: { kind: "dropAttr" },
122160
},
123161
];
124162

0 commit comments

Comments
 (0)