@metaobjectsdev/metadata 0.24.0-rc.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dist/attr-schema-validate.d.ts.map +1 -1
  2. package/dist/attr-schema-validate.js +17 -2
  3. package/dist/attr-schema-validate.js.map +1 -1
  4. package/dist/core/field/field-constants.d.ts +18 -5
  5. package/dist/core/field/field-constants.d.ts.map +1 -1
  6. package/dist/core/field/field-constants.js +21 -5
  7. package/dist/core/field/field-constants.js.map +1 -1
  8. package/dist/core/field/field-definition.embedded.d.ts.map +1 -1
  9. package/dist/core/field/field-definition.embedded.js +8 -3
  10. package/dist/core/field/field-definition.embedded.js.map +1 -1
  11. package/dist/core/field/validate-field-mutability.d.ts +22 -0
  12. package/dist/core/field/validate-field-mutability.d.ts.map +1 -0
  13. package/dist/core/field/validate-field-mutability.js +208 -0
  14. package/dist/core/field/validate-field-mutability.js.map +1 -0
  15. package/dist/core/identity/identity-constants.d.ts +12 -0
  16. package/dist/core/identity/identity-constants.d.ts.map +1 -1
  17. package/dist/core/identity/identity-constants.js +15 -0
  18. package/dist/core/identity/identity-constants.js.map +1 -1
  19. package/dist/core/identity/identity-definition.embedded.js +2 -2
  20. package/dist/core/identity/identity-definition.embedded.js.map +1 -1
  21. package/dist/core/index/index-definition.embedded.js +2 -2
  22. package/dist/core/index/index-definition.embedded.js.map +1 -1
  23. package/dist/core/object/object-definition.embedded.js +2 -2
  24. package/dist/core/object/object-definition.embedded.js.map +1 -1
  25. package/dist/core/requirement/meta-requirement.d.ts +1 -2
  26. package/dist/core/requirement/meta-requirement.d.ts.map +1 -1
  27. package/dist/core/requirement/meta-requirement.js +2 -6
  28. package/dist/core/requirement/meta-requirement.js.map +1 -1
  29. package/dist/core/requirement/requirement-constants.d.ts +3 -8
  30. package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
  31. package/dist/core/requirement/requirement-constants.js +2 -9
  32. package/dist/core/requirement/requirement-constants.js.map +1 -1
  33. package/dist/core/requirement/requirement-definition.embedded.d.ts.map +1 -1
  34. package/dist/core/requirement/requirement-definition.embedded.js +8 -46
  35. package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
  36. package/dist/core/requirement/resolve-claim.d.ts +37 -0
  37. package/dist/core/requirement/resolve-claim.d.ts.map +1 -0
  38. package/dist/core/requirement/resolve-claim.js +83 -0
  39. package/dist/core/requirement/resolve-claim.js.map +1 -0
  40. package/dist/core/vocabulary-rewrite-yaml.d.ts +21 -0
  41. package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -0
  42. package/dist/core/vocabulary-rewrite-yaml.js +245 -0
  43. package/dist/core/vocabulary-rewrite-yaml.js.map +1 -0
  44. package/dist/core-types.d.ts.map +1 -1
  45. package/dist/core-types.js +10 -10
  46. package/dist/core-types.js.map +1 -1
  47. package/dist/errors.d.ts +2 -2
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +37 -4
  50. package/dist/errors.js.map +1 -1
  51. package/dist/index.d.ts +7 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +13 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/library/library-sources.d.ts +11 -0
  56. package/dist/library/library-sources.d.ts.map +1 -1
  57. package/dist/library/library-sources.js +13 -0
  58. package/dist/library/library-sources.js.map +1 -1
  59. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  60. package/dist/loader/meta-data-loader.js +14 -10
  61. package/dist/loader/meta-data-loader.js.map +1 -1
  62. package/dist/loader/validation-passes.d.ts +1 -0
  63. package/dist/loader/validation-passes.d.ts.map +1 -1
  64. package/dist/loader/validation-passes.js +263 -45
  65. package/dist/loader/validation-passes.js.map +1 -1
  66. package/dist/naming-refs.d.ts.map +1 -1
  67. package/dist/naming-refs.js +2 -3
  68. package/dist/naming-refs.js.map +1 -1
  69. package/dist/parser-core.d.ts.map +1 -1
  70. package/dist/parser-core.js +22 -5
  71. package/dist/parser-core.js.map +1 -1
  72. package/dist/persistence/origin/meta-origin.d.ts +0 -10
  73. package/dist/persistence/origin/meta-origin.d.ts.map +1 -1
  74. package/dist/persistence/origin/meta-origin.js +1 -15
  75. package/dist/persistence/origin/meta-origin.js.map +1 -1
  76. package/dist/persistence/origin/origin-constants.d.ts +2 -4
  77. package/dist/persistence/origin/origin-constants.d.ts.map +1 -1
  78. package/dist/persistence/origin/origin-constants.js +9 -7
  79. package/dist/persistence/origin/origin-constants.js.map +1 -1
  80. package/dist/persistence/origin/origin-definition.embedded.d.ts.map +1 -1
  81. package/dist/persistence/origin/origin-definition.embedded.js +5 -21
  82. package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
  83. package/dist/registry-manifest.d.ts +9 -4
  84. package/dist/registry-manifest.d.ts.map +1 -1
  85. package/dist/registry-manifest.js +9 -4
  86. package/dist/registry-manifest.js.map +1 -1
  87. package/dist/retired-vocabulary.d.ts +92 -0
  88. package/dist/retired-vocabulary.d.ts.map +1 -0
  89. package/dist/retired-vocabulary.js +204 -0
  90. package/dist/retired-vocabulary.js.map +1 -0
  91. package/dist/super-resolve.d.ts +33 -0
  92. package/dist/super-resolve.d.ts.map +1 -1
  93. package/dist/super-resolve.js +56 -1
  94. package/dist/super-resolve.js.map +1 -1
  95. package/dist/template/prompt-definition.embedded.js +1 -1
  96. package/dist/template/prompt-definition.embedded.js.map +1 -1
  97. package/dist/template/template-constants.d.ts +6 -0
  98. package/dist/template/template-constants.d.ts.map +1 -1
  99. package/dist/template/template-constants.js +24 -1
  100. package/dist/template/template-constants.js.map +1 -1
  101. package/dist/template/template-definition.embedded.d.ts.map +1 -1
  102. package/dist/template/template-definition.embedded.js +34 -21
  103. package/dist/template/template-definition.embedded.js.map +1 -1
  104. package/dist/vocabulary-rewrite.d.ts +33 -0
  105. package/dist/vocabulary-rewrite.d.ts.map +1 -0
  106. package/dist/vocabulary-rewrite.js +314 -0
  107. package/dist/vocabulary-rewrite.js.map +1 -0
  108. package/package.json +11 -1
  109. package/src/attr-schema-validate.ts +17 -2
  110. package/src/core/field/field-constants.ts +24 -5
  111. package/src/core/field/field-definition.embedded.ts +8 -3
  112. package/src/core/field/validate-field-mutability.ts +259 -0
  113. package/src/core/identity/identity-constants.ts +16 -0
  114. package/src/core/identity/identity-definition.embedded.ts +2 -2
  115. package/src/core/index/index-definition.embedded.ts +2 -2
  116. package/src/core/object/object-definition.embedded.ts +2 -2
  117. package/src/core/requirement/meta-requirement.ts +1 -7
  118. package/src/core/requirement/requirement-constants.ts +2 -9
  119. package/src/core/requirement/requirement-definition.embedded.ts +8 -46
  120. package/src/core/requirement/resolve-claim.ts +98 -0
  121. package/src/core/vocabulary-rewrite-yaml.ts +267 -0
  122. package/src/core-types.ts +9 -10
  123. package/src/errors.ts +37 -4
  124. package/src/index.ts +29 -1
  125. package/src/library/library-sources.ts +14 -0
  126. package/src/loader/meta-data-loader.ts +15 -10
  127. package/src/loader/validation-passes.ts +323 -50
  128. package/src/naming-refs.ts +1 -3
  129. package/src/parser-core.ts +27 -5
  130. package/src/persistence/origin/meta-origin.ts +0 -16
  131. package/src/persistence/origin/origin-constants.ts +9 -8
  132. package/src/persistence/origin/origin-definition.embedded.ts +5 -21
  133. package/src/registry-manifest.ts +9 -4
  134. package/src/retired-vocabulary.ts +280 -0
  135. package/src/super-resolve.ts +59 -1
  136. package/src/template/prompt-definition.embedded.ts +1 -1
  137. package/src/template/template-constants.ts +27 -1
  138. package/src/template/template-definition.embedded.ts +34 -21
  139. package/src/vocabulary-rewrite.ts +350 -0
  140. package/dist/core/field/validate-field-readonly.d.ts +0 -9
  141. package/dist/core/field/validate-field-readonly.d.ts.map +0 -1
  142. package/dist/core/field/validate-field-readonly.js +0 -128
  143. package/dist/core/field/validate-field-readonly.js.map +0 -1
  144. package/src/core/field/validate-field-readonly.ts +0 -154
