Skip to content

Commit eae94b3

Browse files
dmealingclaude
andcommitted
feat(metadata): retired vocabulary explains itself instead of reading as a broken install (#337)
An adopter's 75-node ledger stopped loading on 0.24.0-rc.5 and the error said `@verifiedBy` was "not declared by any registered provider". True, and the wrong story: it says YOUR METADATA IS MALFORMED when the fact is that the vocabulary was retired deliberately, in a named release, with a written migration. Reading that, they filed a registration bug against an adjudicated breaking change and argued against reasoning they had never seen — because nothing in the error pointed at it. `verified-by-retirement.md` answers their strongest objection (deleting an `abandoned` node destroys history) precisely. They never found it. Adds a retired-vocabulary map and consults it at the three doors retired vocabulary can fail at: unknown ATTR, out-of-range attr VALUE, unknown SUBTYPE. Unknown attribute '@Verifiedby' on requirement.functional 'orderRecord' — retired in 0.24.0 — it asked you to name a test, and verify could only check that the NAME occurred somewhere in your test sources — never that the named test verified the claim. Migration: docs/features/migrations/verified-by-retirement.md. NO LOAD OUTCOME CHANGES. Retired vocabulary still fails, same error code, same site; only the message gains a sentence. Pinned by a test asserting the load still errors with ERR_UNKNOWN_ATTR — a "helpful" fix that also started accepting the attribute would silently undo the retirement, which is the failure mode a sympathetic reading of #337 walks straight into. SCOPED BY TYPE, NEVER BY BARE NAME. `@unique` is retired on `identity.secondary` and is live vocabulary on a field; `@readOnly` was retired from FIELDS and never existed on a requirement. A name-only match would tell an author their typo was a retirement and send them to a guide that says nothing about it — worse than the generic message it replaces. Both directions are pinned: `@readOnly` on a field names `@mutability`, on a requirement it reports generically. A TYPO MUST STAY A TYPO. The map is consulted only after the normal check has already decided to fail and returns undefined for anything it does not recognise, so `@verifiedByy` and `@status: abandonned` still report against the registry with no migration pointer. Pinned, because a map that made every failure sound like a retirement would be a downgrade. Covers six retirements that would each have produced this same issue: @Verifiedby, @supersededBy, @status abandoned/superseded (FR-038), @readonly (FR-037 R1), origin.collection (FR-037 R2), @unique on identity.secondary (ADR-0040), and the dropped @dbColumnType array kinds. Every entry naming a guide is checked against the FILESYSTEM, not the path shape — a pointer to a document nobody wrote sends the reader looking and then strands them, which is the exact failure this exists to prevent. Verified by pointing an entry at a missing file and watching the gate go red. Wired at BOTH subtype doors — root and child — though no retired subtype is root-legal today, so that arm is currently unreachable. Fixing one door and leaving the other is how a rule ends up half-true while passing every probe. CROSS-PORT: this is a DIAGNOSTIC, not registered vocabulary. It touches no registry manifest and no load outcome, so it carries no registry-conformance obligation and the other four ports still fail identically — just with the generic message. Mirroring the map is a per-port ergonomics follow-up, stated here rather than left for someone to discover the asymmetry. Verified: metadata 2449 pass / 0 fail, cli 609 / 0 fail, workspace typecheck exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9dc3a93 commit eae94b3

5 files changed

Lines changed: 496 additions & 4 deletions

File tree

‎server/typescript/packages/metadata/src/attr-schema-validate.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ import type { AttrSchema, TypeRegistry } from "./registry.js";
4747
import { childRuleMatches } from "./registry.js";
4848
import { TYPE_FIELD, TYPE_METADATA } from "./shared/base-types.js";
4949
import { ATTR_SUBTYPE_PROPERTIES } from "./core/attr/attr-constants.js";
50+
// #337: turns "unknown X" into "retired in V — here is the migration". Changes no load
51+
// outcome; retired vocabulary still fails with the same code.
52+
import { retiredAttr, retiredAttrValue, retirementHint } from "./retired-vocabulary.js";
5053
import {
5154
FIELD_SUBTYPE_ENUM,
5255
FIELD_ATTR_VALUES,
@@ -164,10 +167,17 @@ function validateNode(
164167
// @-attr still fails — only the `properties` subtype is exempt.)
165168
if (inst.subType === ATTR_SUBTYPE_PROPERTIES) continue;
166169
if (!byName.has(inst.name)) {
170+
// #337: if we RETIRED this name, say so. "Not declared by any registered
171+
// provider" is true and reads as "your metadata is malformed", which sent one
172+
// adopter to file a registration bug against a deliberate, documented breaking
173+
// change. Unrecognised names still report generically — a typo is a typo.
174+
const retired = retiredAttr(typeKey, inst.name);
167175
errors.push(
168176
new ParseError(
169177
`Unknown attribute '@${inst.name}' on ${nodeLabel(node)} — ` +
170-
`not declared by any registered provider for ${typeKey}`,
178+
(retired !== undefined
179+
? retirementHint(retired)
180+
: `not declared by any registered provider for ${typeKey}`),
171181
{ code: "ERR_UNKNOWN_ATTR", source: node.source },
172182
),
173183
);
@@ -290,11 +300,16 @@ function validateNode(
290300
? []
291301
: [value];
292302
for (const bad of offenders) {
303+
// #337: a member we REMOVED reads very differently from a member that never
304+
// existed. `@status: abandoned` was valid one release ago, so "not one of the
305+
// allowed values" invites the reader to conclude the enum is broken.
306+
const retiredValue = retiredAttrValue(typeKey, inst.name, bad);
293307
errors.push(
294308
new ParseError(
295309
`${nodeLabel(node)} attribute '@${inst.name}' has value ` +
296310
`'${String(bad)}' which is not one of the allowed values: ` +
297-
`${allowed.map((v) => String(v)).join(", ")}`,
311+
`${allowed.map((v) => String(v)).join(", ")}` +
312+
(retiredValue !== undefined ? ` — ${retirementHint(retiredValue)}` : ""),
298313
{ code: "ERR_BAD_ATTR_VALUE", source: node.source },
299314
),
300315
);

‎server/typescript/packages/metadata/src/parser-core.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { MetaRoot } from "./shared/meta-root.js";
2929
import { MetaAttr } from "./core/attr/meta-attr.js";
3030
import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
3131
import { ParseError, type ErrorCode } from "./errors.js";
32+
// #337 — see retired-vocabulary.ts. Diagnostic only; no load outcome changes.
33+
import { retiredSubType, retirementHint } from "./retired-vocabulary.js";
3234
import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
3335
import { semanticDiff } from "./semantic-diff.js";
3436
import {
@@ -427,8 +429,13 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
427429
_currentPath!.pushKey(rootKey);
428430
const src = errSource();
429431
_currentPath!.pop();
432+
// Same hint at the ROOT door. No retired subtype is root-legal today, so this arm
433+
// is currently unreachable — wired anyway, because "fixed one door, left the
434+
// other" is how a rule ends up half-true and passing every probe.
435+
const retiredRoot = retiredSubType(rootType, rootSubType);
430436
throw new ParseError(
431-
`Unknown root type "${rootType}.${rootSubType}" — not registered`,
437+
`Unknown root type "${rootType}.${rootSubType}" — ` +
438+
(retiredRoot !== undefined ? retirementHint(retiredRoot) : "not registered"),
432439
{ code: rootTypeCode, source: src },
433440
);
434441
}
@@ -1264,9 +1271,13 @@ function processChildren(
12641271
const childTypeCode = explicit && registry.allSubTypesOf(childType).length > 0
12651272
? "ERR_UNKNOWN_SUBTYPE" as const
12661273
: "ERR_UNKNOWN_TYPE" as const;
1274+
// #337: a subtype we RETIRED (origin.collection) gets its retirement and its
1275+
// migration, not a bare "not registered" that reads like a broken install.
1276+
const retiredChild = retiredSubType(childType, childSubType);
12671277
errors.push(
12681278
new ParseError(
1269-
`Unknown type "${childType}.${childSubType}" — not registered`,
1279+
`Unknown type "${childType}.${childSubType}" — ` +
1280+
(retiredChild !== undefined ? retirementHint(retiredChild) : "not registered"),
12701281
{ code: childTypeCode, source: errSource() },
12711282
),
12721283
);
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// server/typescript/packages/metadata/src/retired-vocabulary.ts
2+
//
3+
// Vocabulary this project USED to register, and what to do instead.
4+
//
5+
// WHY THIS EXISTS (#337). Under ADR-0023 the registry is strict and sealed, so retired
6+
// vocabulary fails the load — correctly. But the message it failed with said the attribute
7+
// was "not declared by any registered provider", which tells an author their metadata is
8+
// malformed when the truth is that the vocabulary was retired ON PURPOSE, in a named
9+
// release, with a written migration. An adopter read that error, concluded the tool had a
10+
// registration bug, filed it as one, and argued against a decision whose reasoning they had
11+
// never seen — because nothing in the error pointed at it. The migration guide answered
12+
// their objection precisely. They never found it.
13+
//
14+
// THIS CHANGES NO LOAD OUTCOME. Retired vocabulary still fails, with the same error code,
15+
// at the same site. Only the message gains a sentence. That is deliberate: making any of
16+
// these load again would undo an adjudicated breaking change, and a "helpful" shim is how a
17+
// retirement quietly stops being one.
18+
//
19+
// SCOPE IS BY TYPE, NEVER BY BARE NAME. `@unique` is retired on `identity.secondary` and
20+
// perfectly live on a field; a name-only match would brand a valid declaration as retired,
21+
// which is worse than the generic message it replaces. Every entry names the type it
22+
// applies to, and `*` means "every subtype of this type" — never "every type".
23+
//
24+
// A GENUINE TYPO MUST STAY GENERIC. This map is consulted only after the normal check has
25+
// already decided to fail, and it returns undefined for anything it does not recognise, so
26+
// `@maxLenght` still reports as an unknown attribute. The map speaks only where it KNOWS.
27+
//
28+
// CROSS-PORT: this is a DIAGNOSTIC, not registered vocabulary — it affects no registry
29+
// manifest and no load outcome, so it carries no registry-conformance obligation. The other
30+
// four ports fail identically today, just with the generic message; mirroring this map is a
31+
// per-port ergonomics follow-up, not a conformance gap.
32+
33+
/** What a reader needs at the moment their load fails. */
34+
export interface RetirementNote {
35+
/** The release that retired it. */
36+
readonly since: string;
37+
/** One line: what it was, and why it went. */
38+
readonly why: string;
39+
/** The vocabulary that replaced it, when something did. */
40+
readonly replacedBy?: string;
41+
/** Repo-relative migration guide. */
42+
readonly migration?: string;
43+
}
44+
45+
/** One retirement. `subTypes: "*"` means every subtype of `type`. */
46+
export interface RetiredEntry extends RetirementNote {
47+
readonly type: string;
48+
/** `*` for every subtype of `type`, else the exact subtype. */
49+
readonly subType: string;
50+
/** Retired ATTRIBUTE name (no `@`), when the attribute itself went. */
51+
readonly attr?: string;
52+
/** Retired VALUES of a surviving attribute, when only some members went. */
53+
readonly attrValues?: readonly string[];
54+
/** Set when the SUBTYPE itself was retired (`attr`/`attrValues` absent). */
55+
readonly isSubTypeRetirement?: boolean;
56+
}
57+
58+
const REQUIREMENT_MIGRATION = "docs/features/migrations/verified-by-retirement.md";
59+
60+
export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
61+
// ── FR-038: the requirement vocabulary becomes prescriptive-only (0.24.0) ──
62+
{
63+
type: "requirement", subType: "*", attr: "verifiedBy",
64+
since: "0.24.0",
65+
why: "it asked you to name a test, and verify could only check that the NAME occurred " +
66+
"somewhere in your test sources — never that the named test verified the claim",
67+
migration: REQUIREMENT_MIGRATION,
68+
},
69+
{
70+
type: "requirement", subType: "*", attr: "supersededBy",
71+
since: "0.24.0",
72+
why: "a requirement is prescriptive — it states what should be true and is never a " +
73+
"journal of what happened",
74+
migration: REQUIREMENT_MIGRATION,
75+
},
76+
{
77+
type: "requirement", subType: "*", attr: "status",
78+
attrValues: ["abandoned", "superseded"],
79+
since: "0.24.0",
80+
why: "retiring a capability is DELETING its requirement; version control holds that it " +
81+
"existed, and `notes` on a surviving entry holds what a reader still needs",
82+
migration: REQUIREMENT_MIGRATION,
83+
},
84+
85+
// ── FR-037 R1: @readOnly becomes the @mutability enum (0.24.0) ──
86+
{
87+
type: "field", subType: "*", attr: "readOnly",
88+
since: "0.24.0",
89+
why: "a boolean could not express write-once, so the axis became an enum",
90+
replacedBy: "@mutability",
91+
migration: "docs/features/migrations/readonly-to-mutability.md",
92+
},
93+
94+
// ── FR-037 R2: origin.collection retires to reserved-not-registered (0.24.0) ──
95+
{
96+
type: "origin", subType: "collection", isSubTypeRetirement: true,
97+
since: "0.24.0",
98+
why: "it duplicated `origin.aggregate @agg: collect` on a strictly smaller attribute " +
99+
"set, and nothing dispatched on it",
100+
replacedBy: "origin.aggregate @agg: collect",
101+
migration: "docs/features/migrations/origin-collection-retirement.md",
102+
},
103+
104+
// ── ADR-0040: uniqueness lives in the TYPE, not an attribute (0.15.1) ──
105+
{
106+
type: "identity", subType: "secondary", attr: "unique",
107+
since: "0.15.1",
108+
why: "`identity.secondary` is now always a unique alternate key — uniqueness is encoded " +
109+
"in the type, so the attribute had nothing left to say",
110+
replacedBy: "index.lookup (for a NON-unique retrieval index)",
111+
migration: "docs/features/migrations/identity-secondary-to-index-lookup.md",
112+
},
113+
114+
// ── Metamodel 1.0: @dbColumnType slim-and-derive (0.15.0) ──
115+
{
116+
type: "field", subType: "*", attr: "dbColumnType",
117+
attrValues: ["uuid_array", "text_array"],
118+
since: "0.15.0",
119+
why: "array-ness is DERIVED from `isArray`, so an array-flavoured physical type restated " +
120+
"something the model already knew",
121+
replacedBy: "isArray: true on the field",
122+
},
123+
];
124+
125+
/** True when `entry` governs `typeKey` (`"<type>.<subType>"`). */
126+
function scopeMatches(entry: RetiredEntry, typeKey: string): boolean {
127+
const dot = typeKey.indexOf(".");
128+
if (dot < 0) return false;
129+
const type = typeKey.slice(0, dot);
130+
const subType = typeKey.slice(dot + 1);
131+
if (entry.type !== type) return false;
132+
return entry.subType === "*" || entry.subType === subType;
133+
}
134+
135+
function note(entry: RetiredEntry): RetirementNote {
136+
const out: RetirementNote = { since: entry.since, why: entry.why };
137+
return {
138+
...out,
139+
...(entry.replacedBy !== undefined ? { replacedBy: entry.replacedBy } : {}),
140+
...(entry.migration !== undefined ? { migration: entry.migration } : {}),
141+
};
142+
}
143+
144+
/**
145+
* The attribute NAME itself was retired for this type. Returns undefined for an attr whose
146+
* name is unknown here — a typo must keep reporting as a typo.
147+
*
148+
* An entry carrying `attrValues` is a VALUE retirement on a surviving attribute, so it is
149+
* deliberately not matched here: `@status` is still perfectly good vocabulary.
150+
*/
151+
export function retiredAttr(typeKey: string, attrName: string): RetirementNote | undefined {
152+
const hit = RETIRED_VOCABULARY.find(
153+
(e) => e.attr === attrName && e.attrValues === undefined && scopeMatches(e, typeKey),
154+
);
155+
return hit === undefined ? undefined : note(hit);
156+
}
157+
158+
/** A specific VALUE of a surviving attribute was retired. */
159+
export function retiredAttrValue(
160+
typeKey: string,
161+
attrName: string,
162+
value: unknown,
163+
): RetirementNote | undefined {
164+
if (typeof value !== "string") return undefined;
165+
const hit = RETIRED_VOCABULARY.find(
166+
(e) => e.attr === attrName && e.attrValues?.includes(value) === true && scopeMatches(e, typeKey),
167+
);
168+
return hit === undefined ? undefined : note(hit);
169+
}
170+
171+
/** The SUBTYPE itself was retired (`origin.collection`). */
172+
export function retiredSubType(type: string, subType: string): RetirementNote | undefined {
173+
const hit = RETIRED_VOCABULARY.find(
174+
(e) => e.isSubTypeRetirement === true && e.type === type && e.subType === subType,
175+
);
176+
return hit === undefined ? undefined : note(hit);
177+
}
178+
179+
/**
180+
* The sentence appended to a failing diagnostic. Kept here rather than at each call site so
181+
* all three failure sites word a retirement identically — an adopter who has seen one
182+
* recognises the next.
183+
*/
184+
export function retirementHint(n: RetirementNote): string {
185+
const parts = [`retired in ${n.since} — ${n.why}`];
186+
if (n.replacedBy !== undefined) parts.push(`Use ${n.replacedBy} instead.`);
187+
if (n.migration !== undefined) parts.push(`Migration: ${n.migration}.`);
188+
return parts.join(". ").replace(/\.\./g, ".");
189+
}

0 commit comments

Comments
 (0)