@@ -13,7 +13,7 @@
13
13
  import type { MetaData } from "../shared/meta-data.js";
14
14
  import type { MetaObject } from "../core/object/meta-object.js";
15
15
  import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js";
16
- import { ParseError } from "../errors.js";
16
+ import { ParseError, type ErrorCode } from "../errors.js";
17
17
  import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js";
18
18
  import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js";
19
19
  import { resolvedSource, type ErrorSource } from "../source.js";
@@ -32,6 +32,8 @@ import {
32
32
  INDEX_SUBTYPE_LOOKUP,
33
33
  INDEX_ATTR_FIELDS,
34
34
  } from "../core/index/index-constants.js";
35
+ import { IDENTITY_SUBTYPE_SECONDARY } from "../core/identity/identity-constants.js";
36
+ import type { MetaIdentity } from "../core/identity/meta-identity.js";
35
37
  import { MetaIndex } from "../core/index/meta-index.js";
36
38
  import {
37
39
  TEMPLATE_ATTR_PAYLOAD_REF,
@@ -59,6 +61,7 @@ import {
59
61
  } from "../presentation/layout/layout-constants.js";
60
62
  import {
61
63
  FIELD_ATTR_FILTERABLE,
64
+ FIELD_ATTR_SORTABLE,
62
65
  FIELD_ATTR_OBJECT_REF,
63
66
  FIELD_ATTR_STORAGE,
64
67
  STORAGE_FLATTENED,
@@ -81,7 +84,7 @@ import {
81
84
  FIELD_SUBTYPE_TIMESTAMP,
82
85
  FIELD_SUBTYPE_UUID,
83
86
  } from "../core/field/field-constants.js";
84
- import { FIELD_ATTR_DB_INDEXED } from "../persistence/db/db-constants.js";
87
+ import { FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js";
85
88
  import {
86
89
  IDENTITY_ATTR_FIELDS,
87
90
  IDENTITY_SUBTYPE_REFERENCE,
@@ -441,6 +444,26 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
441
444
  for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
442
445
  // ADR-0039: resolving — a concrete field may inherit @filterable via extends.
443
446
  if (field.attr(FIELD_ATTR_FILTERABLE) !== true) continue;
447
+
448
+ // #335 Half B — an ARRAY field has no operator band either. Every FR-009
449
+ // operator (eq/ne/gt/gte/lt/lte/in/like/isNull) is a scalar comparison;
450
+ // none applies to a collection column. The allowlist template does not
451
+ // consult isArray and falls through to the "string" band, so this
452
+ // previously emitted a `like` rule against a text[] column — SQL that
453
+ // cannot execute. Same reason as the subtype check below, so same code.
454
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
455
+ if (field.resolvedIsArray()) {
456
+ errors.push(
457
+ new ParseError(
458
+ `Field "${obj.name}.${field.name}" has @filterable: true but is an array ` +
459
+ `(isArray: true). No filter operator applies to a collection column. ` +
460
+ `Remove @filterable from this field.`,
461
+ { code: "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE", source: field.source },
462
+ ),
463
+ );
464
+ continue;
465
+ }
466
+
444
467
  if (opsForSubType(field.subType).length > 0) continue;
445
468
  errors.push(
446
469
  new ParseError(
@@ -455,6 +478,40 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
455
478
  return errors;
456
479
  }
457
480
 
481
+ // ---------------------------------------------------------------------------
482
+ // @sortable on a subtype or shape that cannot be ordered (#335 Half B)
483
+ // ---------------------------------------------------------------------------
484
+ // @sortable defaults FROM @filterable, so it is independently set only when
485
+ // explicit — and nothing validated it, while @filterable has had a hard error
486
+ // since SP-H Unit9. A @sortable JSON or array column emits a sort entry over a
487
+ // column no dialect can ORDER BY meaningfully. → ERR_SORTABLE_UNSUPPORTED_SUBTYPE.
488
+
489
+ export function validateSortableHasSupportedSubtype(root: MetaData): ParseError[] {
490
+ const errors: ParseError[] = [];
491
+ // ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
492
+ for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
493
+ // children() — inherited @sortable fields (via extends:/super:) are visible.
494
+ for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
495
+ // ADR-0039: resolving — a concrete field may inherit @sortable via extends.
496
+ if (field.attr(FIELD_ATTR_SORTABLE) !== true) continue;
497
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
498
+ const isArray = field.resolvedIsArray();
499
+ if (!isArray && opsForSubType(field.subType).length > 0) continue;
500
+ errors.push(
501
+ new ParseError(
502
+ `Field "${obj.name}.${field.name}" has @sortable: true but ` +
503
+ (isArray
504
+ ? `is an array (isArray: true) — a collection column has no ordering.`
505
+ : `its subtype "${field.subType}" cannot be ordered.`) +
506
+ ` Remove @sortable from this field.`,
507
+ { code: "ERR_SORTABLE_UNSUPPORTED_SUBTYPE", source: field.source },
508
+ ),
509
+ );
510
+ }
511
+ }
512
+ return errors;
513
+ }
514
+
458
515
  // ---------------------------------------------------------------------------
459
516
  // Origin path validation
460
517
  //
@@ -592,10 +649,25 @@ function _validateFromPath(
592
649
  return { entity: sourceObj, field: sourceField };
593
650
  }
594
651
 
652
+ /** A fully-walked `@via` path: the relationship hop nodes in path order, and
653
+ * the TERMINAL entity reached after the last hop. Both fall out of one walk,
654
+ * so they are returned together — recovering the terminal with a second walk
655
+ * means maintaining a second copy of the ADR-0042 package-resolution rule.
656
+ * Shape mirrors `_validateFromPath`'s `ResolvedFromTarget`, which returns a
657
+ * pair for the same reason. */
658
+ interface WalkedViaPath {
659
+ hops: MetaData[];
660
+ terminal: MetaData;
661
+ }
662
+
595
663
  /**
596
- * Validate an explicit `@via` "Entity.rel[.rel...]" path. Returns the walked
597
- * relationship hop nodes (in path order) on full success — FR-024 B5 runs the
598
- * cardinality checks over them — or undefined when any error was pushed.
664
+ * Validate an explicit `@via` "Entity.rel[.rel...]" path. On full success
665
+ * returns the walked relationship hop nodes (in path order) — FR-024 B5 runs
666
+ * the cardinality checks over them — together with the terminal entity node
667
+ * (#335: a whole-object `@agg:collect` has no `@of` entity, so `@orderBy` keys
668
+ * and value-object members resolve against the terminal instead). Returns
669
+ * undefined when any error was pushed, so `terminal` is defined exactly when
670
+ * `hops` is.
599
671
  */
600
672
  function _validateViaPath(
601
673
  viaAttr: string,
@@ -604,7 +676,7 @@ function _validateViaPath(
604
676
  fieldName: string,
605
677
  originSource: ErrorSource,
606
678
  errors: ParseError[],
607
- ): MetaData[] | undefined {
679
+ ): WalkedViaPath | undefined {
608
680
  const projectionName = projection.name;
609
681
  // FR5d — referrer is `<projection-FQN>::<fieldName>`.
610
682
  const referrer = `${projection.fqn()}::${fieldName}`;
@@ -706,7 +778,8 @@ function _validateViaPath(
706
778
  hops.push(rel);
707
779
  currentObj = nextObj;
708
780
  }
709
- return hops;
781
+ // currentObj is the terminal: every earlier exit returned undefined.
782
+ return { hops, terminal: currentObj };
710
783
  }
711
784
 
712
785
  // ---------------------------------------------------------------------------
@@ -956,6 +1029,58 @@ function _checkAggregateCardinality(
956
1029
  }
957
1030
  }
958
1031
 
1032
+ /**
1033
+ * #335 — a whole-object `@agg:collect` projects EXACTLY the declared value
1034
+ * object's members, each matched by NAME against the `@via` terminal entity.
1035
+ *
1036
+ * Two rules, both fail-closed:
1037
+ * - a member with no matching field on the terminal is unresolvable. Failing
1038
+ * OPEN here is how #270 turned a curated value object into the full entity,
1039
+ * invisible in a diff because the metadata still read as curated.
1040
+ * - a matched member must agree on BOTH type axes (#185 type-preserving
1041
+ * doctrine), so a scalar member cannot bind an array field or vice versa.
1042
+ *
1043
+ * Both refusals carry a whole-object-specific code — ERR_COLLECT_MEMBER_UNRESOLVED
1044
+ * for the unmatched member, ERR_COLLECT_WHOLE_OBJECT for the type disagreement.
1045
+ * The latter is deliberately NOT the scalar arm's ERR_INVALID_ORIGIN: a loader
1046
+ * that still requires @of rejects this metadata with ERR_INVALID_ORIGIN too, so
1047
+ * sharing the code would make a corpus fixture pass on a port that implements
1048
+ * nothing (the corpus compares only code + source, never message text).
1049
+ */
1050
+ function _checkCollectMembers(
1051
+ refTarget: MetaData,
1052
+ terminal: MetaData,
1053
+ obj: MetaData,
1054
+ field: MetaData,
1055
+ src: ErrorSource,
1056
+ errors: ParseError[],
1057
+ ): void {
1058
+ // ADR-0039: resolving — a value object may inherit members via extends, and
1059
+ // the terminal entity may inherit fields; own-only would silently skip
1060
+ // inherited members, which is exactly the #270 bug class this guards.
1061
+ const terminalFields = terminal.children().filter((c) => c.type === TYPE_FIELD);
1062
+ for (const member of refTarget.children().filter((c) => c.type === TYPE_FIELD)) {
1063
+ const match = terminalFields.find((f) => f.name === member.name);
1064
+ if (match === undefined) {
1065
+ errors.push(new ParseError(
1066
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1067
+ `'${member.name}' has no matching field on '${terminal.name}' — a whole-object ` +
1068
+ `rollup projects exactly the declared members.`,
1069
+ { code: "ERR_COLLECT_MEMBER_UNRESOLVED", source: src }));
1070
+ continue;
1071
+ }
1072
+ const memberLabel = _typeLabel(member);
1073
+ const matchLabel = _typeLabel(match);
1074
+ if (memberLabel !== matchLabel) {
1075
+ errors.push(new ParseError(
1076
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1077
+ `'${member.name}' is ${memberLabel} but '${terminal.name}.${match.name}' ` +
1078
+ `is ${matchLabel} — a whole-object rollup preserves each member's type.`,
1079
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1080
+ }
1081
+ }
1082
+ }
1083
+
959
1084
  /**
960
1085
  * FR-024 B6 (spec §4; ADR-0029 decision 7) — extends/origin agreement.
961
1086
  *
@@ -1021,6 +1146,17 @@ function _checkExtendsOriginAgreement(
1021
1146
  * FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_
1022
1147
  * TYPE_MISMATCH used to cover).
1023
1148
  */
1149
+ /**
1150
+ * Both type axes in one comparable token. Subtype names never contain "[]", so
1151
+ * equal labels ⇔ same subType AND same array-ness. Nullability is deliberately
1152
+ * NOT judged — an outer-join view legitimately widens NOT NULL.
1153
+ * ADR-0039: resolvedIsArray(), never the own `isArray` flag — a field may
1154
+ * inherit its array-ness via extends.
1155
+ */
1156
+ function _typeLabel(field: MetaData): string {
1157
+ return `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1158
+ }
1159
+
1024
1160
  function _checkPassthroughType(
1025
1161
  field: MetaData,
1026
1162
  fromField: MetaData,
@@ -1031,11 +1167,8 @@ function _checkPassthroughType(
1031
1167
  errors: ParseError[],
1032
1168
  ): void {
1033
1169
  if (convert) return; // deliberate type change acknowledged
1034
- // Compare both axes at once via the type-label: subtype names never contain
1035
- // "[]", so equal labels ⇔ same subType AND same array-ness (nullability is
1036
- // deliberately not judged — an outer-join view legitimately widens NOT NULL).
1037
- const declared = `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1038
- const source = `field.${fromField.subType}${fromField.resolvedIsArray() ? "[]" : ""}`;
1170
+ const declared = _typeLabel(field);
1171
+ const source = _typeLabel(fromField);
1039
1172
  if (declared === source) return;
1040
1173
  errors.push(
1041
1174
  new ParseError(
@@ -1057,6 +1190,10 @@ function _checkPassthroughType(
1057
1190
  * and carries no vocabulary. Shared by `@agg:collect` (element order) and
1058
1191
  * `origin.first` (row selection). A missing related entity means a prior error
1059
1192
  * already fired — skip silently.
1193
+ *
1194
+ * `code` lets the whole-object `@agg:collect` arm report ERR_COLLECT_WHOLE_OBJECT
1195
+ * instead; it defaults to ERR_INVALID_ORIGIN so the scalar `@of` and `origin.first`
1196
+ * call sites keep their existing envelope byte-for-byte.
1060
1197
  */
1061
1198
  function _validateOrderByKeys(
1062
1199
  orderBy: unknown,
@@ -1066,6 +1203,7 @@ function _validateOrderByKeys(
1066
1203
  label: string,
1067
1204
  originSource: ErrorSource,
1068
1205
  errors: ParseError[],
1206
+ code: ErrorCode = "ERR_INVALID_ORIGIN",
1069
1207
  ): void {
1070
1208
  if (!Array.isArray(orderBy) || relatedEntity === undefined) return;
1071
1209
  for (const raw of orderBy) {
@@ -1079,14 +1217,14 @@ function _validateOrderByKeys(
1079
1217
  errors.push(
1080
1218
  new ParseError(
1081
1219
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — no such field "${key}" on ${relatedEntity.name}.`,
1082
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1220
+ { code, source: originSource },
1083
1221
  ),
1084
1222
  );
1085
1223
  } else if (dir !== undefined && !(SORT_ORDER_VALUES as readonly string[]).includes(dir)) {
1086
1224
  errors.push(
1087
1225
  new ParseError(
1088
1226
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — direction must be one of ${SORT_ORDER_VALUES.join("|")}.`,
1089
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1227
+ { code, source: originSource },
1090
1228
  ),
1091
1229
  );
1092
1230
  }
@@ -1109,8 +1247,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1109
1247
  for (const field of obj.ownChildren().filter((c) => c.type === TYPE_FIELD)) {
1110
1248
  for (const origin of field.ownChildren().filter((c) => c.type === TYPE_ORIGIN)) {
1111
1249
  // #210 — assembly origins live on projections. A value-hosted field may
1112
- // not carry origin.aggregate / origin.computed / origin.collection /
1113
- // origin.first: a value is constructed — by a caller or by embedding —
1250
+ // not carry origin.aggregate / origin.computed / origin.first:
1251
+ // a value is constructed — by a caller or by embedding —
1114
1252
  // never assembled from a backing store. origin.passthrough STAYS legal
1115
1253
  // on a value (FR-015 parameter lineage; the B5 exemption below).
1116
1254
  if (
@@ -1156,9 +1294,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1156
1294
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1157
1295
  const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
1158
1296
  if (typeof via === "string" && via !== "") {
1159
- const hops = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1160
- if (hops !== undefined) {
1161
- _checkPassthroughCardinality(hops, obj, field.name, origin.source, errors);
1297
+ const walked = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1298
+ if (walked !== undefined) {
1299
+ _checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors);
1162
1300
  }
1163
1301
  } else if (fromTarget !== undefined && !isValueHost) {
1164
1302
  // FR-024 §6 — no @via: derive the base entity; a @from targeting
@@ -1243,17 +1381,89 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1243
1381
  `origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: requires an explicit @via (a quantifier has no @of to infer the path from).`,
1244
1382
  { code: "ERR_INVALID_ORIGIN", source: src }));
1245
1383
  } else {
1246
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1247
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1384
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1385
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1248
1386
  }
1249
1387
  continue;
1250
1388
  }
1251
1389
 
1252
- // --- count/sum/avg/min/max/collect: @of REQUIRED ---
1390
+ // --- @of: REQUIRED for count/sum/avg/min/max; OPTIONAL for collect ---
1391
+ // #335 — an @of-absent collect is a WHOLE-OBJECT rollup: collect the
1392
+ // related rows as an array of the field's declared @objectRef value
1393
+ // object rather than an array of one scalar column.
1253
1394
  if (!ofPresent) {
1254
- errors.push(new ParseError(
1255
- `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1256
- { code: "ERR_INVALID_ORIGIN", source: src }));
1395
+ if (!isCollect) {
1396
+ errors.push(new ParseError(
1397
+ `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1398
+ { code: "ERR_INVALID_ORIGIN", source: src }));
1399
+ continue;
1400
+ }
1401
+ // Whole-object rollup. The carrying field must be a field.object
1402
+ // naming a value object, and @via must be explicit (there is no @of
1403
+ // entity to infer the single-hop relation from).
1404
+ // ADR-0039: resolving — @objectRef may be inherited via extends.
1405
+ const objectRef = field.attr(FIELD_ATTR_OBJECT_REF);
1406
+ if (field.subType !== FIELD_SUBTYPE_OBJECT || typeof objectRef !== "string" || objectRef === "") {
1407
+ errors.push(new ParseError(
1408
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @of is omitted, so this is a ` +
1409
+ `whole-object rollup — the carrying field must be a field.object declaring @objectRef ` +
1410
+ `(add @of to collect a single column instead).`,
1411
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1412
+ continue;
1413
+ }
1414
+ // #210's value-only rule is PAYLOAD-scoped and never reaches a
1415
+ // projection-hosted field, so this branch enforces it itself.
1416
+ // Without it an @objectRef to an entity silently rolls up the FULL
1417
+ // entity — the #270 shape, this time baked into DDL.
1418
+ // ADR-0042 — a bare @objectRef resolves in the DECLARING owner's
1419
+ // package (an inherited field resolves in the package that
1420
+ // declared it) — same rule _checkNestedPayloadRefsValueOnly uses.
1421
+ const refOwner = field.parent ?? obj;
1422
+ const refPkg = refOwner.package ?? refOwner.fileDefaultPackage ?? "";
1423
+ const refTarget = resolveObjectRef(root, objectRef, refPkg).node;
1424
+ if (refTarget !== undefined && refTarget.subType !== OBJECT_SUBTYPE_VALUE) {
1425
+ errors.push(new ParseError(
1426
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @objectRef '${objectRef}' ` +
1427
+ `resolves to ${TYPE_OBJECT}.${refTarget.subType} — a whole-object rollup must target an ` +
1428
+ `object.value (#210, ADR-0028).`,
1429
+ { code: "ERR_SUBTYPE_RULE_VIOLATION", source: src }));
1430
+ continue;
1431
+ }
1432
+ // ADR-0039: own — origin.* never inherits (ADR-0029).
1433
+ const viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1434
+ if (typeof viaAttr !== "string" || viaAttr === "") {
1435
+ errors.push(new ParseError(
1436
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @via is required on a ` +
1437
+ `whole-object rollup — there is no @of entity to infer the relationship from.`,
1438
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1439
+ continue;
1440
+ }
1441
+ // @distinct is refused on the object form. It is NOT an engine limit
1442
+ // (both engines dedupe JSON objects); it is a guaranteed no-op
1443
+ // whenever the value object carries the entity's primary key, which
1444
+ // is the common case, and a silent no-op is worse than a refusal.
1445
+ if (hasDistinct) {
1446
+ errors.push(new ParseError(
1447
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @distinct is not supported on a ` +
1448
+ `whole-object rollup (it is a no-op whenever the value object carries the primary key).`,
1449
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1450
+ continue;
1451
+ }
1452
+ // One walk yields both the hops (cardinality) and the terminal
1453
+ // entity (@orderBy keys, member resolution). An invalid @via
1454
+ // (e.g. single-segment "A") returns undefined having already
1455
+ // pushed its own error, so everything downstream is skipped and
1456
+ // no second, misleadingly-scoped error is emitted.
1457
+ const via = _validateViaPath(viaAttr, root, obj, field.name, src, errors);
1458
+ if (via !== undefined) {
1459
+ _checkAggregateCardinality(via.hops, obj, field.name, src, errors);
1460
+ // @orderBy keys resolve against the @via TERMINAL entity, not @of.
1461
+ _validateOrderByKeys(orderBy, via.terminal, obj, field.name, "origin.aggregate @agg:collect", src, errors,
1462
+ "ERR_COLLECT_WHOLE_OBJECT");
1463
+ if (refTarget !== undefined) {
1464
+ _checkCollectMembers(refTarget, via.terminal, obj, field, src, errors);
1465
+ }
1466
+ }
1257
1467
  continue;
1258
1468
  }
1259
1469
  // NOTE (FR-024 B6): NO extends/origin agreement check on aggregates —
@@ -1274,9 +1484,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1274
1484
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1275
1485
  const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1276
1486
  if (typeof via === "string" && via !== "") {
1277
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1278
- if (hops !== undefined) {
1279
- _checkAggregateCardinality(hops, obj, field.name, src, errors);
1487
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1488
+ if (walked !== undefined) {
1489
+ _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1280
1490
  }
1281
1491
  continue;
1282
1492
  }
@@ -1369,8 +1579,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1369
1579
  // @via — explicit (validated + cardinality) or single-hop-unique inferred.
1370
1580
  const via = origin.ownAttr(ORIGIN_FIRST_ATTR_VIA);
1371
1581
  if (typeof via === "string" && via !== "") {
1372
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1373
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1582
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1583
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1374
1584
  } else if (ofTarget !== undefined) {
1375
1585
  // (A value host never reaches here — the #210 assembly-origin
1376
1586
  // check above already rejected origin.first on a value.)
@@ -1887,12 +2097,32 @@ export function validateRelationships(root: MetaData): ParseError[] {
1887
2097
  // (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
1888
2098
 
1889
2099
  // ---------------------------------------------------------------------------
1890
- // index.lookup @fields resolution (Task 3)
2100
+ // Index-key resolution for index.lookup AND identity.secondary (#342)
2101
+ //
2102
+ // An index declares its key EXACTLY ONE of two ways: plain columns (@fields) or
2103
+ // a key expression (@expr, e.g. `lower(email)` / `(payload->>'device_id')`).
2104
+ // The registry has always said so — @expr is described as "Used INSTEAD of
2105
+ // @fields" — and `migrate-ts` has always implemented it that way
2106
+ // (`columns: expr ? [] : cols`, expected-schema.ts). Only the LOADER disagreed,
2107
+ // requiring @fields unconditionally, which made an expression index
2108
+ // unreachable: omitting @fields failed to load, and the one spelling that DID
2109
+ // load (@fields AND @expr) had its @fields silently discarded by the engine.
2110
+ //
2111
+ // So the two rules below are one rule — the key is @fields XOR @expr:
2112
+ // - NEITHER: nothing declares the key.
2113
+ // - BOTH: contradictory, and previously half-honored. Rejected rather than
2114
+ // given a precedence rule, because an accepted-but-half-ignored declaration
2115
+ // is exactly the silent-wrong-output the sealed strict registry exists to
2116
+ // prevent (cf. ERR_SQL_BODY_WITH_UNMANAGED — @sql vs @unmanaged is the same
2117
+ // "two mutually exclusive non-default states of one axis" shape).
2118
+ //
2119
+ // Applies to identity.secondary too: per ADR-0040 uniqueness lives in the TYPE,
2120
+ // so identity.secondary IS a unique index and keys itself identically. Both
2121
+ // carry @expr from the same db provider, and migrate-ts branches on @expr for
2122
+ // both — the loader was the only tier treating them differently.
1891
2123
  //
1892
- // Every index.lookup on an entity must name at least one field, and every
1893
- // named field must exist in the entity's EFFECTIVE (resolved) field set.
1894
- // ADR-0039: use children() / MetaIndex.fields() — never own* — so that a
1895
- // field inherited via extends still resolves correctly.
2124
+ // ADR-0039: children() / MetaIndex.fields() never own* so a field inherited
2125
+ // via extends still resolves.
1896
2126
  // ---------------------------------------------------------------------------
1897
2127
 
1898
2128
  export function validateIndexLookupFields(root: MetaData): ParseError[] {
@@ -1903,34 +2133,77 @@ export function validateIndexLookupFields(root: MetaData): ParseError[] {
1903
2133
  const effectiveFieldNames = new Set(
1904
2134
  obj.children().filter((c) => c.type === TYPE_FIELD).map((f) => f.name),
1905
2135
  );
1906
- for (const node of obj.children().filter(
1907
- (c) => c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP,
1908
- )) {
1909
- // MetaIndex.fields() uses the resolving attr() accessor per ADR-0039.
1910
- const idx = node as MetaIndex;
1911
- const fields = idx.fields();
1912
-
1913
- // Rule 1: must have at least one field.
1914
- if (fields.length === 0) {
2136
+ const keyed = obj.children().filter(
2137
+ (c) =>
2138
+ (c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP) ||
2139
+ (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_SECONDARY),
2140
+ );
2141
+ for (const node of keyed) {
2142
+ const label = `${node.type}.${node.subType}`;
2143
+ // PRESENCE vs CONTENT are two different questions here, and conflating them
2144
+ // is a bug in both directions:
2145
+ //
2146
+ // - The CONTRADICTION check needs PRESENCE. `@fields: []` alongside @expr
2147
+ // is still a declaration of both, and keying it on non-emptiness let the
2148
+ // total-discard spelling load clean while `@fields: ["x"]` + @expr was
2149
+ // refused — the rule missing exactly the case it exists to catch.
2150
+ // - The KEY-RESOLUTION check needs normalized CONTENT, via the guarded
2151
+ // accessor. Reading the raw attr with a cast meant a scalar `@fields: 5`
2152
+ // threw an uncaught TypeError out of load() in TS while the other three
2153
+ // ports reported a clean error.
2154
+ //
2155
+ // The guarded accessor is the fix for the second and the OBSTACLE for the
2156
+ // first — it collapses absent, scalar and explicit `[]` to the same `[]` —
2157
+ // so the two questions are asked separately and never routed through one
2158
+ // predicate. ADR-0039: both reads resolve through extends.
2159
+ const hasFieldsAttr = node.attr(IDENTITY_ATTR_FIELDS) !== undefined;
2160
+ // MetaIndex.fields() / MetaIdentity.fields are the same guarded read
2161
+ // (`Array.isArray(f) ? f : []`); never re-hand-roll it.
2162
+ const fields =
2163
+ node instanceof MetaIndex ? node.fields() : (node as MetaIdentity).fields;
2164
+ const exprRaw = node.attr(IDENTITY_ATTR_EXPR);
2165
+ const hasExpr = typeof exprRaw === "string" && exprRaw.trim().length > 0;
2166
+
2167
+ // Rule 1a: exactly one of @fields / @expr may be DECLARED.
2168
+ if (hasFieldsAttr && hasExpr) {
1915
2169
  errors.push(
1916
2170
  new ParseError(
1917
- `index.lookup "${idx.name}" on "${obj.name}" has no @${INDEX_ATTR_FIELDS}; ` +
1918
- `at least one field is required`,
1919
- { code: "ERR_INVALID_INDEX", source: idx.source },
2171
+ `${label} "${node.name}" on "${obj.name}" declares BOTH ` +
2172
+ `@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}; they are the two ` +
2173
+ `mutually exclusive ways to key an index. @${IDENTITY_ATTR_EXPR} is used ` +
2174
+ `INSTEAD of @${INDEX_ATTR_FIELDS} — drop one. ` +
2175
+ `(Declaring both previously loaded but silently discarded ` +
2176
+ `@${INDEX_ATTR_FIELDS}.)`,
2177
+ { code: "ERR_INVALID_INDEX", source: node.source },
1920
2178
  ),
1921
2179
  );
1922
2180
  continue;
1923
2181
  }
1924
2182
 
2183
+ // Rule 1b: whichever is declared must actually supply a key.
2184
+ if (fields.length === 0 && !hasExpr) {
2185
+ errors.push(
2186
+ new ParseError(
2187
+ `${label} "${node.name}" on "${obj.name}" declares no key: ` +
2188
+ `it must have @${INDEX_ATTR_FIELDS} (one or more columns) or ` +
2189
+ `@${IDENTITY_ATTR_EXPR} (a key expression)`,
2190
+ { code: "ERR_INVALID_INDEX", source: node.source },
2191
+ ),
2192
+ );
2193
+ continue;
2194
+ }
1925
2195
  // Rule 2: every named field must resolve against the entity's effective field set.
2196
+ // An expression index has no @fields to resolve — @expr is raw SQL over the
2197
+ // physical columns, deliberately not parsed here (ADR-0023 keeps the grammar
2198
+ // closed only where the loader owns it).
1926
2199
  for (const fieldName of fields) {
1927
2200
  if (!effectiveFieldNames.has(fieldName)) {
1928
2201
  errors.push(
1929
2202
  new ParseError(
1930
- `index.lookup "${idx.name}" on "${obj.name}" references field "${fieldName}" ` +
2203
+ `${label} "${node.name}" on "${obj.name}" references field "${fieldName}" ` +
1931
2204
  `which does not exist on "${obj.name}". ` +
1932
2205
  `Available fields: ${[...effectiveFieldNames].join(", ") || "(none)"}`,
1933
- { code: "ERR_INVALID_INDEX", source: idx.source },
2206
+ { code: "ERR_INVALID_INDEX", source: node.source },
1934
2207
  ),
1935
2208
  );
1936
2209
  }
@@ -1997,7 +2270,7 @@ function checkFilterClauses(
1997
2270
  // - a plain (extends-bound / no-origin) or origin.passthrough or origin.computed
1998
2271
  // field → addressable (a base/joined column, or an inlined row-level expression).
1999
2272
  // - an aggregate-derived field (origin.aggregate — count/sum/…/any/all/collect —
2000
- // or origin.first / origin.collection) → NOT addressable: a WHERE runs before
2273
+ // or origin.first) → NOT addressable: a WHERE runs before
2001
2274
  // aggregation, so it cannot see an aggregate (post-aggregate filtering is HAVING,
2002
2275
  // a separate later extension). Fail-closed → ERR_BAD_ATTR_FILTER.
2003
2276
  // - a ref naming no declared field → dangling → ERR_BAD_ATTR_FILTER.
@@ -37,7 +37,6 @@ import {
37
37
  ORIGIN_PASSTHROUGH_ATTR_VIA,
38
38
  ORIGIN_AGGREGATE_ATTR_OF,
39
39
  ORIGIN_AGGREGATE_ATTR_VIA,
40
- ORIGIN_COLLECTION_ATTR_VIA,
41
40
  } from "./persistence/origin/origin-constants.js";
42
41
  import { SOURCE_ATTR_PARAMETER_REF } from "./persistence/source/source-constants.js";
43
42
  import { TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF } from "./template/template-constants.js";
@@ -63,10 +62,9 @@ export const REF_BEARING_ATTR_NAMES: ReadonlySet<string> = new Set<string>([
63
62
  RELATIONSHIP_ATTR_THROUGH, // ADR-0042: the M:N junction ref joins the desugar+resolution set.
64
63
  IDENTITY_REFERENCE_ATTR_REFERENCES,
65
64
  ORIGIN_PASSTHROUGH_ATTR_FROM,
66
- ORIGIN_PASSTHROUGH_ATTR_VIA, // = ORIGIN_AGGREGATE_ATTR_VIA = ORIGIN_COLLECTION_ATTR_VIA ("via")
65
+ ORIGIN_PASSTHROUGH_ATTR_VIA, // = ORIGIN_AGGREGATE_ATTR_VIA ("via")
67
66
  ORIGIN_AGGREGATE_ATTR_OF,
68
67
  ORIGIN_AGGREGATE_ATTR_VIA,
69
- ORIGIN_COLLECTION_ATTR_VIA,
70
68
  SOURCE_ATTR_PARAMETER_REF,
71
69
  TEMPLATE_ATTR_PAYLOAD_REF,
72
70
  TEMPLATE_ATTR_RESPONSE_REF,
@@ -29,9 +29,16 @@ import { MetaRoot } from "./shared/meta-root.js";
29
29
  import { MetaAttr } from "./core/attr/meta-attr.js";
30
30
  import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
31
31
  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";
32
34
  import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
33
35
  import { semanticDiff } from "./semantic-diff.js";
34
- import { resolveSuperRef, isChildTargetingRef } from "./super-resolve.js";
36
+ import {
37
+ resolveSuperRef,
38
+ isChildTargetingRef,
39
+ extendsTargetCompatible,
40
+ EXTENDS_TARGET_MISMATCH_RULE,
41
+ } from "./super-resolve.js";
35
42
  import { JsonPathBuilder } from "./json-path.js";
36
43
  import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js";
37
44
  import {
@@ -422,8 +429,13 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
422
429
  _currentPath!.pushKey(rootKey);
423
430
  const src = errSource();
424
431
  _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);
425
436
  throw new ParseError(
426
- `Unknown root type "${rootType}.${rootSubType}" — not registered`,
437
+ `Unknown root type "${rootType}.${rootSubType}" — ` +
438
+ (retiredRoot !== undefined ? retirementHint(retiredRoot) : "not registered"),
427
439
  { code: rootTypeCode, source: src },
428
440
  );
429
441
  }
@@ -607,12 +619,18 @@ function parseNodeFresh(
607
619
  // FR-024 — a dotted child-targeting ref must resolve to a node of the
608
620
  // SAME type and subtype as the extending node. Dotted-only check; the
609
621
  // shipped top-level extends behavior is unchanged.
622
+ //
623
+ // The predicate is IMPORTED, not restated: this eager path and
624
+ // super-resolve's deferred path are two doors onto one rule, and they
625
+ // previously held independent copies of the boolean — so #310's relaxation
626
+ // applied to one would have left the other refusing, on whichever path a
627
+ // given loader configuration happens to take.
610
628
  if (
611
629
  isChildTargetingRef(model.superRef) &&
612
- (superModel.type !== model.type || superModel.subType !== model.subType)
630
+ !extendsTargetCompatible(model, superModel)
613
631
  ) {
614
632
  throw new ParseError(
615
- `the extends target '${model.superRef}' is ${superModel.type}.${superModel.subType} but the extending node '${model.fqn()}' is ${model.type}.${model.subType} — a dotted extends must target a node of the same type and subtype`,
633
+ `the extends target '${model.superRef}' is ${superModel.type}.${superModel.subType} but the extending node '${model.fqn()}' is ${model.type}.${model.subType} — ${EXTENDS_TARGET_MISMATCH_RULE}`,
616
634
  {
617
635
  code: "ERR_EXTENDS_TARGET_MISMATCH",
618
636
  source: resolvedSource(errSource(), model.fqn(), model.superRef),
@@ -1253,9 +1271,13 @@ function processChildren(
1253
1271
  const childTypeCode = explicit && registry.allSubTypesOf(childType).length > 0
1254
1272
  ? "ERR_UNKNOWN_SUBTYPE" as const
1255
1273
  : "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);
1256
1277
  errors.push(
1257
1278
  new ParseError(
1258
- `Unknown type "${childType}.${childSubType}" — not registered`,
1279
+ `Unknown type "${childType}.${childSubType}" — ` +
1280
+ (retiredChild !== undefined ? retirementHint(retiredChild) : "not registered"),
1259
1281
  { code: childTypeCode, source: errSource() },
1260
1282
  ),
1261
1283
  );