@sdd-method/maintain-cli 0.0.0-stage → 0.1.0-rc.0

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 (206) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +17 -0
  3. package/README.md +6 -2
  4. package/dist/lib/adr-origin.js +59 -0
  5. package/dist/lib/agent-harness.js +28 -0
  6. package/dist/lib/briefing/index.js +13 -0
  7. package/dist/lib/briefing/render.js +123 -0
  8. package/dist/lib/briefing/variables.js +92 -0
  9. package/dist/lib/bundle.js +123 -0
  10. package/dist/lib/catalogue/aggregate.js +2404 -0
  11. package/dist/lib/catalogue/aggregation-manifest-loader.js +173 -0
  12. package/dist/lib/catalogue/application-archetype/expectations.js +119 -0
  13. package/dist/lib/catalogue/application-archetype/vocabulary.js +177 -0
  14. package/dist/lib/catalogue/archetype/maps/android.yaml +66 -0
  15. package/dist/lib/catalogue/archetype/maps/swiftui.yaml +58 -0
  16. package/dist/lib/catalogue/archetype/maps/uikit.yaml +48 -0
  17. package/dist/lib/catalogue/archetype/maps/web-generic.yaml +86 -0
  18. package/dist/lib/catalogue/archetype/openui-archetype-vocabulary.yaml +46 -0
  19. package/dist/lib/catalogue/archetype/resolver.js +103 -0
  20. package/dist/lib/catalogue/archetype/wireframe.js +418 -0
  21. package/dist/lib/catalogue/baseline-check.js +151 -0
  22. package/dist/lib/catalogue/build.js +1502 -0
  23. package/dist/lib/catalogue/builders/adr-supersedes.js +103 -0
  24. package/dist/lib/catalogue/builders/adrs.js +222 -0
  25. package/dist/lib/catalogue/builders/application-conformance-coverage.js +47 -0
  26. package/dist/lib/catalogue/builders/application-data-plane-bindings.js +115 -0
  27. package/dist/lib/catalogue/builders/c4-components.js +229 -0
  28. package/dist/lib/catalogue/builders/c4-containers.js +240 -0
  29. package/dist/lib/catalogue/builders/c4-entity-rollups.js +87 -0
  30. package/dist/lib/catalogue/builders/c4-external-systems.js +194 -0
  31. package/dist/lib/catalogue/builders/c4-l1-candidates.js +107 -0
  32. package/dist/lib/catalogue/builders/c4-systems.js +98 -0
  33. package/dist/lib/catalogue/builders/capabilities.js +488 -0
  34. package/dist/lib/catalogue/builders/capability-domain-model.js +74 -0
  35. package/dist/lib/catalogue/builders/capability-edges.js +256 -0
  36. package/dist/lib/catalogue/builders/capability-spec-walker.js +99 -0
  37. package/dist/lib/catalogue/builders/capability-spec.js +348 -0
  38. package/dist/lib/catalogue/builders/container-dependency.js +183 -0
  39. package/dist/lib/catalogue/builders/contracts.js +402 -0
  40. package/dist/lib/catalogue/builders/data-items.js +344 -0
  41. package/dist/lib/catalogue/builders/data-store-mapping.js +113 -0
  42. package/dist/lib/catalogue/builders/domain-models.js +175 -0
  43. package/dist/lib/catalogue/builders/domains.js +119 -0
  44. package/dist/lib/catalogue/builders/edge-label-merge.js +19 -0
  45. package/dist/lib/catalogue/builders/entities-manifest.js +160 -0
  46. package/dist/lib/catalogue/builders/entities-mermaid.js +189 -0
  47. package/dist/lib/catalogue/builders/entity-persistence.js +102 -0
  48. package/dist/lib/catalogue/builders/entity-slug.js +47 -0
  49. package/dist/lib/catalogue/builders/event-catalog-walker.js +186 -0
  50. package/dist/lib/catalogue/builders/event-edges.js +327 -0
  51. package/dist/lib/catalogue/builders/events.js +168 -0
  52. package/dist/lib/catalogue/builders/evidence-class.js +43 -0
  53. package/dist/lib/catalogue/builders/evidence-target-repo.js +67 -0
  54. package/dist/lib/catalogue/builders/external-system-exemption.js +120 -0
  55. package/dist/lib/catalogue/builders/feature-contract.js +77 -0
  56. package/dist/lib/catalogue/builders/feature-domain-model.js +50 -0
  57. package/dist/lib/catalogue/builders/feature-grounding-walker.js +254 -0
  58. package/dist/lib/catalogue/builders/feature-grounding.js +391 -0
  59. package/dist/lib/catalogue/builders/feature-groups.js +63 -0
  60. package/dist/lib/catalogue/builders/feature-subscription-channels.js +228 -0
  61. package/dist/lib/catalogue/builders/feature-tree-walker.js +295 -0
  62. package/dist/lib/catalogue/builders/features.js +300 -0
  63. package/dist/lib/catalogue/builders/glossary-xref.js +378 -0
  64. package/dist/lib/catalogue/builders/glossary.js +204 -0
  65. package/dist/lib/catalogue/builders/implementations.js +214 -0
  66. package/dist/lib/catalogue/builders/index.js +64 -0
  67. package/dist/lib/catalogue/builders/integration-components.js +344 -0
  68. package/dist/lib/catalogue/builders/integrations.js +181 -0
  69. package/dist/lib/catalogue/builders/persona-c4system.js +149 -0
  70. package/dist/lib/catalogue/builders/persona-container.js +133 -0
  71. package/dist/lib/catalogue/builders/persona-journey-edges.js +133 -0
  72. package/dist/lib/catalogue/builders/personas.js +152 -0
  73. package/dist/lib/catalogue/builders/product-edges.js +106 -0
  74. package/dist/lib/catalogue/builders/product-platform-dependencies.js +405 -0
  75. package/dist/lib/catalogue/builders/products.js +162 -0
  76. package/dist/lib/catalogue/builders/quality-scores.js +635 -0
  77. package/dist/lib/catalogue/builders/repo-capability-exemption.js +61 -0
  78. package/dist/lib/catalogue/builders/repo-dependency.js +127 -0
  79. package/dist/lib/catalogue/builders/repo-edges.js +340 -0
  80. package/dist/lib/catalogue/builders/repositories.js +458 -0
  81. package/dist/lib/catalogue/builders/scaffolds.js +197 -0
  82. package/dist/lib/catalogue/builders/screen-tree.js +222 -0
  83. package/dist/lib/catalogue/builders/screens.js +396 -0
  84. package/dist/lib/catalogue/builders/service-capability-exemption.js +63 -0
  85. package/dist/lib/catalogue/builders/service-contract-consumes.js +186 -0
  86. package/dist/lib/catalogue/builders/service-contract.js +74 -0
  87. package/dist/lib/catalogue/builders/service-entity-tables.js +187 -0
  88. package/dist/lib/catalogue/builders/services.js +232 -0
  89. package/dist/lib/catalogue/builders/system-external-dependency.js +115 -0
  90. package/dist/lib/catalogue/builders/user-journeys.js +253 -0
  91. package/dist/lib/catalogue/canonical-schema.yaml +4117 -0
  92. package/dist/lib/catalogue/cli-version.js +10 -0
  93. package/dist/lib/catalogue/contract-id.js +49 -0
  94. package/dist/lib/catalogue/contract-path-resolution.js +149 -0
  95. package/dist/lib/catalogue/coverage-predicates.js +603 -0
  96. package/dist/lib/catalogue/coverage-render.js +552 -0
  97. package/dist/lib/catalogue/coverage-run.js +115 -0
  98. package/dist/lib/catalogue/csv-reader.js +97 -0
  99. package/dist/lib/catalogue/csv-writer.js +96 -0
  100. package/dist/lib/catalogue/data-item-coverage.js +142 -0
  101. package/dist/lib/catalogue/data-plane-coherence.js +77 -0
  102. package/dist/lib/catalogue/ddl-generator.js +233 -0
  103. package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
  104. package/dist/lib/catalogue/engine-validator.js +239 -0
  105. package/dist/lib/catalogue/ext-id.js +76 -0
  106. package/dist/lib/catalogue/feature-grounding-aliases-loader.js +65 -0
  107. package/dist/lib/catalogue/feature-repo-zero-resolution-summary.js +31 -0
  108. package/dist/lib/catalogue/finding-identity.js +183 -0
  109. package/dist/lib/catalogue/findings.js +46 -0
  110. package/dist/lib/catalogue/fk-validator.js +136 -0
  111. package/dist/lib/catalogue/freshness.js +69 -0
  112. package/dist/lib/catalogue/gate-checks.js +338 -0
  113. package/dist/lib/catalogue/graph-projection.js +346 -0
  114. package/dist/lib/catalogue/graph-run.js +64 -0
  115. package/dist/lib/catalogue/impact-run.js +228 -0
  116. package/dist/lib/catalogue/manifest-loader.js +435 -0
  117. package/dist/lib/catalogue/platform-scope.js +7 -0
  118. package/dist/lib/catalogue/population-checks.js +87 -0
  119. package/dist/lib/catalogue/relationship-semantics.js +66 -0
  120. package/dist/lib/catalogue/reverse-product-quality.js +99 -0
  121. package/dist/lib/catalogue/rubric-coverage-map.yaml +871 -0
  122. package/dist/lib/catalogue/runway-vocabulary-loader.js +56 -0
  123. package/dist/lib/catalogue/scaffold-delta.js +261 -0
  124. package/dist/lib/catalogue/schema-compat.js +52 -0
  125. package/dist/lib/catalogue/schema-loader.js +87 -0
  126. package/dist/lib/catalogue/scores-coverage.js +573 -0
  127. package/dist/lib/catalogue/screen-validation-loader.js +89 -0
  128. package/dist/lib/catalogue/service-architecture-parser.js +193 -0
  129. package/dist/lib/catalogue/service-contract-aliases-loader.js +109 -0
  130. package/dist/lib/catalogue/staleness-verdicts.js +345 -0
  131. package/dist/lib/catalogue/traceability-loader.js +350 -0
  132. package/dist/lib/catalogue/validate.js +200 -0
  133. package/dist/lib/catalogue/version.js +18 -0
  134. package/dist/lib/ci-binding/index.js +209 -0
  135. package/dist/lib/ci-binding/module.js +204 -0
  136. package/dist/lib/conformance/binding.js +70 -0
  137. package/dist/lib/context-pull/index.js +151 -0
  138. package/dist/lib/context-pull/resolve.js +70 -0
  139. package/dist/lib/generate-briefing/index.js +77 -0
  140. package/dist/lib/hooks-seed/copilot.js +167 -0
  141. package/dist/lib/hooks-seed/entries.js +47 -0
  142. package/dist/lib/hooks-seed/index.js +17 -0
  143. package/dist/lib/hooks-seed/lifecycle-mapping.js +28 -0
  144. package/dist/lib/hooks-seed/merge.js +128 -0
  145. package/dist/lib/hooks-seed/types.js +20 -0
  146. package/dist/lib/init/ci-workflow.js +89 -0
  147. package/dist/lib/list/discover.js +100 -0
  148. package/dist/lib/list/format.js +84 -0
  149. package/dist/lib/list/index.js +16 -0
  150. package/dist/lib/lock-file.js +432 -0
  151. package/dist/lib/mcp-seed/entries.js +14 -0
  152. package/dist/lib/mcp-seed/index.js +28 -0
  153. package/dist/lib/mcp-seed/merge.js +99 -0
  154. package/dist/lib/mcp-seed/types.js +13 -0
  155. package/dist/lib/pack/builder.js +303 -0
  156. package/dist/lib/pack/index.js +87 -0
  157. package/dist/lib/pack/leakage.js +144 -0
  158. package/dist/lib/pack/log.js +18 -0
  159. package/dist/lib/pack/publish-transform.js +233 -0
  160. package/dist/lib/pack/reference-closure.js +125 -0
  161. package/dist/lib/pack/schema.js +121 -0
  162. package/dist/lib/pack/seed.js +182 -0
  163. package/dist/lib/pack/validate.js +49 -0
  164. package/dist/lib/package-version.js +92 -0
  165. package/dist/lib/repo-kind.js +129 -0
  166. package/dist/lib/scripts.js +7 -0
  167. package/dist/lib/snapshot/catalogue-diff.js +219 -0
  168. package/dist/lib/snapshot/manifest.js +206 -0
  169. package/dist/lib/sync/adr-transitions.js +250 -0
  170. package/dist/lib/sync/apply.js +91 -0
  171. package/dist/lib/sync/briefing-merge.js +90 -0
  172. package/dist/lib/sync/bundle-cache.js +109 -0
  173. package/dist/lib/sync/bundle-source.js +61 -0
  174. package/dist/lib/sync/config.js +72 -0
  175. package/dist/lib/sync/copilot-hooks-merge.js +108 -0
  176. package/dist/lib/sync/diff.js +251 -0
  177. package/dist/lib/sync/index.js +809 -0
  178. package/dist/lib/sync/settings-merge.js +216 -0
  179. package/dist/lib/sync/yaml-block-merge.js +120 -0
  180. package/dist/lib/validate/aggregate.js +51 -0
  181. package/dist/lib/validate/bundle-coherence.js +74 -0
  182. package/dist/lib/validate/dispatch.js +243 -0
  183. package/dist/lib/validate/index.js +110 -0
  184. package/dist/lib/validate/plan.js +54 -0
  185. package/dist/lib/validate/runner.js +85 -0
  186. package/dist/lib/validate/yaml-parse.js +112 -0
  187. package/dist/maintain-bin.js +8 -0
  188. package/dist/maintain-program.js +91 -0
  189. package/dist/maintain-version.json +4 -0
  190. package/dist/verbs/catalogue-maintain.js +110 -0
  191. package/dist/verbs/catalogue-verify.js +114 -0
  192. package/dist/verbs/context.js +39 -0
  193. package/dist/verbs/generate-briefing.js +29 -0
  194. package/dist/verbs/list.js +45 -0
  195. package/dist/verbs/pack-build.js +31 -0
  196. package/dist/verbs/pack-validate.js +17 -0
  197. package/dist/verbs/path-option.js +25 -0
  198. package/dist/verbs/render.js +87 -0
  199. package/dist/verbs/sync.js +98 -0
  200. package/dist/verbs/validate.js +48 -0
  201. package/dist/verify-program.js +122 -0
  202. package/dist/verify-version.json +4 -0
  203. package/package.json +32 -4
  204. package/scripts/validate-app-dependencies.sh +664 -0
  205. package/scripts/validate-app-docs.sh +199 -0
  206. package/scripts/validate-integration-docs.sh +505 -0
@@ -0,0 +1,2404 @@
1
+ // Catalogue aggregator — `sdd-cli catalogue aggregate`.
2
+ //
3
+ // Implements the 8-step contract in
4
+ // docs/method/methods/catalogue-builder-specification.md
5
+ // §"Aggregator contract":
6
+ //
7
+ // 1. Discover sibling SDDs via aggregation-manifest.yaml.
8
+ // 2. Pull each sibling's catalogues/ (filesystem only in v1).
9
+ // 3. Verify schema-version alignment across SDDs.
10
+ // 4. Concatenate per-catalogue rows.
11
+ // 5. Stamp source_sdd_id on every node-catalogue row.
12
+ // 6. Fail on duplicate primary keys.
13
+ // 7. Run full FK validation including external references.
14
+ // 8. Emit catalogues/ + aggregation-manifest.json.
15
+ //
16
+ // The ecosystem-root SDD itself contributes its own catalogues to the
17
+ // aggregation alongside the siblings — it doesn't list itself in the
18
+ // manifest. That's how single-SDD ecosystems and federated ecosystems
19
+ // share the same code path: with zero siblings the aggregator just
20
+ // re-emits the ecosystem-root's catalogues stamped with source_sdd_id.
21
+ import { readFile, stat, writeFile } from "node:fs/promises";
22
+ import { basename, join, relative, resolve } from "node:path";
23
+ import { CLI_VERSION } from "./cli-version.js";
24
+ import { loadAggregationManifest, DEFAULT_AGGREGATION_MANIFEST_REL, } from "./aggregation-manifest-loader.js";
25
+ import { isInRange, loadSddManifest } from "./manifest-loader.js";
26
+ import { SCHEMA_COMPAT } from "./schema-compat.js";
27
+ import { loadCanonicalSchema } from "./schema-loader.js";
28
+ import { readCatalogueCsv, parseCatalogueCsv } from "./csv-reader.js";
29
+ import { writeCatalogue } from "./csv-writer.js";
30
+ import { buildLegacyContractIdMap } from "./contract-id.js";
31
+ import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
32
+ import { PLATFORM_STAMP_COLUMN, isPlatformScopedFk } from "./fk-validator.js";
33
+ import { FindingsLog } from "./findings.js";
34
+ import { readLockFile } from "../lock-file.js";
35
+ import { mergeEdgeLabel } from "./builders/edge-label-merge.js";
36
+ import { isAsyncContractType } from "./builders/product-platform-dependencies.js";
37
+ import { resolveContractPaths } from "./contract-path-resolution.js";
38
+ import { buildRepoEdges } from "./builders/repo-edges.js";
39
+ import { buildCapabilitySpec } from "./builders/capability-spec.js";
40
+ import { buildFeatureGrounding } from "./builders/feature-grounding.js";
41
+ import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
42
+ import { buildFeatureContract } from "./builders/feature-contract.js";
43
+ import { buildCapabilityEvent } from "./builders/event-edges.js";
44
+ import { discoverVocabulary } from "./application-archetype/vocabulary.js";
45
+ import { checkDataPlaneOverlayAtAggregation } from "./application-archetype/expectations.js";
46
+ import { indexServiceArchitectures, readServiceMetadata, } from "./service-architecture-parser.js";
47
+ export async function runCatalogueAggregate(inputs, write) {
48
+ const sddPath = resolve(inputs.sddPath ?? process.cwd());
49
+ const findings = new FindingsLog();
50
+ // 0. Load canonical schema.
51
+ const schema = await loadCanonicalSchema();
52
+ write(`sdd-cli catalogue aggregate (canonical schema ${schema.schemaVersion})\n`);
53
+ // 0a. Load ecosystem-root manifest.
54
+ const rootManifest = await loadSddManifest(sddPath, {
55
+ cliVersion: CLI_VERSION,
56
+ });
57
+ write(` root: ${rootManifest.sddId} profile: ${rootManifest.profile} tier: ${rootManifest.profile === "organisation-profile" ? "organisation" : "platform"}\n`);
58
+ // 1. Resolve aggregation-manifest path + load.
59
+ //
60
+ // Two anchors, deliberately, because the terms are different KINDS of path:
61
+ //
62
+ // * `inputs.manifestPath` is TYPED BY AN OPERATOR on the command line
63
+ // (`--manifest`), so it anchors to the process cwd like every other CLI
64
+ // operand. This is ON PURPOSE — do not "fix" it to follow --sdd-path.
65
+ // * `aggregationManifestPath` is DECLARED in <sdd-path>/.sdd/sdd-manifest.yaml
66
+ // and is documented relative to the SDD root, so it anchors to `sddPath`.
67
+ // Anchoring it to cwd was the B3 defect: every real ecosystem root declares
68
+ // it (the forward estate declares the default name verbatim), so running
69
+ // `aggregate --sdd-path <root>` from anywhere else exited 1 naming a path
70
+ // under the CALLER's directory — which reads as the default branch being
71
+ // broken, though that branch was always anchored correctly (below, and
72
+ // guarded by its own test).
73
+ const manifestPath = inputs.manifestPath !== undefined
74
+ ? resolve(inputs.manifestPath)
75
+ : rootManifest.aggregationManifestPath !== undefined
76
+ ? resolve(sddPath, rootManifest.aggregationManifestPath)
77
+ : join(sddPath, DEFAULT_AGGREGATION_MANIFEST_REL);
78
+ const aggManifest = await loadAggregationManifest(manifestPath, {
79
+ cliVersion: CLI_VERSION,
80
+ ecosystemRootPath: sddPath,
81
+ });
82
+ if (aggManifest.ecosystemRoot !== rootManifest.sddId) {
83
+ throw new Error(`aggregation manifest root_sdd_id="${aggManifest.ecosystemRoot}"\n` +
84
+ ` does not match the owning SDD's sdd_id="${rootManifest.sddId}"\n` +
85
+ ` (rule: root_sdd_id_consistent, M-ADR 0208 §4.3)\n` +
86
+ ` (manifest: ${manifestPath})`);
87
+ }
88
+ write(` aggregation manifest: ${relative(sddPath, manifestPath)}\n`);
89
+ write(` siblings: ${aggManifest.siblings.length}\n`);
90
+ // The work queue, reported rather than merely parseable: an audit asking "what is
91
+ // still pending authoring?" gets it from a normal aggregate run (M-ADR 0195 §4.3).
92
+ if (aggManifest.pendingMembers.length > 0) {
93
+ write(` pending authoring: ${aggManifest.pendingMembers.length}\n`);
94
+ for (const p of aggManifest.pendingMembers) {
95
+ write(` - ${p.sddId}: ${p.reason}${p.blockedOn ? ` [blocked on: ${p.blockedOn}]` : ""}\n`);
96
+ }
97
+ }
98
+ // 2. Pull (filesystem only in v1) — pre-checked at manifest load. Now
99
+ // cross-check each sibling's own .sdd/sdd-manifest.yaml + its
100
+ // vendored schema vs the aggregator's.
101
+ const crossCheck = await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
102
+ if (aggManifest.rootFieldDeprecated) {
103
+ findings.add("convention", `aggregation manifest uses deprecated key \`ecosystem_root\` — rename to \`root_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
104
+ }
105
+ // M-ADR 0208 §4.2/§4.4: the tier decides the platform_sdd_id fill rule.
106
+ const rootIsOrganisation = rootManifest.profile === "organisation-profile";
107
+ // 3. Schema-version alignment was checked in crossCheckSiblings.
108
+ // 4 + 5. Concatenate per-catalogue rows from the ecosystem-root + every
109
+ // sibling, stamping source_sdd_id on node catalogues as we go.
110
+ const rowsByCatalogue = new Map();
111
+ const perSiblingCounts = new Map();
112
+ for (const cat of schema.catalogues) {
113
+ rowsByCatalogue.set(cat.name, []);
114
+ }
115
+ perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId, rootIsOrganisation ? null : rootManifest.sddId));
116
+ // source_sdd_id -> the platform the §4.4 fill rule assigned it. Kept for the final
117
+ // pass (fillDerivedPlatformStamps): rows the aggregator itself constructs AFTER the
118
+ // node loader (0189 adjudication conversions, re-derived edges) set source_sdd_id
119
+ // explicitly and would otherwise leave platform_sdd_id empty under FK PASS —
120
+ // the same silent shape as the empty platform leg on cross_sdd_platform_capability.
121
+ const platformBySource = new Map();
122
+ if (!rootIsOrganisation)
123
+ platformBySource.set(rootManifest.sddId, rootManifest.sddId);
124
+ for (const sib of aggManifest.siblings) {
125
+ // M-ADR 0208 §4.4 fill rule, by pull path. An empty stamp is UNRESOLVED and is
126
+ // reported below by checkPlatformKindUniqueness; it is never a key value.
127
+ let platformSddId;
128
+ if (!rootIsOrganisation) {
129
+ platformSddId = rootManifest.sddId; // every row in a platform-tier aggregate belongs to it
130
+ }
131
+ else if (sib.level === "organisation") {
132
+ platformSddId = null; // arrived stamped from the child's own platform-tier run
133
+ }
134
+ else {
135
+ // CATEGORY decides root-ness, not profile: platform-subsystem is platform-profile
136
+ // too, but it is a MEMBER contained by exactly one platform (M-ADR 0208 §4.1).
137
+ const sibCategory = crossCheck.siblingCategories.get(sib.sddId);
138
+ const declared = crossCheck.siblingDeclaredPlatform.get(sib.sddId);
139
+ if (sibCategory === "platform-root") {
140
+ platformSddId = sib.sddId; // a platform-root is its own platform
141
+ }
142
+ else if (declared !== undefined) {
143
+ platformSddId = declared; // a member that declares its platform gets it
144
+ }
145
+ else {
146
+ platformSddId = null;
147
+ findings.add("coverage", `platform_sdd_id: sibling "${sib.sddId}" (${sibCategory ?? crossCheck.siblingProfiles.get(sib.sddId) ?? "?"}) is a MEMBER pulled directly at organisation tier with no declared platform — rows left unstamped (M-ADR 0208 §4.4; federate it under its platform's aggregate, or set platform_sdd_id in its manifest)`);
148
+ }
149
+ }
150
+ if (crossCheck.deprecatedPlatformField.has(sib.sddId)) {
151
+ findings.add("convention", `sibling "${sib.sddId}" manifest uses deprecated key \`ecosystem_root\` — rename to \`platform_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
152
+ }
153
+ if (platformSddId !== null)
154
+ platformBySource.set(sib.sddId, platformSddId);
155
+ perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId, platformSddId));
156
+ }
157
+ // @rule aggregate-cross-sdd-platform-capability-derivation
158
+ // 5a. Derive cross_sdd_platform_capability — federated edge, schema 2.9.0.
159
+ // Joins each consuming SDD's product_platform_dependencies rows against
160
+ // every platform SDD's capability_service rows on target_service_id ==
161
+ // capability_service.service_id, emitting one row per resolved
162
+ // (consuming-product, platform-capability) pair. Per-SDD builders
163
+ // emit nothing for this catalogue; the aggregator owns it entirely.
164
+ // See content/plans/v0-9-0-cohort-sweep-derived-bundle.md
165
+ // §"Cross-SDD platform-capability-edge" + ADR 0134.
166
+ const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings, rowsByCatalogue.get("capabilities") ?? []);
167
+ rowsByCatalogue.set("cross_sdd_platform_capability", crossSddRows);
168
+ // @rule aggregate-feature-integration-support
169
+ // 5a-di. Derive feature_integration_support (M-ADR 0216 §4.4): supply and demand come from
170
+ // different SDDs, so coverage is computed only here. The demand-declared fraction is printed
171
+ // beside the verdicts — a coverage table is only as complete as its demand side (§5).
172
+ const coverage = deriveFeatureIntegrationSupport(rowsByCatalogue);
173
+ rowsByCatalogue.set("feature_integration_support", coverage.rows);
174
+ write(` data-item coverage (M-ADR 0216): ${coverage.stats.featuresWithRequirements} of ${coverage.stats.features} feature(s) declare a requirement; ` +
175
+ `${coverage.stats.integrationsWithSupply} of ${coverage.stats.integrations} integration(s) declare supply; ` +
176
+ `${coverage.rows.length} pair(s): ${Object.entries(coverage.stats.byVerdict).sort().map(([k, v]) => `${k} ${v}`).join(", ") || "none"} ` +
177
+ `(supported = reaches the canonical boundary, not end to end)\n`);
178
+ // Every SDD root in this aggregation, by sdd_id: the ecosystem root plus each
179
+ // sibling. This is precisely what the app tier does not have and cannot obtain.
180
+ const sddRootsById = new Map([
181
+ [rootManifest.sddId, sddPath],
182
+ ...aggManifest.siblings.map((sib) => [sib.sddId, sib.sddRoot]),
183
+ ]);
184
+ // The same roots, additionally reachable by DIRECTORY BASENAME. An author who
185
+ // writes a `platform/<name>/...` contract path is reading that name off the
186
+ // filesystem, so it is the directory — which is routinely not the sdd_id (one
187
+ // estate declares `experience-platform` in a directory named
188
+ // `sdd-experience-platform`). Kept separate from sddRootsById on purpose: the
189
+ // try-every-root pass must iterate each root ONCE, or a root reachable under two
190
+ // names would count as two hits and report a false ambiguity.
191
+ const sddRootAliases = new Map(sddRootsById);
192
+ for (const root of sddRootsById.values()) {
193
+ sddRootAliases.set(basename(root), root);
194
+ }
195
+ // 5a-quater. M-ADR 0204 §4.2–§4.3: corroborate off-platform rows. Their
196
+ // external-system id SHOULD match a c4_external_systems entry — WARNING when
197
+ // it does not, because the C4 surface may legitimately lag discovery, and a
198
+ // declared FK could only fail. A capability id surviving on such a row (a
199
+ // member built by a pre-0204 CLI can still carry one) is reported per §4.3.
200
+ {
201
+ const extIds = new Set((rowsByCatalogue.get("c4_external_systems") ?? []).map((r) => String(r["id"] ?? "").trim()));
202
+ let offPlatform = 0;
203
+ let corroborated = 0;
204
+ for (const ppd of rowsByCatalogue.get("product_platform_dependencies") ?? []) {
205
+ const locus = String(ppd["target_locus"] ?? "").trim().toLowerCase();
206
+ if (locus !== "external" && locus !== "legacy")
207
+ continue;
208
+ offPlatform++;
209
+ const who = `${String(ppd["source_sdd_id"] ?? "")}/${String(ppd["product_id"] ?? "")}`;
210
+ const extId = String(ppd["target_external_system_id"] ?? "").trim();
211
+ if (extId !== "" && extIds.has(extId)) {
212
+ corroborated++;
213
+ }
214
+ else if (extId !== "") {
215
+ findings.add("external", `product_platform_dependencies: ${who} targets ${locus} system "${extId}" with no corroborating c4_external_systems entry (M-ADR 0204 §4.2) — attested by the consumer only`);
216
+ }
217
+ else {
218
+ findings.add("external", `product_platform_dependencies: ${who} declares a ${locus} target with no external-system id — keyed by contract_path only`);
219
+ }
220
+ const capLeft = String(ppd["target_capability_id"] ?? "").trim();
221
+ if (capLeft !== "") {
222
+ findings.add("convention", `product_platform_dependencies: ${who} carries target_capability_id="${capLeft}" on a ${locus} row — an off-platform target has no platform capability (M-ADR 0204 §4.3)`);
223
+ }
224
+ }
225
+ if (offPlatform > 0) {
226
+ write(` off-platform dependencies: ${offPlatform} declared, ${corroborated} corroborated by c4_external_systems\n`);
227
+ }
228
+ }
229
+ // 5a-ter. Resolve every declared contract_path (A2 step 5 / check 3b). The
230
+ // app-tier gate can only resolve a path against the ONE platform root it was
231
+ // given, so a contract owned by a sibling SDD is reported as missing when it
232
+ // exists — a false failure no app-tier fix can reach. Here every SDD root is
233
+ // present, so the resolution is possible for the first time.
234
+ //
235
+ // Findings only, no rows and no rewriting: an ambiguous path is reported and
236
+ // left alone rather than resolved to a guess.
237
+ const contractPathSummary = await resolveContractPaths(rowsByCatalogue.get("product_platform_dependencies") ?? [], sddRootsById, sddRootAliases, fileExists, findings);
238
+ // Stated, not merely computed. "0 unresolved" and "nothing was resolvable" are
239
+ // different results and must not print the same (M-ADR 0200 §4.3).
240
+ {
241
+ const c = contractPathSummary;
242
+ const declared = c.resolved + c.missing + c.ambiguous + c.nonPath;
243
+ write(` contract paths: ${c.resolved} of ${declared} declared resolved` +
244
+ ` (${c.missing} missing, ${c.ambiguous} ambiguous, ${c.nonPath} not a path;` +
245
+ ` ${c.absent} dependency(ies) declare none) across ${sddRootsById.size} SDD root(s)\n`);
246
+ }
247
+ // 5a-bis. M-ADR 0198: re-derive the derived edges whose join can legitimately span
248
+ // sibling SDDs, over the merged rows above. Concatenation alone cannot produce a
249
+ // cross-SDD row for these — neither member holds both join inputs, so neither computes
250
+ // it, and appending finished per-member joins never creates a row no input produced.
251
+ //
252
+ // §4.1's rule (a catalogue qualifies when it is derived AND at least one join input can
253
+ // carry a cross-SDD reference) selects five: feature_repo, repo_capability, repo_contract,
254
+ // feature_contract, capability_event. One shared mechanism (rederiveAppend below) runs
255
+ // all five so a sixth qualifying catalogue is a new call, not new merge logic.
256
+ //
257
+ // feature_repo moved into scope under §4.1's "automatic movement" clause: its derived leg
258
+ // resolves free-text evidence tokens (M-ADR 0143 §4.4) against `repositories.id`, and the
259
+ // merged repositories table carries every sibling's repos — a platform spec that names a
260
+ // repo registered by a MEMBER SDD can only resolve here. The spec text is not a catalogue,
261
+ // so the derived leg is re-run the way §4.2 describes for every builder: the two feature
262
+ // extractors are invoked over each manifest-read contributor's docs with the MERGED
263
+ // repository / service / capability id sets as their resolution inputs. Only their
264
+ // feature_repo output is kept; feature rows and their per-row findings were already
265
+ // emitted at member tier and are not repeated. level:organisation siblings expose no docs
266
+ // (pre-built catalogues-aggregated/ only) and are skipped — their rows arrived from their
267
+ // own platform-tier run, where this pass has already happened.
268
+ //
269
+ // §4.2: only the derived leg is re-run — each builder is invoked with its authored-leg
270
+ // inputs (configs / manifests) empty, which runs zero iterations of that path and yields
271
+ // the derived rows alone; the authored rows are already correct via concatenation and are
272
+ // left untouched. §4.2/§4.3: rederiveAppend adds the result to what concatenation already
273
+ // produced, keeping the concatenated row on any primary-key collision — never a replace.
274
+ {
275
+ const mergedRepoIds = new Set((rowsByCatalogue.get("repositories") ?? []).map((r) => String(r["id"])));
276
+ const mergedServiceIds = new Set((rowsByCatalogue.get("services") ?? []).map((r) => String(r["id"])));
277
+ const mergedCapabilityIds = new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"])));
278
+ const mergedFeatureIds = new Set((rowsByCatalogue.get("features") ?? []).map((r) => String(r["id"])));
279
+ // Re-derivation FILLS ABSENCES; it does not widen. A feature its own member
280
+ // build already anchored keeps exactly those anchors: re-running bare-token
281
+ // resolution against every sibling's repo ids over an already-resolved row
282
+ // mostly finds collisions (measured on one estate: 13 of the 43 rows it would
283
+ // have added to anchored features resolved to repo ids like `web`, `proxy`,
284
+ // `service`, `batch` owned by unrelated SDDs; of the 261 rows added to
285
+ // UNANCHORED features, 4 did). The motivating case — and §4.1's clause — is a
286
+ // row no member could produce, which is exactly an unanchored feature.
287
+ const anchoredFeatureIds = new Set((rowsByCatalogue.get("feature_repo") ?? []).map((r) => String(r["feature_id"])));
288
+ const contributors = [
289
+ { sddPath, profile: rootManifest.profile, sddId: rootManifest.sddId },
290
+ ];
291
+ for (const sib of aggManifest.siblings) {
292
+ if (sib.level === "organisation")
293
+ continue;
294
+ const profile = crossCheck.siblingProfiles.get(sib.sddId);
295
+ if (profile === undefined)
296
+ continue;
297
+ contributors.push({ sddPath: sib.sddRoot, profile, sddId: sib.sddId });
298
+ }
299
+ const rederivedFeatureRepo = [];
300
+ // Both extractors, over several contributors, can yield the same edge (the same
301
+ // feature grounded in the same repo from two tables); rederiveAppend dedups only
302
+ // against the CONCATENATED rows, so dedup the re-derived set itself here or the
303
+ // duplicate-PK check fails the aggregate on two copies of one correct fact.
304
+ const rederivedIds = new Set();
305
+ const scratch = new FindingsLog(); // member-tier findings already emitted; not repeated
306
+ for (const c of contributors) {
307
+ const aliases = await loadFeatureGroundingAliases(c.sddPath);
308
+ const common = {
309
+ sddPath: c.sddPath,
310
+ findings: scratch,
311
+ profile: c.profile,
312
+ productId: c.sddId,
313
+ knownCapabilityIds: mergedCapabilityIds,
314
+ knownServiceIds: mergedServiceIds,
315
+ knownRepoKeys: mergedRepoIds,
316
+ // Against every sibling's ids a prose word collides with a repo that happens to be
317
+ // named like it ("templates"); here only identifier-shaped tokens name a repo.
318
+ repoTokenPolicy: "identifier",
319
+ existingFeatureIds: new Set(),
320
+ aliases,
321
+ };
322
+ const [grounding, spec] = await Promise.all([
323
+ buildFeatureGrounding(common),
324
+ buildCapabilitySpec(common),
325
+ ]);
326
+ for (const fr of [...grounding.featureRepoRows, ...spec.featureRepoRows]) {
327
+ const featureId = String(fr["feature_id"] ?? "");
328
+ if (!mergedFeatureIds.has(featureId))
329
+ continue;
330
+ if (anchoredFeatureIds.has(featureId))
331
+ continue;
332
+ const id = String(fr["id"] ?? "");
333
+ if (id === "" || rederivedIds.has(id))
334
+ continue;
335
+ rederivedIds.add(id);
336
+ rederivedFeatureRepo.push(fr);
337
+ }
338
+ }
339
+ const before = (rowsByCatalogue.get("feature_repo") ?? []).length;
340
+ rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo);
341
+ const added = (rowsByCatalogue.get("feature_repo") ?? []).length - before;
342
+ if (added > 0) {
343
+ findings.add("coverage", `feature_repo: ${added} row(s) re-derived at aggregate tier for features that had NO anchor at member tier — evidence tokens naming a repo registered by another contributor resolve only against the merged repositories; already-anchored features are not widened (M-ADR 0198 §4.1 as amended; ${contributors.length} contributor doc trees scanned)`);
344
+ }
345
+ }
346
+ const repoEdges = buildRepoEdges({
347
+ findings,
348
+ configs: [],
349
+ capabilityIdByPath: new Map(),
350
+ contractIdByPath: new Map(),
351
+ serviceRows: rowsByCatalogue.get("services") ?? [],
352
+ capabilityServiceRows: rowsByCatalogue.get("capability_service") ?? [],
353
+ serviceContractRows: rowsByCatalogue.get("service_contract") ?? [],
354
+ capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
355
+ featureRepoRows: rowsByCatalogue.get("feature_repo") ?? [],
356
+ });
357
+ rederiveAppend(rowsByCatalogue, "repo_capability", repoEdges.repoCapability);
358
+ rederiveAppend(rowsByCatalogue, "repo_contract", repoEdges.repoContract);
359
+ const featureContractRows = buildFeatureContract({
360
+ findings,
361
+ capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
362
+ capabilityContractRows: rowsByCatalogue.get("capability_contract") ?? [],
363
+ featureServiceRows: rowsByCatalogue.get("feature_service") ?? [],
364
+ serviceContractRows: rowsByCatalogue.get("service_contract") ?? [],
365
+ });
366
+ rederiveAppend(rowsByCatalogue, "feature_contract", featureContractRows);
367
+ const capabilityEventRows = buildCapabilityEvent({
368
+ findings,
369
+ manifests: [],
370
+ knownCapabilityIds: new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"]))),
371
+ knownEventIds: new Set((rowsByCatalogue.get("events") ?? []).map((r) => String(r["id"]))),
372
+ capabilityServiceRows: rowsByCatalogue.get("capability_service") ?? [],
373
+ serviceEventRows: rowsByCatalogue.get("service_event") ?? [],
374
+ });
375
+ rederiveAppend(rowsByCatalogue, "capability_event", capabilityEventRows);
376
+ // 5a-iii. Archetype data-plane expectations overlay (M-ADR 0191 §4.3) — the
377
+ // deviation-visible WARN + conformance surface, run at AGGREGATION tier where
378
+ // the reference-architecture bundle's `expected_bindings` resolve (the
379
+ // ecosystem-root repo caches it). Per-SDD builds deliberately do not run this
380
+ // — an app SDD may consume an adopter context-pack, not the RA bundle.
381
+ checkDataPlaneOverlayAtAggregation({
382
+ products: rowsByCatalogue.get("products") ?? [],
383
+ bindings: rowsByCatalogue.get("application_data_plane_bindings") ?? [],
384
+ deviations: rowsByCatalogue.get("application_data_plane_deviations") ?? [],
385
+ conformanceCoverage: rowsByCatalogue.get("application_conformance_coverage") ?? [],
386
+ vocab: await discoverVocabulary(sddPath),
387
+ findings,
388
+ });
389
+ // 5a-ii. Resolve cross-SDD screen_feature edges (schema 2.24.0, R3). App/
390
+ // integration builds emit screen_feature rows carrying the bare `realises:`
391
+ // slug when the feature lives in another SDD (the platform). Rewrite those
392
+ // to the resolved platform feature id by matching the slug against every
393
+ // merged feature's slug. One slug vocabulary per capability (ADR 0177), so
394
+ // a single match is authoritative; an ambiguous estate-wide match is refused
395
+ // with a divergence finding (never guessed); no match is an orphan finding.
396
+ rowsByCatalogue.set("screen_feature", resolveCrossSddScreenFeature(rowsByCatalogue.get("screen_feature") ?? [], rowsByCatalogue.get("features") ?? [], findings));
397
+ // @rule aggregate-external-system-dedup
398
+ // 5b. Dedup c4_external_systems by id (ADR 0155). External systems are
399
+ // inherently shared references — the same external (e.g. ext-google-maps)
400
+ // may be declared by multiple SDDs. This is the one narrowly-scoped
401
+ // exception to the duplicate-PK rule: identical rows merge (first-seen
402
+ // wins); conflicting attributes on the same id surface as a finding.
403
+ dedupExternalSystems(rowsByCatalogue, findings);
404
+ // @rule aggregate-external-exemption-dedup
405
+ // 5b-i-bis. Dedup external_system_exemption by PK (M-ADR 0189 §4.6) —
406
+ // shared external entities may be exempted by multiple SDDs; identical
407
+ // assertions merge, conflicting ones keep first-seen with a finding.
408
+ dedupExternalExemptions(rowsByCatalogue, findings);
409
+ // 5b-ii. Dedup method-scoped ADRs by id. ADRs under docs/method/adr/ are the
410
+ // shared method baseline — byte-identical across every method-based SDD. When
411
+ // two or more full SDDs federate (e.g. a second platform-root joins the org
412
+ // tier alongside an existing one), each contributes the same method ADR ids. Merge
413
+ // them (first-seen wins) rather than failing the duplicate-PK check. Platform
414
+ // / domain ADRs are NOT deduped — a collision there is a genuine error.
415
+ await dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById);
416
+ // @rule aggregate-federated-reverse-metadata-enrichment
417
+ // 5c. Federated reverse-metadata enrichment (ADR 0163 P2). Both reference
418
+ // adopters centralise the service inventory in the platform-root, but for
419
+ // reverse-method federations the per-service evidence (service-architecture.md)
420
+ // lives one federation hop away, in member SDDs. The platform-root's own
421
+ // build can't see those docs, so those service rows carry empty reverse
422
+ // metadata. Here — the one place that can reach every member's repos/ —
423
+ // fill ONLY the empty reverse columns from each member's arch doc.
424
+ // Fill-if-empty: platform-root-local metadata always wins, order-independent
425
+ // across members. Cross-member slug ambiguity is refused, not guessed.
426
+ await enrichFederatedServiceMetadata(rowsByCatalogue, aggManifest.siblings, findings);
427
+ // @rule aggregate-successor-orphan
428
+ // 5d. Successor-reference orphan check (schema 2.22.0). products.successor_sdd_id
429
+ // is a soft reference in sdd_id vocabulary — resolve against the sdd_ids
430
+ // visible at this tier (direct contributors plus source_sdd_ids stamped at a
431
+ // lower tier, so org-tier re-aggregation sees through cohort roots). A miss
432
+ // is a finding, never a failure: the successor may live outside this
433
+ // aggregation's scope.
434
+ checkSuccessorReferences(rowsByCatalogue.get("products") ?? [], new Set(perSiblingCounts.keys()), findings);
435
+ // @rule aggregate-sibling-identity-duplicate
436
+ // 5e. Sibling-identity duplicate check (M-ADR 0189 §4.5.3): c4_systems
437
+ // rows sharing name+description — the syndicated-L1 overwrite class that
438
+ // PK/FK gates cannot see (the id is right, every other column is wrong).
439
+ checkDuplicateSystemIdentity(rowsByCatalogue.get("c4_systems") ?? [], findings);
440
+ // @rule aggregate-external-system-adjudication
441
+ // 5f. M-ADR 0189 §4.4 — adjudicate external-system candidates against the
442
+ // merged estate: internal-system resolutions convert to
443
+ // system_system_dependency; container resolutions are tier-contradiction
444
+ // findings; integration-kind resolutions are provider-pair collisions and
445
+ // stay external. Runs before the dup-PK/FK gates so the rewritten rows
446
+ // are what gets validated and emitted.
447
+ const adjudication = adjudicateExternalSystems(rowsByCatalogue, findings);
448
+ write(` adjudication (M-ADR 0189 §4.4): ${adjudication.converted} candidates → internal ` +
449
+ `(${adjudication.edgesRewritten} edges → system_system_dependency), ` +
450
+ `${adjudication.providerPairsKept} provider pairs kept external, ` +
451
+ `${adjudication.uncorroboratedKept} uncorroborated holds, ` +
452
+ `${adjudication.uncertainHolds} uncertain holds, ` +
453
+ `${adjudication.ambiguousHolds} ambiguous holds, ` +
454
+ `${adjudication.tierContradictions} tier contradictions asserted, ` +
455
+ `${adjudication.containerConverted} container conversions (${adjudication.containerEdgesRewritten} edges → container_dependency, ` +
456
+ `${adjudication.systemFallbackEdges} stuck edges → system_system_dependency at L1), ` +
457
+ `${adjudication.exempted} exempted, ${adjudication.unverified} unverified\n`);
458
+ // 5f-bis. Rows constructed at THIS tier after the node loader (adjudication
459
+ // conversions, re-derived edges) carry source_sdd_id but no platform stamp.
460
+ // Fill them now that every derivation has run (M-ADR 0208 §4.4 as applied to
461
+ // aggregator-constructed rows); whatever stays empty is reported, never keyed on.
462
+ fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings);
463
+ // @rule aggregate-platform-kind-uniqueness
464
+ // 5g. Platform-kind uniqueness (M-ADR 0133 §4.2c open hygiene).
465
+ checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings, rootIsOrganisation ? "organisation" : "platform");
466
+ // @rule aggregate-contract-id-rekey
467
+ // 5h. M-ADR 0203 §4.4 cross-SDD re-key: the contract-id migration derives
468
+ // per-SDD, but contract-FK edges cross SDD boundaries — a federated SDD
469
+ // that owns no contracts has an empty map, so its references keep the 2.x
470
+ // kindless ids (measured on the first rehearsal probe: 170 dangling FKs
471
+ // over 6 old ids). This tier sees every contract row, so the old form is
472
+ // decidable here and nowhere earlier.
473
+ const contractRekey = rekeyLegacyContractRefs(schema, rowsByCatalogue, findings);
474
+ // Always emitted: an absent line is indistinguishable from a check that
475
+ // never ran (M-ADR 0200 §4.3 — a zero must be affirmable). Measured: the
476
+ // first all-zero estate correctly refused to report "0" for a line that
477
+ // was never printed.
478
+ write(` contract-id cut window (M-ADR 0203 §4.4): ${contractRekey.rekeyed} cross-SDD reference(s) re-keyed, ` +
479
+ `${contractRekey.deduped} duplicate fact(s) dropped, ${contractRekey.ambiguous} ambiguous left dangling\n`);
480
+ // @rule aggregate-duplicate-primary-key
481
+ // 6. Duplicate primary key check. M-ADR 0208 §4.4 (F-D): at organisation
482
+ // tier the key of `domains` is (platform_sdd_id, id) — two platforms may
483
+ // each own `identity`. Everywhere else, and for every other catalogue,
484
+ // the scalar pk. An empty stamp is unresolved, never a key.
485
+ const platformScopedTargets = rootIsOrganisation ? PLATFORM_SCOPED_AT_ORGANISATION : [];
486
+ const duplicates = findDuplicatePrimaryKeys(schema, rowsByCatalogue, platformScopedTargets);
487
+ if (duplicates.length > 0) {
488
+ write(`\nDuplicate primary keys (${duplicates.length}) — aggregation fails:\n`);
489
+ for (const d of duplicates) {
490
+ write(` ${d.catalogue}.${d.primaryKey}="${d.value}" contributed by [${d.sources.join(", ")}]\n`);
491
+ }
492
+ return finalise({
493
+ schema,
494
+ ecosystemRoot: rootManifest.sddId,
495
+ aggManifest,
496
+ rowsByCatalogue,
497
+ perSiblingCounts,
498
+ aggregatedFkPass: false,
499
+ findings,
500
+ outDir: resolveOutDir(sddPath, inputs.outDir),
501
+ manifestPath,
502
+ writeOutputs: false,
503
+ duplicateCount: duplicates.length,
504
+ write,
505
+ strict: inputs.strict === true,
506
+ });
507
+ }
508
+ // 7. Full FK validation, treating external as internal.
509
+ const { fk: fkRaw } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { treatExternalAsInternal: true, platformScopedTargets },
510
+ // The aggregation tier has never emitted enum findings; the cutover
511
+ // changes no tier's finding surface (M-ADR 0205 §4.4).
512
+ { logEnumFindings: false });
513
+ // @rule aggregate-tier-aware-external-deferral
514
+ // 7a. Tier-aware external resolution (M-ADR 0134 §4.3 as read under M-ADR
515
+ // 0208's organisation tier). 0134 wrote "the aggregator validates external
516
+ // FKs after merge" for a world with ONE aggregate. With an organisation tier
517
+ // above the platform-roots, a platform-root's OWN dependency on something it
518
+ // does not own — a row SOURCED BY THE ROOT ITSELF whose external FK dangles
519
+ // at its own platform-tier aggregate — is a cross-platform dependency by
520
+ // construction: no sibling at this tier can hold the target, and the tier
521
+ // that can is the organisation root. Such a row is deferred there as an
522
+ // `external` finding, not failed here. Rows sourced by MEMBERS still fail:
523
+ // their targets are the platform's own and are supposed to be present. At
524
+ // organisation tier nothing is deferred — there is no tier above.
525
+ // Measured shape: a packaged-SaaS platform-root depending on three of the
526
+ // reference platform's capabilities through two of its services — 4 dangling
527
+ // at its own aggregate, all present at the organisation root.
528
+ const fk = rootIsOrganisation
529
+ ? fkRaw
530
+ : deferRootSourcedCrossPlatformExternals(schema, rowsByCatalogue, fkRaw, rootManifest.sddId, findings, write);
531
+ // 7b. M-ADR 0208 §4.4 (F-D): report the domain-key state at organisation
532
+ // tier. Always emitted there — an absent line is indistinguishable from a
533
+ // check that never ran (M-ADR 0200 §4.3).
534
+ if (rootIsOrganisation) {
535
+ checkPlatformScopedKeyAmbiguity(schema, rowsByCatalogue, platformScopedTargets, findings, write);
536
+ }
537
+ // 8. Emit aggregated catalogues + aggregation-manifest.json.
538
+ const outDir = resolveOutDir(sddPath, inputs.outDir);
539
+ const rowCounts = new Map();
540
+ for (const cat of schema.catalogues) {
541
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
542
+ const result = await writeCatalogue({ outDir, catalogue: cat, rows });
543
+ rowCounts.set(cat.name, result.rowCount);
544
+ // A preserved file is data this build could not reproduce. Saying nothing
545
+ // would move the silence rather than close it: the previous behaviour was
546
+ // also silent, and that is why 26 SDDs lost their conformance evidence
547
+ // while their registries went on citing it.
548
+ if (result.preserved) {
549
+ findings.add("convention", `catalogues/${cat.name}.csv: this build produced NO rows for it, and the file on disk ` +
550
+ `holds ${result.preservedRows}. The existing file was LEFT UNTOUCHED rather than ` +
551
+ `replaced with a bare header. Either the catalogue has no producer in this CLI — in ` +
552
+ `which case its rows are authored data, not build output, and nothing here will ever ` +
553
+ `regenerate them — or a builder that used to emit rows has stopped. Both are worth ` +
554
+ `resolving; neither is worth deleting the rows over.`);
555
+ }
556
+ }
557
+ await writeAggregationManifestJson({
558
+ outDir,
559
+ schemaVersion: schema.schemaVersion,
560
+ ecosystemRoot: rootManifest.sddId,
561
+ siblings: aggManifest.siblings,
562
+ perSiblingCounts,
563
+ aggregatedRowCounts: rowCounts,
564
+ cliVersion: CLI_VERSION,
565
+ });
566
+ // Report.
567
+ write("\nAggregated row counts:\n");
568
+ for (const [name, count] of rowCounts) {
569
+ if (count > 0)
570
+ write(` ${name}: ${count}\n`);
571
+ }
572
+ const emptyCount = [...rowCounts.values()].filter((c) => c === 0).length;
573
+ write(` (${emptyCount} headers-only)\n`);
574
+ const findingsText = findings.render();
575
+ if (findingsText)
576
+ write(`\n${findingsText}\n`);
577
+ if (fk.internalPass) {
578
+ write(`\nAggregated FK validation: PASS (${validatedPopulation(rowsByCatalogue)})\n`);
579
+ }
580
+ else {
581
+ write(`\nAggregated FK validation: FAIL (${fk.internalFailures.length} dangling)\n`);
582
+ for (const f of fk.internalFailures) {
583
+ const sourceSdd = findSourceSdd(rowsByCatalogue, f.catalogue, f.referencingRow);
584
+ write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}` +
585
+ (sourceSdd ? ` (from ${sourceSdd})\n` : "\n"));
586
+ }
587
+ }
588
+ const strictFail = inputs.strict === true && findings.count() > 0;
589
+ const exitCode = !fk.internalPass || strictFail ? 1 : 0;
590
+ return {
591
+ exitCode,
592
+ schemaVersion: schema.schemaVersion,
593
+ ecosystemRoot: rootManifest.sddId,
594
+ siblingCount: aggManifest.siblings.length,
595
+ rowCounts,
596
+ perSiblingCounts,
597
+ aggregatedFkPass: fk.internalPass,
598
+ findingsCount: findings.count(),
599
+ outDir,
600
+ };
601
+ }
602
+ // ADR 0155: c4_external_systems rows dedup by id across SDDs rather than
603
+ // failing the duplicate-PK check. First-seen row wins; a `source_sdd_id`
604
+ // already stamped is preserved. Conflicting name/technology/description on
605
+ // the same id emits a `convention` finding so authors can reconcile.
606
+ function dedupExternalExemptions(rowsByCatalogue, findings) {
607
+ const rows = rowsByCatalogue.get("external_system_exemption");
608
+ if (rows === undefined || rows.length === 0)
609
+ return;
610
+ const byId = new Map();
611
+ for (const row of rows) {
612
+ const id = String(row["id"] ?? "");
613
+ if (id === "")
614
+ continue;
615
+ const existing = byId.get(id);
616
+ if (existing === undefined) {
617
+ byId.set(id, row);
618
+ continue;
619
+ }
620
+ if (String(existing["reason"]) !== String(row["reason"]) ||
621
+ String(existing["note"]) !== String(row["note"])) {
622
+ findings.add("convention", `external_system_exemption: "${id}" asserted with conflicting reason/note across SDDs — first-seen kept`);
623
+ }
624
+ }
625
+ rowsByCatalogue.set("external_system_exemption", [...byId.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"]))));
626
+ }
627
+ // M-ADR 0198 §4.2/§4.3: append a re-derived catalogue's rows to whatever concatenation
628
+ // already produced for it, keeping the concatenated row on any primary-key collision. One
629
+ // shared mechanism for every re-derived catalogue, so adding a fifth is a call to this
630
+ // function with that catalogue's builder output, not new merge logic.
631
+ function rederiveAppend(rowsByCatalogue, catalogueName, rederivedRows) {
632
+ const concatenated = rowsByCatalogue.get(catalogueName) ?? [];
633
+ const existingIds = new Set(concatenated.map((r) => String(r["id"])));
634
+ const added = rederivedRows.filter((r) => !existingIds.has(String(r["id"])));
635
+ rowsByCatalogue.set(catalogueName, [...concatenated, ...added]);
636
+ }
637
+ export function dedupExternalSystems(rowsByCatalogue, findings) {
638
+ const rows = rowsByCatalogue.get("c4_external_systems");
639
+ if (rows === undefined || rows.length === 0)
640
+ return;
641
+ const byId = new Map();
642
+ const namesById = new Map();
643
+ for (const row of rows) {
644
+ const id = String(row["id"] ?? "");
645
+ if (id === "")
646
+ continue;
647
+ const name = String(row["name"] ?? "");
648
+ const names = namesById.get(id);
649
+ if (names) {
650
+ if (name !== "" && !names.includes(name))
651
+ names.push(name);
652
+ }
653
+ else {
654
+ namesById.set(id, name === "" ? [] : [name]);
655
+ }
656
+ const existing = byId.get(id);
657
+ if (existing === undefined) {
658
+ byId.set(id, row);
659
+ continue;
660
+ }
661
+ for (const attr of ["technology", "description"]) {
662
+ const a = String(existing[attr] ?? "");
663
+ const b = String(row[attr] ?? "");
664
+ if (a !== "" && b !== "" && a !== b) {
665
+ findings.add("convention", `c4_external_systems: id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
666
+ }
667
+ }
668
+ }
669
+ // M-ADR 0203 §4.2's second fallout detector — the mirror image of the
670
+ // same-name split. With identity now the DECLARED entry id, a generic
671
+ // diagram-local mnemonic shared across SDDs merges DIFFERENT real-world
672
+ // systems into one row (measured before this shipped: 17 integration
673
+ // SDDs each declaring their provider under one shared mnemonic — 17
674
+ // providers, one surviving row). A merge whose display names corroborate
675
+ // (compact-equal or token-subset — benign label variance on one
676
+ // referent) stays the quiet dedup this catalogue is designed for; a
677
+ // merge whose names DON'T corroborate is likely N referents under one
678
+ // entry id, and every hidden referent is named. Rename the diagram entry
679
+ // ids apart — a diagram edit, the M-ADR 0189 §4.2 sanctioned opt-out.
680
+ const compactName = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
681
+ const nameTokens = (s) => new Set(s
682
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
683
+ .toLowerCase()
684
+ .split(/[^a-z0-9]+/)
685
+ .filter((t) => t !== ""));
686
+ const namesCorroborate = (a, b) => {
687
+ if (compactName(a) === compactName(b))
688
+ return true;
689
+ const ta = nameTokens(a);
690
+ const tb = nameTokens(b);
691
+ const subset = (x, y) => x.size > 0 && [...x].every((t) => y.has(t));
692
+ return subset(ta, tb) || subset(tb, ta);
693
+ };
694
+ for (const [id, names] of namesById) {
695
+ if (names.length < 2)
696
+ continue;
697
+ const anchor = names[0];
698
+ const strangers = names.slice(1).filter((n) => !namesCorroborate(anchor, n));
699
+ if (strangers.length === 0) {
700
+ // Corroborating label variance — keep the plain per-attr conflict
701
+ // shape the pre-3.0.0 dedup reported.
702
+ findings.add("convention", `c4_external_systems: id "${id}" has conflicting name across SDDs [${names.join(", ")}] — first-seen kept`);
703
+ continue;
704
+ }
705
+ findings.add("divergence", `c4_external_systems: declared id "${id}" merges ${names.length} rows whose display names do not corroborate ` +
706
+ `[${names.join(" | ")}] — likely DIFFERENT systems under one shared diagram entry id; ` +
707
+ `first-seen kept and ${names.length - 1} referent(s) are hidden by this merge. ` +
708
+ `Rename the diagram entry ids apart (M-ADR 0203 §4.2 / M-ADR 0189 §4.2)`);
709
+ }
710
+ rowsByCatalogue.set("c4_external_systems", [...byId.values()]);
711
+ }
712
+ // Method-DISTRIBUTED ADRs are the shared method baseline, identical across
713
+ // method-based SDDs. Dedupe by id (first-seen wins), preserving order and leaving
714
+ // adopter-AUTHORED platform/domain ADRs untouched — a collision there is a genuine
715
+ // error and MUST still fail the duplicate-PK check. Mirrors dedupExternalSystems.
716
+ //
717
+ // WHAT COUNTS AS METHOD-DISTRIBUTED (M-ADR 0208 / gov F-E): a row whose doc_path is
718
+ // BUNDLE-MANAGED in the contributing SDD's method-baseline.lock. Provenance, not
719
+ // path prefix. The earlier test `startsWith("docs/method/adr/")` was a proxy for
720
+ // the same property and it failed the first time two platform-roots aggregated:
721
+ // the baseline also distributes docs/method/platform-adr/ (13 ADR-emitting files
722
+ // in platform-baseline v2.3.28), which the prefix missed, so identical
723
+ // bundle-managed rows from two roots collided as if adopter-authored. A prefix
724
+ // extension would have passed every test available that day and merged genuinely
725
+ // authored platform ADRs the next time an adopter wrote a colliding one; the lock
726
+ // already records exactly the property we mean.
727
+ //
728
+ // FALLBACK: a contributing SDD with no lock (fixtures; a repo that has never been
729
+ // synced) keeps the historical prefix test, so it is never LESS deduped than before.
730
+ // @rule aggregate-method-adr-dedup
731
+ async function dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById) {
732
+ const rows = rowsByCatalogue.get("adrs");
733
+ if (rows === undefined || rows.length === 0)
734
+ return;
735
+ // source_sdd_id -> set of bundle-managed paths, or null when that SDD has no lock.
736
+ const managedBySdd = new Map();
737
+ for (const [sddId, root] of sddRootsById) {
738
+ const lock = await readLockFile(root);
739
+ if (lock === null) {
740
+ managedBySdd.set(sddId, null);
741
+ continue;
742
+ }
743
+ const managed = new Set();
744
+ for (const b of lock.bundles)
745
+ for (const mp of b.managedPaths)
746
+ managed.add(mp);
747
+ managedBySdd.set(sddId, managed);
748
+ }
749
+ const isMethodDistributed = (row) => {
750
+ const docPath = String(row["doc_path"] ?? "");
751
+ if (docPath === "")
752
+ return false;
753
+ const managed = managedBySdd.get(String(row["source_sdd_id"] ?? ""));
754
+ if (managed === undefined || managed === null) {
755
+ // No lock for this contributor: historical prefix test.
756
+ return docPath.startsWith("docs/method/adr/");
757
+ }
758
+ return managed.has(docPath);
759
+ };
760
+ const seen = new Map();
761
+ const out = [];
762
+ for (const row of rows) {
763
+ const id = String(row["id"] ?? "");
764
+ if (!isMethodDistributed(row) || id === "") {
765
+ out.push(row);
766
+ continue;
767
+ }
768
+ const existing = seen.get(id);
769
+ if (existing === undefined) {
770
+ seen.set(id, row);
771
+ out.push(row);
772
+ continue;
773
+ }
774
+ for (const attr of ["title", "status"]) {
775
+ const a = String(existing[attr] ?? "");
776
+ const b = String(row[attr] ?? "");
777
+ if (a !== "" && b !== "" && a !== b) {
778
+ findings.add("convention", `adrs: method-distributed id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
779
+ }
780
+ }
781
+ // duplicate method-distributed ADR — already kept first-seen; drop this copy.
782
+ }
783
+ rowsByCatalogue.set("adrs", out);
784
+ }
785
+ // ADR 0163 P2: the reverse-derived metadata columns that a member SDD's
786
+ // service-architecture.md can backfill onto a platform-root service row.
787
+ const FEDERATED_META_COLS = [
788
+ "runtime_type",
789
+ "language",
790
+ "status",
791
+ "confidence",
792
+ "description",
793
+ ];
794
+ /**
795
+ * Backfill empty reverse-metadata columns on the aggregated `services`
796
+ * rows from member SDDs' `service-architecture.md` docs.
797
+ *
798
+ * Mechanism (Option 1 per the coordination analysis — no schema change):
799
+ * the aggregator already knows each sibling's repo root (`sib.sddRoot`),
800
+ * so it indexes `<sddRoot>/repos/**​/docs/service-architecture.md` per
801
+ * member and matches by repo slug == service id. Only cells that are empty
802
+ * after the per-SDD builds are filled (platform-root-local metadata is never
803
+ * overwritten), so the pass is idempotent and order-independent.
804
+ *
805
+ * Safety: a slug whose arch doc exists in more than one member is
806
+ * **ambiguous** — we cannot know which doc describes the single inventory
807
+ * row — so it is skipped with a finding rather than guessed.
808
+ */
809
+ async function enrichFederatedServiceMetadata(rowsByCatalogue, siblings, findings) {
810
+ const services = rowsByCatalogue.get("services");
811
+ if (services === undefined || services.length === 0)
812
+ return;
813
+ const hasEmptyCell = (row) => FEDERATED_META_COLS.some((c) => String(row[c] ?? "") === "");
814
+ // Service ids that still want metadata after the per-SDD builds.
815
+ const needed = new Set();
816
+ for (const row of services) {
817
+ if (!hasEmptyCell(row))
818
+ continue;
819
+ const id = String(row["id"] ?? "");
820
+ if (id !== "")
821
+ needed.add(id);
822
+ }
823
+ if (needed.size === 0)
824
+ return;
825
+ // Index each member's arch docs, but keep only slugs we actually need.
826
+ // A slug → the members whose repos/ carry an arch doc for it (collision
827
+ // detection lives in the >1 case below).
828
+ const bySlug = new Map();
829
+ for (const sib of siblings) {
830
+ let index;
831
+ try {
832
+ index = await indexServiceArchitectures(sib.sddRoot);
833
+ }
834
+ catch {
835
+ continue; // member without a repos/ tree contributes nothing
836
+ }
837
+ for (const [slug, archPath] of index) {
838
+ if (!needed.has(slug))
839
+ continue;
840
+ const list = bySlug.get(slug) ?? [];
841
+ list.push({ sddId: sib.sddId, archPath });
842
+ bySlug.set(slug, list);
843
+ }
844
+ }
845
+ if (bySlug.size === 0)
846
+ return;
847
+ const metaCache = new Map();
848
+ const enrichingMembers = new Set();
849
+ let rowsEnriched = 0;
850
+ let cellsFilled = 0;
851
+ const enriched = [];
852
+ for (const row of services) {
853
+ const id = String(row["id"] ?? "");
854
+ const members = id !== "" ? bySlug.get(id) : undefined;
855
+ if (members === undefined || !hasEmptyCell(row)) {
856
+ enriched.push(row);
857
+ continue;
858
+ }
859
+ if (members.length > 1) {
860
+ findings.add("convention", `services: federated metadata for "${id}" is ambiguous — ` +
861
+ `service-architecture.md exists in ${members.length} member SDDs ` +
862
+ `[${members.map((m) => m.sddId).join(", ")}]; enrichment skipped`);
863
+ enriched.push(row);
864
+ continue;
865
+ }
866
+ const { sddId, archPath } = members[0];
867
+ let meta = metaCache.get(archPath);
868
+ if (meta === undefined) {
869
+ meta = await readServiceMetadata(archPath);
870
+ metaCache.set(archPath, meta);
871
+ }
872
+ const next = {
873
+ ...row,
874
+ };
875
+ let filledThisRow = false;
876
+ for (const col of FEDERATED_META_COLS) {
877
+ if (String(row[col] ?? "") === "" && meta[col] !== "") {
878
+ next[col] = meta[col];
879
+ cellsFilled++;
880
+ filledThisRow = true;
881
+ }
882
+ }
883
+ if (filledThisRow) {
884
+ rowsEnriched++;
885
+ enrichingMembers.add(sddId);
886
+ }
887
+ enriched.push(filledThisRow ? next : row);
888
+ }
889
+ if (rowsEnriched > 0) {
890
+ const stillEmpty = enriched.filter(hasEmptyCell).length;
891
+ findings.add("coverage", `services: federated reverse-metadata enrichment filled ${cellsFilled} ` +
892
+ `empty cell(s) across ${rowsEnriched} service row(s) from ` +
893
+ `${enrichingMembers.size} member SDD(s); ${stillEmpty} row(s) still ` +
894
+ `carry empty reverse metadata`);
895
+ rowsByCatalogue.set("services", enriched);
896
+ }
897
+ }
898
+ /**
899
+ * M-ADR 0208 §4.4: the catalogues whose organisation-tier key is
900
+ * `(platform_sdd_id, <pk>)`. Only `domains` — a domain is a bounded context
901
+ * OF a platform, so the pair is its natural key; every other catalogue's ids
902
+ * are already platform-qualified by construction or are method-scoped.
903
+ */
904
+ import { PLATFORM_SCOPED_AT_ORGANISATION } from "./platform-scope.js";
905
+ import { deriveFeatureIntegrationSupport } from "./data-item-coverage.js";
906
+ export { PLATFORM_SCOPED_AT_ORGANISATION };
907
+ export function findDuplicatePrimaryKeys(schema, rowsByCatalogue, platformScopedTargets = []) {
908
+ const out = [];
909
+ for (const cat of schema.catalogues) {
910
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
911
+ // Scoped iff asked for AND the catalogue carries the stamp column.
912
+ const scoped = isPlatformScopedFk(cat, cat, { platformScopedTargets });
913
+ const byKey = new Map();
914
+ const stampsByKey = new Map();
915
+ for (const row of rows) {
916
+ const pk = row[cat.primaryKey];
917
+ if (pk === undefined || pk === null || pk === "")
918
+ continue;
919
+ const value = String(pk);
920
+ const sourceSdd = String(row["source_sdd_id"] ?? "");
921
+ const list = byKey.get(value) ?? [];
922
+ list.push(sourceSdd || "<unknown>");
923
+ byKey.set(value, list);
924
+ if (scoped) {
925
+ const stamps = stampsByKey.get(value) ?? [];
926
+ stamps.push(String(row[PLATFORM_STAMP_COLUMN] ?? "").trim());
927
+ stampsByKey.set(value, stamps);
928
+ }
929
+ }
930
+ for (const [value, sources] of byKey) {
931
+ if (sources.length > 1 && scoped) {
932
+ // n rows are distinct under (stamp, pk) iff every stamp is present
933
+ // and all differ. An EMPTY stamp is unresolved, never a key: it
934
+ // collides with everything (§4.4 — "two children contributing
935
+ // (∅, identity) must produce a finding, not a silent merge").
936
+ // Mirrors ddl-generator pkQuery's HAVING clause.
937
+ const stamps = stampsByKey.get(value) ?? [];
938
+ const distinctNonEmpty = new Set(stamps.filter((st) => st !== "")).size;
939
+ if (distinctNonEmpty === stamps.length)
940
+ continue;
941
+ out.push({
942
+ catalogue: cat.name,
943
+ primaryKey: `(${PLATFORM_STAMP_COLUMN}, ${cat.primaryKey})`,
944
+ value: `${value} [stamps: ${stamps.map((st) => st || "∅").join(", ")}] — an empty stamp is unresolved, never a key (M-ADR 0208 §4.4)`,
945
+ sources,
946
+ });
947
+ continue;
948
+ }
949
+ if (sources.length > 1) {
950
+ out.push({
951
+ catalogue: cat.name,
952
+ primaryKey: cat.primaryKey,
953
+ value,
954
+ sources,
955
+ });
956
+ }
957
+ }
958
+ }
959
+ return out;
960
+ }
961
+ /**
962
+ * M-ADR 0208 §4.4 (F-D), organisation tier. For each platform-scoped
963
+ * catalogue: which pks are owned by more than one platform, and how many
964
+ * referencing rows carry such a pk with NO stamp of their own — those
965
+ * references resolve by scalar fallback (the FK check cannot fail them
966
+ * without inventing a platform) but they are AMBIGUOUS, and this says so.
967
+ * One line per scoped catalogue, always written; a coverage finding only
968
+ * when ambiguity exists. Fires zero on an estate where no id is shared.
969
+ */
970
+ export function checkPlatformScopedKeyAmbiguity(schema, rowsByCatalogue, platformScopedTargets, findings, write) {
971
+ for (const target of schema.catalogues) {
972
+ if (!isPlatformScopedFk(target, target, { platformScopedTargets }))
973
+ continue;
974
+ const owners = new Map();
975
+ for (const row of rowsByCatalogue.get(target.name) ?? []) {
976
+ const pk = String(row[target.primaryKey] ?? "");
977
+ if (pk === "")
978
+ continue;
979
+ const set = owners.get(pk) ?? new Set();
980
+ set.add(String(row[PLATFORM_STAMP_COLUMN] ?? "").trim() || "∅");
981
+ owners.set(pk, set);
982
+ }
983
+ const shared = new Set([...owners].filter(([, o]) => o.size > 1).map(([pk]) => pk));
984
+ let ambiguous = 0;
985
+ const where = [];
986
+ for (const cat of schema.catalogues) {
987
+ for (const fk of cat.foreignKeys) {
988
+ if (fk.references.split(".")[0] !== target.name)
989
+ continue;
990
+ if (!isPlatformScopedFk(cat, target, { platformScopedTargets }))
991
+ continue;
992
+ let n = 0;
993
+ for (const row of rowsByCatalogue.get(cat.name) ?? []) {
994
+ const v = String(row[fk.column] ?? "");
995
+ if (v === "" || !shared.has(v))
996
+ continue;
997
+ if (String(row[PLATFORM_STAMP_COLUMN] ?? "").trim() === "")
998
+ n++;
999
+ }
1000
+ if (n > 0) {
1001
+ ambiguous += n;
1002
+ where.push(`${cat.name}.${fk.column}=${n}`);
1003
+ }
1004
+ }
1005
+ }
1006
+ write(` ${target.name} key at organisation tier is (${PLATFORM_STAMP_COLUMN}, ${target.primaryKey}) (M-ADR 0208 §4.4): ` +
1007
+ `${shared.size} id(s) owned by more than one platform, ${ambiguous} unstamped referencing row(s) ambiguous\n`);
1008
+ if (ambiguous > 0) {
1009
+ findings.add("coverage", `${target.name}: ${ambiguous} referencing row(s) with EMPTY ${PLATFORM_STAMP_COLUMN} name an id owned by more than one platform [${[...shared].sort().join(", ")}] (${where.join(", ")}) — resolved by scalar fallback, AMBIGUOUS until the row is stamped (M-ADR 0208 §4.4)`);
1010
+ }
1011
+ }
1012
+ }
1013
+ async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId,
1014
+ /** M-ADR 0208 §4.4: value to fill into `platform_sdd_id` when empty; null = do not fill. */
1015
+ platformSddId) {
1016
+ const counts = new Map();
1017
+ let dirExists = true;
1018
+ try {
1019
+ const s = await stat(cataloguesDir);
1020
+ if (!s.isDirectory())
1021
+ dirExists = false;
1022
+ }
1023
+ catch (err) {
1024
+ if (err.code === "ENOENT")
1025
+ dirExists = false;
1026
+ else
1027
+ throw err;
1028
+ }
1029
+ if (!dirExists) {
1030
+ throw new Error(`catalogues/ directory not found at ${cataloguesDir}\n` +
1031
+ ` Run \`sdd-cli catalogue build\` in that SDD before aggregating.`);
1032
+ }
1033
+ for (const cat of schema.catalogues) {
1034
+ const filePath = join(cataloguesDir, `${cat.name}.csv`);
1035
+ let parsed;
1036
+ try {
1037
+ parsed = await readCatalogueCsv(filePath);
1038
+ }
1039
+ catch (err) {
1040
+ if (err.code === "ENOENT") {
1041
+ counts.set(cat.name, 0);
1042
+ continue;
1043
+ }
1044
+ throw err;
1045
+ }
1046
+ const stamped = stampColumn(cat, stampColumn(cat, parsed.rows, "source_sdd_id", sourceSddId), "platform_sdd_id", platformSddId);
1047
+ rowsByCatalogue.get(cat.name).push(...stamped);
1048
+ counts.set(cat.name, stamped.length);
1049
+ }
1050
+ return counts;
1051
+ }
1052
+ /**
1053
+ * Stamp `column` = `value` on every row that does not already carry one
1054
+ * (preserve-if-set — chained aggregation, M-ADR 0134 §4.5 step 4). Used for
1055
+ * `source_sdd_id` and, per M-ADR 0208 §4.4, for `platform_sdd_id`. A null value
1056
+ * means "do not fill" (organisation tier, where the stamp arrived from below or
1057
+ * is legitimately unresolved). No-op when the catalogue lacks the column.
1058
+ */
1059
+ function stampColumn(catalogue, rows, column, value) {
1060
+ const hasColumn = catalogue.columns.some((c) => c.name === column);
1061
+ if (!hasColumn || value === null)
1062
+ return rows.map((r) => ({ ...r }));
1063
+ return rows.map((r) => {
1064
+ if (String(r[column] ?? "").trim() !== "")
1065
+ return { ...r };
1066
+ return { ...r, [column]: value };
1067
+ });
1068
+ }
1069
+ async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema, write) {
1070
+ const findings = new FindingsLog();
1071
+ const siblingProfiles = new Map();
1072
+ const siblingCategories = new Map();
1073
+ const siblingDeclaredPlatform = new Map();
1074
+ const deprecatedPlatformField = new Set();
1075
+ for (const sib of siblings) {
1076
+ let sibProfile;
1077
+ if (sib.level === "organisation") {
1078
+ // Organisation-level sibling: pre-built catalogues-aggregated/, no manifest.
1079
+ sibProfile = "organisation";
1080
+ }
1081
+ else {
1082
+ // Platform / app / integration: require manifest and cross-check sdd_id.
1083
+ let sibManifest;
1084
+ try {
1085
+ sibManifest = await loadSddManifest(sib.sddRoot, {
1086
+ cliVersion: CLI_VERSION,
1087
+ });
1088
+ }
1089
+ catch (err) {
1090
+ throw new Error(`aggregator: sibling "${sib.sddId}" at ${relative(ecosystemRootPath, sib.sddRoot)}\n` +
1091
+ ` failed to load .sdd/sdd-manifest.yaml: ${err.message}`);
1092
+ }
1093
+ if (sibManifest.sddId !== sib.sddId) {
1094
+ throw new Error(`aggregator: sibling "${sib.sddId}" in aggregation manifest\n` +
1095
+ ` but its own .sdd/sdd-manifest.yaml declares sdd_id="${sibManifest.sddId}"\n` +
1096
+ ` (rule: sibling_id_matches_sibling_manifest)`);
1097
+ }
1098
+ sibProfile = sibManifest.profile;
1099
+ siblingCategories.set(sib.sddId, sibManifest.category);
1100
+ if (sibManifest.platformSddId)
1101
+ siblingDeclaredPlatform.set(sib.sddId, sibManifest.platformSddId);
1102
+ if (sibManifest.platformFieldDeprecated)
1103
+ deprecatedPlatformField.add(sib.sddId);
1104
+ }
1105
+ siblingProfiles.set(sib.sddId, sibProfile);
1106
+ // Cross-check sibling's vendored catalogue-schema.yaml against the
1107
+ // declared compat range. The aggregator's own canonical schema is the
1108
+ // source of truth; the sibling's vendored copy is informational and
1109
+ // only fails the cross-check if it falls outside SCHEMA_COMPAT (i.e.
1110
+ // a major-version mismatch). Minor-version drift inside `^X.Y.Z` is
1111
+ // tolerated — sibling at 2.1.0 against aggregator at 2.2.0 is fine
1112
+ // because both satisfy ^2.0.0.
1113
+ const sibSchemaPath = await findVendoredSchema(sib.sddRoot);
1114
+ if (sibSchemaPath !== "") {
1115
+ const text = await readFile(sibSchemaPath, "utf-8");
1116
+ const m = /^schema_version:\s*"?([0-9.]+)"?/m.exec(text);
1117
+ const sibVersion = m?.[1] ?? "";
1118
+ if (sibVersion !== "" && !isInRange(sibVersion, SCHEMA_COMPAT.catalogue_schema)) {
1119
+ throw new Error(`aggregator: sibling "${sib.sddId}" vendored catalogue-schema.yaml\n` +
1120
+ ` is at ${sibVersion}, outside the declared compat range\n` +
1121
+ ` ${SCHEMA_COMPAT.catalogue_schema} (aggregator at ${aggregatorSchema.schemaVersion}).\n` +
1122
+ ` Major-version mismatch — upgrade sdd-cli or sync the sibling\n` +
1123
+ ` against a matching forward-baseline (rule: schema_version_alignment).`);
1124
+ }
1125
+ }
1126
+ write(` ✓ ${sib.sddId} (${sibProfile})\n`);
1127
+ }
1128
+ return { findings, siblingProfiles, siblingCategories, siblingDeclaredPlatform, deprecatedPlatformField };
1129
+ }
1130
+ async function findVendoredSchema(sddRoot) {
1131
+ // Adopters typically vendor canonical schema indirectly via sdd-cli.
1132
+ // Some SDDs pin a copy at docs/method/methods/catalogue-schema.yaml.
1133
+ for (const candidate of [
1134
+ "docs/method/methods/catalogue-schema.yaml",
1135
+ ]) {
1136
+ const path = join(sddRoot, candidate);
1137
+ try {
1138
+ const s = await stat(path);
1139
+ if (s.isFile())
1140
+ return path;
1141
+ }
1142
+ catch {
1143
+ // not present — try next
1144
+ }
1145
+ }
1146
+ return "";
1147
+ }
1148
+ function resolveOutDir(sddPath, override) {
1149
+ if (override !== undefined)
1150
+ return resolve(override);
1151
+ return join(sddPath, "catalogues-aggregated");
1152
+ }
1153
+ /**
1154
+ * Platform tier only: reclassify dangling references on EXTERNAL FKs whose
1155
+ * referencing row is sourced by the platform-root itself as deferred to the
1156
+ * organisation tier. Returns the FK result with those failures removed and an
1157
+ * `external` finding per deferred reference. See step 7a for the reasoning.
1158
+ */
1159
+ function deferRootSourcedCrossPlatformExternals(schema, rowsByCatalogue, fk, rootSddId, findings, write) {
1160
+ if (fk.internalFailures.length === 0)
1161
+ return fk;
1162
+ const externalByCatalogue = new Map();
1163
+ for (const cat of schema.catalogues) {
1164
+ const ext = new Set(cat.foreignKeys.filter((f) => f.external).map((f) => f.column));
1165
+ if (ext.size > 0)
1166
+ externalByCatalogue.set(cat.name, ext);
1167
+ }
1168
+ const kept = [];
1169
+ let deferred = 0;
1170
+ for (const f of fk.internalFailures) {
1171
+ const isExternal = externalByCatalogue.get(f.catalogue)?.has(f.column) === true;
1172
+ const source = findSourceSdd(rowsByCatalogue, f.catalogue, f.referencingRow);
1173
+ if (isExternal && f.value !== "" && source === rootSddId) {
1174
+ deferred++;
1175
+ findings.add("external", `${f.catalogue}.${f.column} → ${f.target}=${f.value} (referencing row pk=${f.referencingRow}, sourced by the platform-root itself — a cross-platform dependency; resolves at organisation tier, M-ADR 0208)`);
1176
+ continue;
1177
+ }
1178
+ kept.push(f);
1179
+ }
1180
+ if (deferred > 0) {
1181
+ write(` cross-platform externals deferred to organisation tier: ${deferred} (root-sourced rows on external FKs)\n`);
1182
+ }
1183
+ return { internalPass: kept.length === 0, internalFailures: kept, externalFindings: fk.externalFindings + deferred };
1184
+ }
1185
+ function findSourceSdd(rowsByCatalogue, catalogue, pkValue) {
1186
+ const rows = rowsByCatalogue.get(catalogue) ?? [];
1187
+ for (const row of rows) {
1188
+ if (String(row["id"] ?? "") === pkValue) {
1189
+ const src = String(row["source_sdd_id"] ?? "");
1190
+ return src;
1191
+ }
1192
+ }
1193
+ return "";
1194
+ }
1195
+ async function writeAggregationManifestJson(inputs) {
1196
+ const out = {
1197
+ schema_version: inputs.schemaVersion,
1198
+ cli_version: inputs.cliVersion,
1199
+ ecosystem_root: inputs.ecosystemRoot,
1200
+ siblings: inputs.siblings.map((s) => ({
1201
+ sdd_id: s.sddId,
1202
+ description: s.description,
1203
+ catalogues_path: s.relativeCataloguesPath,
1204
+ pre_merge_row_counts: counts(inputs.perSiblingCounts.get(s.sddId)),
1205
+ })),
1206
+ ecosystem_root_pre_merge_row_counts: counts(inputs.perSiblingCounts.get(inputs.ecosystemRoot)),
1207
+ aggregated_row_counts: counts(inputs.aggregatedRowCounts),
1208
+ };
1209
+ await writeFile(join(inputs.outDir, "aggregation-manifest.json"), JSON.stringify(out, null, 2) + "\n", "utf-8");
1210
+ }
1211
+ function counts(map) {
1212
+ const out = {};
1213
+ if (!map)
1214
+ return out;
1215
+ for (const [k, v] of [...map.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1216
+ out[k] = v;
1217
+ }
1218
+ return out;
1219
+ }
1220
+ async function finalise(inputs) {
1221
+ const rowCounts = new Map();
1222
+ for (const cat of inputs.schema.catalogues) {
1223
+ rowCounts.set(cat.name, (inputs.rowsByCatalogue.get(cat.name) ?? []).length);
1224
+ }
1225
+ return {
1226
+ exitCode: 1,
1227
+ schemaVersion: inputs.schema.schemaVersion,
1228
+ ecosystemRoot: inputs.ecosystemRoot,
1229
+ siblingCount: inputs.aggManifest.siblings.length,
1230
+ rowCounts,
1231
+ perSiblingCounts: inputs.perSiblingCounts,
1232
+ aggregatedFkPass: inputs.aggregatedFkPass,
1233
+ findingsCount: inputs.findings.count(),
1234
+ outDir: inputs.outDir,
1235
+ };
1236
+ }
1237
+ /**
1238
+ * Recover a feature's slug from its catalogue row. `features.id` is
1239
+ * `{capability_id|product_id}-{slug}` (per the features builder), and both
1240
+ * prefixes are columns — so the slug is the id with the owning prefix stripped.
1241
+ * Falls back to the full id when no prefix is recoverable.
1242
+ */
1243
+ function featureSlugOf(row) {
1244
+ const id = String(row["id"] ?? "");
1245
+ const cap = String(row["capability_id"] ?? "");
1246
+ const prod = String(row["product_id"] ?? "");
1247
+ const prefix = cap !== "" ? cap : prod;
1248
+ if (prefix !== "" && id.startsWith(`${prefix}-`)) {
1249
+ return id.slice(prefix.length + 1);
1250
+ }
1251
+ return id;
1252
+ }
1253
+ /**
1254
+ * Resolve cross-SDD `screen_feature` edges (schema 2.24.0, R3).
1255
+ *
1256
+ * App/integration per-SDD builds emit `screen_feature` rows whose `feature_id`
1257
+ * is the bare `realises:` slug when the feature lives in another SDD (the
1258
+ * platform — MFE-over-platform). Here, post-merge, each such row (feature_id
1259
+ * not already a feature PK) is resolved to the platform feature id by matching
1260
+ * the slug against every merged feature's recovered slug:
1261
+ * - exactly one match → rewrite feature_id (and the composite PK).
1262
+ * - >1 match → ambiguous; refuse (divergence finding), leave as-is
1263
+ * so the FK pass surfaces it. ADR 0177 guarantees one
1264
+ * slug per *capability*, not estate-wide, so cross-
1265
+ * capability slug clashes are possible and not guessed.
1266
+ * - 0 matches → orphan finding, leave as-is.
1267
+ * Rows that already resolve (local features; forward/app-owned estates) pass
1268
+ * through untouched.
1269
+ *
1270
+ * Exported for testing.
1271
+ */
1272
+ export function resolveCrossSddScreenFeature(screenFeatures, features, findings) {
1273
+ const featureIds = new Set();
1274
+ for (const f of features) {
1275
+ const id = String(f["id"] ?? "");
1276
+ if (id !== "")
1277
+ featureIds.add(id);
1278
+ }
1279
+ const idsBySlug = new Map();
1280
+ for (const f of features) {
1281
+ const slug = featureSlugOf(f);
1282
+ if (slug === "")
1283
+ continue;
1284
+ const bucket = idsBySlug.get(slug);
1285
+ if (bucket === undefined)
1286
+ idsBySlug.set(slug, [String(f["id"])]);
1287
+ else
1288
+ bucket.push(String(f["id"]));
1289
+ }
1290
+ const out = [];
1291
+ const seenPk = new Set();
1292
+ for (const row of screenFeatures) {
1293
+ const featureId = String(row["feature_id"] ?? "");
1294
+ // Already resolves (local feature, or an already-full id) — pass through.
1295
+ if (featureId === "" || featureIds.has(featureId)) {
1296
+ out.push(row);
1297
+ continue;
1298
+ }
1299
+ const matches = idsBySlug.get(featureId) ?? [];
1300
+ const screenId = String(row["screen_id"] ?? "");
1301
+ if (matches.length === 1) {
1302
+ const resolved = matches[0];
1303
+ const pk = `${screenId}__${resolved}`;
1304
+ if (seenPk.has(pk))
1305
+ continue; // dedupe if another SDD already produced it
1306
+ seenPk.add(pk);
1307
+ out.push({ ...row, id: pk, feature_id: resolved });
1308
+ }
1309
+ else if (matches.length > 1) {
1310
+ findings.add("divergence", `screen_feature: ${screenId} realises slug "${featureId}" resolves ambiguously ` +
1311
+ `to ${matches.length} features ecosystem-wide (${matches.join(", ")}) — left unresolved; ` +
1312
+ `qualify the realises ref or disambiguate the slug vocabulary`);
1313
+ out.push(row);
1314
+ }
1315
+ else {
1316
+ findings.add("orphan", `screen_feature: ${screenId} realises "${featureId}" — no feature resolves it ecosystem-wide`);
1317
+ out.push(row);
1318
+ }
1319
+ }
1320
+ return out;
1321
+ }
1322
+ /**
1323
+ * Orphan-check `products.successor_sdd_id` soft references (schema 2.22.0).
1324
+ *
1325
+ * A deprecated product's successor pointer resolves when the named sdd_id is
1326
+ * visible at this aggregation tier: either a direct contributor (root or
1327
+ * sibling) or a `source_sdd_id` stamped on any products row by a lower tier
1328
+ * (org-tier re-aggregation of cohort outputs). Unresolved pointers emit an
1329
+ * `orphan` finding and nothing else — the successor may legitimately live
1330
+ * outside the current aggregation scope, so this is never a hard FK failure.
1331
+ *
1332
+ * Exported for testing.
1333
+ */
1334
+ export function checkSuccessorReferences(products, contributorSddIds, findings) {
1335
+ const visible = new Set(contributorSddIds);
1336
+ for (const p of products) {
1337
+ const stamped = String(p["source_sdd_id"] ?? "");
1338
+ if (stamped !== "")
1339
+ visible.add(stamped);
1340
+ const own = String(p["id"] ?? "");
1341
+ if (own !== "")
1342
+ visible.add(own);
1343
+ }
1344
+ for (const p of products) {
1345
+ const successor = String(p["successor_sdd_id"] ?? "");
1346
+ if (successor === "")
1347
+ continue;
1348
+ if (!visible.has(successor)) {
1349
+ findings.add("orphan", `products: ${String(p["id"])} declares successor_sdd_id="${successor}" ` +
1350
+ `but no contributing SDD resolves it at this tier (soft reference — ` +
1351
+ `the successor may live outside this aggregation scope)`);
1352
+ }
1353
+ }
1354
+ }
1355
+ /**
1356
+ * M-ADR 0189 §4.4 — adjudicate external-system candidates against the
1357
+ * merged estate. A `System_Ext` in a domain-scoped L2 is a scope-relative
1358
+ * REFERENCE (§4.1), and even L1 declarations may name something the estate
1359
+ * owns (measured: 39% of one estate's dependency edges pointed at an
1360
+ * external-system row denoting its own platform). Identity is the declared
1361
+ * diagram entry id (§4.2) — at schema 3.0.0 the row id derives from it
1362
+ * (M-ADR 0203 §4.2) — with the display name only as a unique-match
1363
+ * fallback.
1364
+ *
1365
+ * Outcomes per candidate:
1366
+ * - resolves to an internal system of kind platform/subsystem/application:
1367
+ * the candidate row is dropped and its system_external_dependency edges
1368
+ * are rewritten onto system_system_dependency (§4.3). A name-fallback
1369
+ * resolution adds a `convention` finding (label-keyed identity, §4.2);
1370
+ * a would-be self-edge is refused with a finding.
1371
+ * - resolves to an internal system of kind `integration`: a PROVIDER-PAIR
1372
+ * COLLISION, not a reference (a connector SDD named after its genuinely
1373
+ * external provider — the kind discriminator measured on both estates).
1374
+ * Kept external; silent when self-declared, `convention` finding
1375
+ * otherwise.
1376
+ * - resolves to an internal container: tier contradiction — `coverage`
1377
+ * finding, row kept (the tier cannot be auto-corrected).
1378
+ * - resolves to nothing: genuinely external, unchanged.
1379
+ *
1380
+ * Exported for testing.
1381
+ */
1382
+ export function adjudicateExternalSystems(rowsByCatalogue, findings) {
1383
+ const systems = rowsByCatalogue.get("c4_systems") ?? [];
1384
+ const exts = rowsByCatalogue.get("c4_external_systems") ?? [];
1385
+ const containers = rowsByCatalogue.get("c4_containers") ?? [];
1386
+ const deps = rowsByCatalogue.get("system_external_dependency") ?? [];
1387
+ // Camel humps split BEFORE lowercasing (F11a): most SDD ids are
1388
+ // hyphenated, and `bvfmGatewayFramework` must be able to declare
1389
+ // `bvfm-gateway-framework`.
1390
+ const norm = (s) => s
1391
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
1392
+ .toLowerCase()
1393
+ .replace(/[^a-z0-9]+/g, "-")
1394
+ .replace(/^-+|-+$/g, "");
1395
+ const compact = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1396
+ const systemIds = new Set(systems.map((s) => String(s["id"])));
1397
+ const kindById = new Map(systems.map((s) => [String(s["id"]), String(s["kind"])]));
1398
+ const sddById = new Map(systems.map((s) => [String(s["id"]), String(s["product_id"] ?? "")]));
1399
+ const systemByProduct = new Map(systems
1400
+ .filter((s) => String(s["product_id"] ?? "") !== "")
1401
+ .map((s) => [String(s["product_id"]), String(s["id"])]));
1402
+ // Name index — names ambiguous across systems are excluded from matching
1403
+ // (post-F7 duplicates are themselves a finding).
1404
+ const systemsByName = new Map();
1405
+ for (const s of systems) {
1406
+ const key = compact(String(s["name"] ?? ""));
1407
+ if (key === "")
1408
+ continue;
1409
+ const ids = systemsByName.get(key);
1410
+ if (ids)
1411
+ ids.push(String(s["id"]));
1412
+ else
1413
+ systemsByName.set(key, [String(s["id"])]);
1414
+ }
1415
+ // Container indexes (F11b): keys (names + slug aliases) are confident
1416
+ // only within the DECLARING SDD's own system; across the estate, only a
1417
+ // full display-name match counts, and only when it is unique — a
1418
+ // diagram-local alias like `emailService` must never bind to an
1419
+ // unrelated SDD's container, and an ambiguous name asserts no referent.
1420
+ const containerKeysBySystem = new Map();
1421
+ const containersByName = new Map();
1422
+ for (const c of containers) {
1423
+ const cid = String(c["id"]);
1424
+ const sysId = String(c["system_id"] ?? "");
1425
+ const slugPart = sysId !== "" && cid.startsWith(`${sysId}-`) ? cid.slice(sysId.length + 1) : cid;
1426
+ let own = containerKeysBySystem.get(sysId);
1427
+ if (own === undefined) {
1428
+ own = new Map();
1429
+ containerKeysBySystem.set(sysId, own);
1430
+ }
1431
+ for (const key of [compact(String(c["name"] ?? "")), compact(slugPart)]) {
1432
+ if (key !== "" && !own.has(key))
1433
+ own.set(key, cid);
1434
+ }
1435
+ const nameKey = compact(String(c["name"] ?? ""));
1436
+ if (nameKey !== "") {
1437
+ const ids = containersByName.get(nameKey);
1438
+ if (ids)
1439
+ ids.push(cid);
1440
+ else
1441
+ containersByName.set(nameKey, [cid]);
1442
+ }
1443
+ }
1444
+ const resolveSystem = (ext) => {
1445
+ // Schema 3.0.0: the row id itself derives from the declared entry id
1446
+ // (M-ADR 0203 §4.2), so the id-slug IS declared identity — it backstops
1447
+ // an empty declared_id cell, and a hit through it is a declared hit
1448
+ // (corroboration-gated), never a label-keyed one.
1449
+ const declared = norm(String(ext["declared_id"] ?? "")) || String(ext["id"]).replace(/^ext-/, "");
1450
+ if (declared !== "") {
1451
+ for (const cand of [declared, `${declared}-system`]) {
1452
+ if (systemIds.has(cand))
1453
+ return { id: cand, method: "declared" };
1454
+ }
1455
+ const viaProduct = systemByProduct.get(declared);
1456
+ if (viaProduct !== undefined)
1457
+ return { id: viaProduct, method: "declared" };
1458
+ }
1459
+ // NAME evidence, per M-ADR 0189 §4.2's ratified fallback ("the
1460
+ // display-name slug remains only as a fallback for entries whose id
1461
+ // resolves to nothing"). Under 2.x the row id WAS the name slug, so
1462
+ // the id-slug path carried this; at 3.0.0 the slug must be derived
1463
+ // from the name itself or the fallback silently narrows — measured on
1464
+ // the first forward-estate probe: 35 of 38 internal platform edges
1465
+ // reclassified external because `System_Ext(platform, "Acme
1466
+ // Platform", …)` stopped resolving against the `acme-platform`
1467
+ // product. Every hit here is method "name" (label-keyed — the
1468
+ // conversion emits the §4.2 convention finding).
1469
+ const nameSlug = norm(String(ext["name"] ?? ""));
1470
+ if (nameSlug !== "") {
1471
+ for (const cand of [nameSlug, `${nameSlug}-system`]) {
1472
+ if (systemIds.has(cand))
1473
+ return { id: cand, method: "name" };
1474
+ }
1475
+ const viaNameProduct = systemByProduct.get(nameSlug);
1476
+ if (viaNameProduct !== undefined)
1477
+ return { id: viaNameProduct, method: "name" };
1478
+ }
1479
+ const byName = systemsByName.get(compact(String(ext["name"] ?? "")));
1480
+ if (byName !== undefined && byName.length === 1) {
1481
+ return { id: byName[0], method: "name" };
1482
+ }
1483
+ return null;
1484
+ };
1485
+ // Estate-wide declared-key index: key → container ids across ALL systems.
1486
+ const containersByKey = new Map();
1487
+ for (const [, own] of containerKeysBySystem) {
1488
+ for (const [key, cid] of own) {
1489
+ const ids = containersByKey.get(key);
1490
+ if (ids) {
1491
+ if (!ids.includes(cid))
1492
+ ids.push(cid);
1493
+ }
1494
+ else
1495
+ containersByKey.set(key, [cid]);
1496
+ }
1497
+ }
1498
+ const containerNameById = new Map(containers.map((c) => [String(c["id"]), String(c["name"] ?? "")]));
1499
+ const containerSystemById = new Map(containers.map((c) => [String(c["id"]), String(c["system_id"] ?? "")]));
1500
+ // F12: containers follow the SAME precedence as systems — declared id
1501
+ // first, corroborated by the display name; the name alone only as a
1502
+ // unique fallback. 0.73.0 inverted this (name-equality only, declared id
1503
+ // discarded) and traded two false positives for five silent true
1504
+ // contradictions. Silence is not the remedy for a false reason; a true
1505
+ // reason is — every held candidate below states why it is held.
1506
+ const resolveContainer = (ext) => {
1507
+ const declarerSystem = systemByProduct.get(String(ext["source_sdd_id"] ?? ""));
1508
+ if (declarerSystem !== undefined) {
1509
+ const own = containerKeysBySystem.get(declarerSystem);
1510
+ if (own !== undefined) {
1511
+ // The id-slug key duplicates the declared key at 3.0.0 (the row id
1512
+ // derives from the entry id) but backstops an empty declared_id.
1513
+ for (const key of [
1514
+ compact(String(ext["declared_id"] ?? "")),
1515
+ compact(String(ext["id"]).replace(/^ext-/, "")),
1516
+ compact(String(ext["name"] ?? "")),
1517
+ ]) {
1518
+ const cid = key !== "" ? own.get(key) : undefined;
1519
+ if (cid !== undefined)
1520
+ return { kind: "own", containerId: cid };
1521
+ }
1522
+ }
1523
+ }
1524
+ const extName = String(ext["name"] ?? "");
1525
+ const declaredKey = compact(String(ext["declared_id"] ?? "")) ||
1526
+ compact(String(ext["id"]).replace(/^ext-/, ""));
1527
+ const byKey = declaredKey !== "" ? containersByKey.get(declaredKey) : undefined;
1528
+ if (byKey !== undefined) {
1529
+ const corroborating = byKey.filter((cid) => corroboratesLabel(extName, containerNameById.get(cid) ?? "", ""));
1530
+ if (corroborating.length > 1) {
1531
+ return { kind: "ambiguous", candidates: [...corroborating].sort((a, b) => a.localeCompare(b)) };
1532
+ }
1533
+ if (corroborating.length === 1) {
1534
+ if (isUncertainLabel(extName)) {
1535
+ return { kind: "uncertain", containerId: corroborating[0] };
1536
+ }
1537
+ return { kind: "cross", containerId: corroborating[0] };
1538
+ }
1539
+ // Declared key matches, name disagrees — held, loudly.
1540
+ return { kind: "uncorroborated", containerId: byKey[0] };
1541
+ }
1542
+ const nameKey = compact(extName);
1543
+ const byName = nameKey !== "" ? containersByName.get(nameKey) : undefined;
1544
+ if (byName !== undefined) {
1545
+ if (byName.length === 1) {
1546
+ if (isUncertainLabel(extName)) {
1547
+ return { kind: "uncertain", containerId: byName[0] };
1548
+ }
1549
+ return { kind: "cross", containerId: byName[0] };
1550
+ }
1551
+ return { kind: "ambiguous", candidates: [...byName].sort((a, b) => a.localeCompare(b)) };
1552
+ }
1553
+ return null;
1554
+ };
1555
+ // F11a/F12 corroboration: a declared-id hit converts (or binds) only when
1556
+ // the display name agrees with the target — diagram entry ids are
1557
+ // conventionally local, and an old mnemonic that happens to equal an
1558
+ // estate id must not convert silently. Comparison is TOKEN-level, not
1559
+ // substring (F12): "Tachograph Service" must not corroborate `tacho`
1560
+ // (shared prefix, different entity), while "Engine (telematics
1561
+ // processor)" must corroborate a container named "Engine" (a richer
1562
+ // label must not defeat the match) and three-letter names like "CWS"
1563
+ // must work (no length floor). Corroborated = compact equality with the
1564
+ // target's name or id, or every token of either appearing among the
1565
+ // label's tokens. A declared id that corroborates is an assertion the
1566
+ // author is accountable for (M-ADR 0189 §4.2); renaming the mnemonic
1567
+ // opts out.
1568
+ const tokens = (s) => s
1569
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
1570
+ .toLowerCase()
1571
+ .split(/[^a-z0-9]+/)
1572
+ .filter((t) => t !== "");
1573
+ const tokenSubset = (target, label) => {
1574
+ const ts = tokens(target);
1575
+ return ts.length > 0 && ts.every((t) => label.has(t));
1576
+ };
1577
+ const corroboratesLabel = (extName, targetName, targetId) => {
1578
+ const en = compact(extName);
1579
+ if (en === "")
1580
+ return false;
1581
+ const labelTokens = new Set(tokens(extName));
1582
+ for (const t of [targetName, targetId]) {
1583
+ if (t === "")
1584
+ continue;
1585
+ if (en === compact(t))
1586
+ return true;
1587
+ if (tokenSubset(t, labelTokens))
1588
+ return true;
1589
+ }
1590
+ return false;
1591
+ };
1592
+ const nameBysystem = new Map(systems.map((s) => [String(s["id"]), String(s["name"] ?? "")]));
1593
+ const corroborates = (ext, sysId) => corroboratesLabel(String(ext["name"] ?? ""), nameBysystem.get(sysId) ?? "", sddById.get(sysId) ?? "");
1594
+ // A bracketed qualifier ("Email Service [unknown]") is the author
1595
+ // declaring uncertainty — it must never be rendered as a confident
1596
+ // referent (F11b/F12): held, with the uncertainty as the stated reason.
1597
+ const isUncertainLabel = (name) => /\[[^\]]*\]/.test(name);
1598
+ const keptExts = [];
1599
+ const dropExtIds = new Map();
1600
+ const outcomeByExtId = new Map();
1601
+ let converted = 0;
1602
+ let tierContradictions = 0;
1603
+ let providerPairsKept = 0;
1604
+ let uncorroboratedKept = 0;
1605
+ let uncertainHolds = 0;
1606
+ let ambiguousHolds = 0;
1607
+ let unverified = 0;
1608
+ let exempted = 0;
1609
+ let containerConverted = 0;
1610
+ let containerEdgesRewritten = 0;
1611
+ let systemFallbackEdges = 0;
1612
+ // (F17) edges per candidate, split by whether the member build resolved
1613
+ // the Rel's non-external endpoint to a local container — the datum that
1614
+ // decides whether a container-bound candidate can convert without
1615
+ // destroying an edge.
1616
+ const edgesByExtId = new Map();
1617
+ for (const dep of deps) {
1618
+ const key = String(dep["external_system_id"]);
1619
+ let bucket = edgesByExtId.get(key);
1620
+ if (bucket === undefined) {
1621
+ bucket = { convertible: [], stuck: [] };
1622
+ edgesByExtId.set(key, bucket);
1623
+ }
1624
+ if (String(dep["from_container_id"] ?? "") !== "")
1625
+ bucket.convertible.push(dep);
1626
+ else
1627
+ bucket.stuck.push(dep);
1628
+ }
1629
+ const containerTargetByExtId = new Map();
1630
+ const containerDroppedByExtId = new Map();
1631
+ for (const ext of exts) {
1632
+ const extId = String(ext["id"]);
1633
+ const declaredBy = String(ext["source_sdd_id"] ?? "");
1634
+ const sys = resolveSystem(ext);
1635
+ if (sys !== null) {
1636
+ const kind = kindById.get(sys.id) ?? "";
1637
+ if (kind === "integration") {
1638
+ providerPairsKept++;
1639
+ outcomeByExtId.set(extId, "provider-pair collision");
1640
+ keptExts.push(ext);
1641
+ const ownerSdd = sddById.get(sys.id) ?? "";
1642
+ if (declaredBy !== "" && declaredBy === ownerSdd) {
1643
+ // Self-declared provider pair — the connector SDD declaring its
1644
+ // genuinely-external provider. Silent by design.
1645
+ }
1646
+ else {
1647
+ findings.add("convention", `c4_external_systems: "${extId}" collides with integration-kind system "${sys.id}" — ` +
1648
+ `likely a provider pair (connector named after its external provider); kept external (M-ADR 0189 §4.4)`);
1649
+ }
1650
+ continue;
1651
+ }
1652
+ if (sys.method === "declared" && !corroborates(ext, sys.id)) {
1653
+ // F11a: declared-id hit without name corroboration — a collision
1654
+ // between a diagram-local mnemonic and an estate id, not a
1655
+ // reference. Kept external, loudly.
1656
+ uncorroboratedKept++;
1657
+ outcomeByExtId.set(extId, "uncorroborated hold");
1658
+ keptExts.push(ext);
1659
+ findings.add("convention", `c4_external_systems: "${extId}" declared_id resolves to internal system "${sys.id}" ` +
1660
+ `but the display name does not corroborate — kept external; align the entry id or name to convert (M-ADR 0189 §4.2 / F11a)`);
1661
+ continue;
1662
+ }
1663
+ if (isUncertainLabel(String(ext["name"] ?? ""))) {
1664
+ // Declared uncertainty never converts (F12) — held with the
1665
+ // uncertainty as the stated reason.
1666
+ uncertainHolds++;
1667
+ outcomeByExtId.set(extId, "uncertain hold");
1668
+ keptExts.push(ext);
1669
+ findings.add("coverage", `c4_external_systems: "${extId}" resolves to internal system "${sys.id}" but the label declares uncertainty — ` +
1670
+ `held, not converted; resolve the uncertainty to convert (M-ADR 0189 §4.4 / F12)`);
1671
+ continue;
1672
+ }
1673
+ // Internal reference — convert.
1674
+ dropExtIds.set(extId, { toSystem: sys.id });
1675
+ converted++;
1676
+ if (sys.method === "name") {
1677
+ findings.add("convention", `c4_external_systems: "${extId}" resolved to internal system "${sys.id}" by display-name fallback — ` +
1678
+ `label-keyed identity; declare the entry id to make this exact (M-ADR 0189 §4.2)`);
1679
+ }
1680
+ continue;
1681
+ }
1682
+ const containerRes = resolveContainer(ext);
1683
+ if (containerRes !== null) {
1684
+ outcomeByExtId.set(extId, containerRes.kind === "uncorroborated"
1685
+ ? "uncorroborated hold"
1686
+ : containerRes.kind === "uncertain"
1687
+ ? "uncertain hold"
1688
+ : containerRes.kind === "ambiguous"
1689
+ ? "ambiguous hold"
1690
+ : "tier contradiction");
1691
+ if (containerRes.kind === "uncorroborated") {
1692
+ uncorroboratedKept++;
1693
+ findings.add("convention", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
1694
+ `declared_id matches internal container "${containerRes.containerId}" but the display name does not corroborate — ` +
1695
+ `no referent asserted; align the entry id or name to bind (M-ADR 0189 §4.2 / F12)`);
1696
+ keptExts.push(ext);
1697
+ continue;
1698
+ }
1699
+ if (containerRes.kind === "uncertain") {
1700
+ uncertainHolds++;
1701
+ // (already marked as an outcome above)
1702
+ findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
1703
+ `matches internal container "${containerRes.containerId}" but the label declares uncertainty — ` +
1704
+ `held, not asserted; resolve the uncertainty to bind (M-ADR 0189 §4.4 / F12)`);
1705
+ keptExts.push(ext);
1706
+ continue;
1707
+ }
1708
+ if (containerRes.kind === "ambiguous") {
1709
+ ambiguousHolds++;
1710
+ findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
1711
+ `name-matches ${containerRes.candidates.length} internal containers [${containerRes.candidates.join(", ")}] — ` +
1712
+ `ambiguous tier contradiction, no referent asserted, row kept (M-ADR 0189 §4.4 / F11b)`);
1713
+ }
1714
+ else {
1715
+ // F17: a bound candidate CONVERTS — its edges rewrite onto
1716
+ // container_dependency where the from-endpoint is known. Only a
1717
+ // candidate with unconvertible edges is kept, with the
1718
+ // contradiction finding stating exactly what blocks it. No edge
1719
+ // is ever destroyed to make a build quieter.
1720
+ containerTargetByExtId.set(extId, containerRes.containerId);
1721
+ const bucket = edgesByExtId.get(extId);
1722
+ // F24 (F17's residue): a stuck edge whose from-SYSTEM differs from
1723
+ // the target container's owning system is a correct C4 L1 edge —
1724
+ // it rewrites onto system_system_dependency (from-system → owning
1725
+ // system). Only a stuck edge WITHIN the owning system truly blocks
1726
+ // conversion: it would be an L1 self-edge, and the candidate is
1727
+ // kept for it.
1728
+ const owner = containerSystemById.get(containerRes.containerId) ?? "";
1729
+ const trulyStuck = (bucket?.stuck ?? []).filter((d) => String(d["c4_system_id"]) === owner).length;
1730
+ if (trulyStuck === 0) {
1731
+ containerConverted++;
1732
+ containerDroppedByExtId.set(extId, containerRes.containerId);
1733
+ outcomeByExtId.set(extId, "container conversion");
1734
+ continue; // row dropped — a phantom duplicate of the container
1735
+ }
1736
+ tierContradictions++;
1737
+ findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
1738
+ `resolves to ${containerRes.kind === "own" ? "its declarer's own" : "an"} internal container "${containerRes.containerId}" — ` +
1739
+ `tier contradiction, row kept: ${trulyStuck} same-system dependency edge(s) have no member-resolved from-container and would be L1 self-edges (M-ADR 0189 §4.4 / F17/F24)`);
1740
+ }
1741
+ }
1742
+ keptExts.push(ext);
1743
+ }
1744
+ // Rewrite dependency edges whose external endpoint converted — to a
1745
+ // system (§4.4) or, one tier down, to a container (F17).
1746
+ const keptDeps = [];
1747
+ const newEdges = new Map();
1748
+ const newContainerEdges = new Map();
1749
+ let edgesRewritten = 0;
1750
+ for (const dep of deps) {
1751
+ const extKey = String(dep["external_system_id"]);
1752
+ const containerTarget = containerTargetByExtId.get(extKey);
1753
+ if (containerTarget !== undefined) {
1754
+ const fromContainer = String(dep["from_container_id"] ?? "");
1755
+ if (fromContainer === "") {
1756
+ // F24: no member-resolved from-container. Across systems this is a
1757
+ // correct C4 L1 edge — from-system → the container's owning system.
1758
+ // Within the owning system it would be an L1 self-edge: kept, and
1759
+ // its candidate row was kept for it.
1760
+ const owner = containerSystemById.get(containerTarget) ?? "";
1761
+ const fromSystem = String(dep["c4_system_id"]);
1762
+ if (owner !== "" && fromSystem !== owner) {
1763
+ const sid = `${fromSystem}__${owner}`;
1764
+ const existingFE = newEdges.get(sid);
1765
+ if (existingFE !== undefined) {
1766
+ mergeEdgeLabel(existingFE, String(dep["interaction"] ?? ""), "system_system_dependency", findings);
1767
+ }
1768
+ else {
1769
+ newEdges.set(sid, {
1770
+ id: sid,
1771
+ from_system_id: fromSystem,
1772
+ to_system_id: owner,
1773
+ interaction: String(dep["interaction"] ?? ""),
1774
+ source_sdd_id: sddById.get(fromSystem) ?? "",
1775
+ });
1776
+ }
1777
+ systemFallbackEdges++;
1778
+ continue;
1779
+ }
1780
+ keptDeps.push(dep); // truly stuck — its candidate row was kept for it
1781
+ continue;
1782
+ }
1783
+ if (fromContainer === containerTarget) {
1784
+ findings.add("coverage", `container_dependency: refused self-edge ${fromContainer} → ${containerTarget} ` +
1785
+ `(external declaration resolved to the declaring container itself; M-ADR 0189 §4.4 / F17)`);
1786
+ continue;
1787
+ }
1788
+ const cid = `${fromContainer}__${containerTarget}`;
1789
+ const existingCE = newContainerEdges.get(cid);
1790
+ if (existingCE !== undefined) {
1791
+ mergeEdgeLabel(existingCE, String(dep["interaction"] ?? ""), "container_dependency", findings);
1792
+ }
1793
+ else {
1794
+ newContainerEdges.set(cid, {
1795
+ id: cid,
1796
+ from_container_id: fromContainer,
1797
+ to_container_id: containerTarget,
1798
+ interaction: String(dep["interaction"] ?? ""),
1799
+ source_sdd_id: sddById.get(String(dep["c4_system_id"])) ?? "",
1800
+ });
1801
+ }
1802
+ containerEdgesRewritten++;
1803
+ continue;
1804
+ }
1805
+ const target = dropExtIds.get(extKey);
1806
+ if (target === undefined) {
1807
+ keptDeps.push(dep);
1808
+ continue;
1809
+ }
1810
+ const from = String(dep["c4_system_id"]);
1811
+ if (from === target.toSystem) {
1812
+ findings.add("coverage", `system_system_dependency: refused self-edge ${from} → ${target.toSystem} ` +
1813
+ `(external declaration resolved to the declaring system itself; M-ADR 0189 §4.4)`);
1814
+ continue;
1815
+ }
1816
+ const id = `${from}__${target.toSystem}`;
1817
+ const existingSE = newEdges.get(id);
1818
+ if (existingSE !== undefined) {
1819
+ mergeEdgeLabel(existingSE, String(dep["interaction"] ?? ""), "system_system_dependency", findings);
1820
+ }
1821
+ else {
1822
+ newEdges.set(id, {
1823
+ id,
1824
+ from_system_id: from,
1825
+ to_system_id: target.toSystem,
1826
+ interaction: String(dep["interaction"] ?? ""),
1827
+ source_sdd_id: sddById.get(from) ?? "",
1828
+ });
1829
+ }
1830
+ edgesRewritten++;
1831
+ }
1832
+ // §4.6 (WS15): exemptions vs outcomes, then the coverage predicate.
1833
+ // An exemption on a CONVERTED candidate contradicts the adjudication —
1834
+ // reported and dropped (it would also dangle). A kept candidate with no
1835
+ // outcome and no exemption is the silent bucket L4 lived in: reported
1836
+ // unverified, one finding per candidate, retired by declared identity
1837
+ // or by an asserted exemption.
1838
+ // F15: the builder docstring — "each row retires exactly one unverified
1839
+ // finding, and nothing else" — is enforced here, not assumed. A kept
1840
+ // exemption MUST retire exactly one unverified finding; one that retires
1841
+ // nothing (its candidate converted, or already has an adjudication
1842
+ // outcome of any class) is reported and DROPPED, so that
1843
+ // |external_system_exemption| == exempted holds as an invariant rather
1844
+ // than a check someone has to remember.
1845
+ const exemptionRows = rowsByCatalogue.get("external_system_exemption") ?? [];
1846
+ const keptExtIds = new Set(keptExts.map((e) => String(e["id"])));
1847
+ const keptExemptions = [];
1848
+ const exemptedIds = new Set();
1849
+ for (const ex of exemptionRows) {
1850
+ const target = String(ex["external_system_id"] ?? "");
1851
+ const conv = dropExtIds.get(target);
1852
+ if (conv !== undefined) {
1853
+ findings.add("convention", `external_system_exemption: "${target}" is asserted genuinely external but adjudication resolved it to ` +
1854
+ `internal system "${conv.toSystem}" — contradiction; exemption dropped (M-ADR 0189 §4.6)`);
1855
+ continue;
1856
+ }
1857
+ const containerDrop = containerDroppedByExtId.get(target);
1858
+ if (containerDrop !== undefined) {
1859
+ findings.add("convention", `external_system_exemption: "${target}" is asserted genuinely external but adjudication resolved it to ` +
1860
+ `internal container "${containerDrop}" — contradiction; exemption dropped (M-ADR 0189 §4.6 / F17)`);
1861
+ continue;
1862
+ }
1863
+ const outcome = outcomeByExtId.get(target);
1864
+ if (outcome !== undefined) {
1865
+ findings.add("convention", `external_system_exemption: "${target}" retires nothing — the candidate already has an adjudication ` +
1866
+ `outcome (${outcome}); exemption dropped (M-ADR 0189 §4.6 / F15)`);
1867
+ continue;
1868
+ }
1869
+ if (!keptExtIds.has(target)) {
1870
+ findings.add("convention", `external_system_exemption: "${target}" names no kept external-system candidate — exemption dropped (M-ADR 0189 §4.6 / F15)`);
1871
+ continue;
1872
+ }
1873
+ keptExemptions.push(ex);
1874
+ exemptedIds.add(target);
1875
+ }
1876
+ rowsByCatalogue.set("external_system_exemption", keptExemptions);
1877
+ for (const ext of keptExts) {
1878
+ const extId = String(ext["id"]);
1879
+ if (outcomeByExtId.has(extId))
1880
+ continue;
1881
+ if (exemptedIds.has(extId)) {
1882
+ exempted++;
1883
+ continue;
1884
+ }
1885
+ unverified++;
1886
+ findings.add("coverage", `c4_external_systems: "${extId}" resolved to no internal system or container and carries no exemption — ` +
1887
+ `kept external, unverified (M-ADR 0189 §4.6)`);
1888
+ }
1889
+ const existingCD = rowsByCatalogue.get("container_dependency") ?? [];
1890
+ const mergedCD = [...existingCD];
1891
+ const existingCDIds = new Set(existingCD.map((r) => String(r["id"])));
1892
+ const existingCDById = new Map(existingCD.map((r) => [String(r["id"]), r]));
1893
+ for (const row of [...newContainerEdges.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))) {
1894
+ const member = existingCDById.get(String(row["id"]));
1895
+ if (member !== undefined) {
1896
+ mergeEdgeLabel(member, String(row["interaction"] ?? ""), "container_dependency", findings);
1897
+ continue;
1898
+ }
1899
+ if (!existingCDIds.has(String(row["id"])))
1900
+ mergedCD.push(row);
1901
+ }
1902
+ rowsByCatalogue.set("container_dependency", mergedCD);
1903
+ const existingSSD = rowsByCatalogue.get("system_system_dependency") ?? [];
1904
+ const merged = [...existingSSD];
1905
+ const existingIds = new Set(existingSSD.map((r) => String(r["id"])));
1906
+ const existingSSDById = new Map(existingSSD.map((r) => [String(r["id"]), r]));
1907
+ for (const row of [...newEdges.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))) {
1908
+ const member = existingSSDById.get(String(row["id"]));
1909
+ if (member !== undefined) {
1910
+ mergeEdgeLabel(member, String(row["interaction"] ?? ""), "system_system_dependency", findings);
1911
+ continue;
1912
+ }
1913
+ if (!existingIds.has(String(row["id"])))
1914
+ merged.push(row);
1915
+ }
1916
+ // M-ADR 0203 §4.2's detector for its own fallout: with row identity now
1917
+ // the DECLARED entry id, SDDs that declared different entry ids for one
1918
+ // real-world external system split into distinct rows — where the 2.x
1919
+ // name-slug ids silently merged them. Measured before this shipped: 5 old
1920
+ // ids deriving 2-3 conflicting new ids across one estate's SDDs. The
1921
+ // split must be loud, not latent: one display name held by several kept
1922
+ // declared identities is either one estate entity (converge the diagram
1923
+ // entry ids — the rename is a diagram edit, the sanctioned opt-out) or
1924
+ // genuinely several (rename the labels apart). Convention, not an error:
1925
+ // the aggregator cannot know which.
1926
+ const keptByName = new Map();
1927
+ for (const ext of keptExts) {
1928
+ const key = compact(String(ext["name"] ?? ""));
1929
+ if (key === "")
1930
+ continue;
1931
+ const ids = keptByName.get(key);
1932
+ if (ids)
1933
+ ids.push(String(ext["id"]));
1934
+ else
1935
+ keptByName.set(key, [String(ext["id"])]);
1936
+ }
1937
+ for (const [, ids] of [...keptByName.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1938
+ if (ids.length < 2)
1939
+ continue;
1940
+ const sorted = [...ids].sort((a, b) => a.localeCompare(b));
1941
+ findings.add("convention", `c4_external_systems: one display name "${String(keptExts.find((e) => String(e["id"]) === sorted[0])?.["name"] ?? "")}" is held by ${sorted.length} declared identities [${sorted.join(", ")}] — ` +
1942
+ `either one estate entity split by divergent diagram entry ids (converge the entry ids) ` +
1943
+ `or several entities sharing a label (rename the labels apart) (M-ADR 0203 §4.2)`);
1944
+ }
1945
+ rowsByCatalogue.set("c4_external_systems", keptExts);
1946
+ rowsByCatalogue.set("system_external_dependency", keptDeps);
1947
+ rowsByCatalogue.set("system_system_dependency", merged);
1948
+ return {
1949
+ converted,
1950
+ edgesRewritten,
1951
+ tierContradictions,
1952
+ providerPairsKept,
1953
+ uncorroboratedKept,
1954
+ uncertainHolds,
1955
+ ambiguousHolds,
1956
+ unverified,
1957
+ exempted,
1958
+ containerConverted,
1959
+ containerEdgesRewritten,
1960
+ systemFallbackEdges,
1961
+ };
1962
+ }
1963
+ /**
1964
+ * M-ADR 0203 §4.4's cross-SDD detector-and-re-key, cut window only.
1965
+ *
1966
+ * Every FK referencing `contracts.id` (schema-driven, so a future contract
1967
+ * edge is covered without touching this) is scanned over the merged rows: a
1968
+ * value that resolves to no contract, but whose `${value}-${kind}` form
1969
+ * resolves via the legacy map built from the MERGED contracts rows, is a
1970
+ * provably tool-authored 2.x id — regenerable, not drift. The value re-keys
1971
+ * and the composite row id's embedded `__`-delimited component re-keys with
1972
+ * it. A re-keyed row whose id then collides with an existing row is the
1973
+ * same fact asserted under both eras — deduped, counted. An old id that was
1974
+ * a 2.x cross-family collision maps to several contracts: loud and left
1975
+ * dangling for FK validation to report (the author qualifies; never a
1976
+ * guess). Exported for testing.
1977
+ */
1978
+ export function rekeyLegacyContractRefs(schema, rowsByCatalogue, findings) {
1979
+ const contracts = rowsByCatalogue.get("contracts") ?? [];
1980
+ const contractIds = new Set(contracts.map((r) => String(r["id"])));
1981
+ const legacyMap = buildLegacyContractIdMap(contracts);
1982
+ let rekeyed = 0;
1983
+ let deduped = 0;
1984
+ let ambiguous = 0;
1985
+ if (legacyMap.size === 0)
1986
+ return { rekeyed, deduped, ambiguous };
1987
+ for (const cat of schema.catalogues) {
1988
+ const fkCols = cat.foreignKeys
1989
+ .filter((fk) => fk.references === "contracts.id")
1990
+ .map((fk) => fk.column);
1991
+ if (fkCols.length === 0 || cat.name === "contracts")
1992
+ continue;
1993
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
1994
+ if (rows.length === 0)
1995
+ continue;
1996
+ const perOldId = new Map();
1997
+ const ambiguousOld = new Map();
1998
+ const kept = [];
1999
+ const idsAfter = new Set(rows.map((r) => String(r["id"])));
2000
+ for (const row of rows) {
2001
+ let dropped = false;
2002
+ let current = row;
2003
+ for (const col of fkCols) {
2004
+ const value = String(current[col] ?? "");
2005
+ if (value === "" || contractIds.has(value))
2006
+ continue;
2007
+ const mapped = legacyMap.get(value);
2008
+ if (mapped === undefined)
2009
+ continue;
2010
+ if (mapped.length > 1) {
2011
+ ambiguousOld.set(value, mapped);
2012
+ continue;
2013
+ }
2014
+ const newVal = mapped[0];
2015
+ const oldRowId = String(current["id"] ?? "");
2016
+ const newRowId = oldRowId
2017
+ .split("__")
2018
+ .map((part) => (part === value ? newVal : part))
2019
+ .join("__");
2020
+ if (newRowId !== oldRowId && idsAfter.has(newRowId)) {
2021
+ // The same edge fact already exists under the new id (typically
2022
+ // the owning SDD's own re-keyed row) — one fact, two eras.
2023
+ deduped++;
2024
+ dropped = true;
2025
+ break;
2026
+ }
2027
+ const replaced = {
2028
+ ...current,
2029
+ [col]: newVal,
2030
+ };
2031
+ if (newRowId !== oldRowId) {
2032
+ idsAfter.delete(oldRowId);
2033
+ idsAfter.add(newRowId);
2034
+ replaced["id"] = newRowId;
2035
+ }
2036
+ current = replaced;
2037
+ perOldId.set(value, (perOldId.get(value) ?? 0) + 1);
2038
+ rekeyed++;
2039
+ }
2040
+ if (!dropped)
2041
+ kept.push(current);
2042
+ }
2043
+ rowsByCatalogue.set(cat.name, kept);
2044
+ for (const [oldId, count] of [...perOldId.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2045
+ findings.add("inferred", `${cat.name}: ${count} cross-SDD reference(s) to "${oldId}" re-keyed to "${legacyMap.get(oldId)[0]}" — ` +
2046
+ `2.x kindless contract id migrated per the 3.0.0 map (regenerable, not drift; M-ADR 0203 §4.4)`);
2047
+ }
2048
+ for (const [oldId, targets] of [...ambiguousOld.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2049
+ ambiguous++;
2050
+ findings.add("convention", `${cat.name}: references to "${oldId}" map to ${targets.length} contracts at 3.0.0 [${targets.join(", ")}] — ` +
2051
+ `a 2.x cross-family collision; the referencing SDD must qualify with the kind-suffixed id; left dangling (M-ADR 0203 §4.4)`);
2052
+ }
2053
+ }
2054
+ return { rekeyed, deduped, ambiguous };
2055
+ }
2056
+ /**
2057
+ * Fill `platform_sdd_id` on rows the aggregator constructed after the node
2058
+ * loader stamped its inputs (M-ADR 0208 §4.4). The node loader stamps every
2059
+ * row it MERGES; rows built at this tier from those inputs — 0189 §4.4
2060
+ * adjudication conversions into system_system_dependency / container_dependency,
2061
+ * re-derived edges — set source_sdd_id explicitly and were emitted with an empty
2062
+ * platform stamp under FK PASS (measured: 38 of 38 system_system_dependency rows
2063
+ * and 2 of 208 container_dependency rows on the forward reference estate, the
2064
+ * other 206 stamped by the loader; 26 of 26 and 13 of 194 on the reverse estate's
2065
+ * platform tier). Resolution order per row: the §4.4 fill-rule platform recorded
2066
+ * for its source_sdd_id; else the single stamp already observed on that source's
2067
+ * rows anywhere in the aggregate (a source whose rows carry two different stamps
2068
+ * is ambiguous and is NOT guessed). Whatever remains empty is reported as
2069
+ * coverage — an empty stamp is unresolved, never a key value. Exported for testing.
2070
+ */
2071
+ export function fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings) {
2072
+ const stamped = schema.catalogues.filter((c) => c.columns.some((col) => col.name === "platform_sdd_id") &&
2073
+ c.columns.some((col) => col.name === "source_sdd_id"));
2074
+ const observed = new Map();
2075
+ for (const cat of stamped) {
2076
+ for (const r of rowsByCatalogue.get(cat.name) ?? []) {
2077
+ const src = String(r["source_sdd_id"] ?? "").trim();
2078
+ const pf = String(r["platform_sdd_id"] ?? "").trim();
2079
+ if (src === "" || pf === "")
2080
+ continue;
2081
+ if (!observed.has(src))
2082
+ observed.set(src, new Set());
2083
+ observed.get(src).add(pf);
2084
+ }
2085
+ }
2086
+ const resolve = (src) => {
2087
+ const ruled = platformBySource.get(src);
2088
+ if (ruled !== undefined)
2089
+ return ruled;
2090
+ const seen = observed.get(src);
2091
+ if (seen !== undefined && seen.size === 1)
2092
+ return [...seen][0];
2093
+ return null;
2094
+ };
2095
+ let filled = 0;
2096
+ const unresolved = [];
2097
+ for (const cat of stamped) {
2098
+ const rows = rowsByCatalogue.get(cat.name);
2099
+ if (rows === undefined || rows.length === 0)
2100
+ continue;
2101
+ let catEmpty = 0;
2102
+ const next = rows.map((r) => {
2103
+ if (String(r["platform_sdd_id"] ?? "").trim() !== "")
2104
+ return r;
2105
+ const src = String(r["source_sdd_id"] ?? "").trim();
2106
+ const value = src === "" ? null : resolve(src);
2107
+ if (value === null) {
2108
+ catEmpty++;
2109
+ return r;
2110
+ }
2111
+ filled++;
2112
+ return { ...r, platform_sdd_id: value };
2113
+ });
2114
+ rowsByCatalogue.set(cat.name, next);
2115
+ if (catEmpty > 0)
2116
+ unresolved.push(`${cat.name} ${catEmpty}`);
2117
+ }
2118
+ if (filled > 0) {
2119
+ findings.add("inferred", `platform_sdd_id: ${filled} aggregator-constructed row(s) stamped from their source_sdd_id after derivation (M-ADR 0208 §4.4) — the node loader stamps only the rows it merges`);
2120
+ }
2121
+ if (unresolved.length > 0) {
2122
+ findings.add("coverage", `platform_sdd_id: rows still EMPTY after the final pass [${unresolved.join(", ")}] — source unknown to the fill rule, or its rows carry more than one stamp; unresolved, not a key (M-ADR 0208 §4.4)`);
2123
+ }
2124
+ }
2125
+ /**
2126
+ * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4, TIER-GATED by
2127
+ * M-ADR 0208 §4.2 — gated, not skipped: a skipped check fails toward success.
2128
+ * platform tier: exactly one row with kind "platform" in the aggregate.
2129
+ * organisation tier: exactly one "platform" row PER DISTINCT platform_sdd_id,
2130
+ * and every "platform" row must carry a stamp — an empty
2131
+ * stamp is unresolved, never a key (0208 §4.4).
2132
+ * Exported for testing.
2133
+ */
2134
+ export function checkPlatformKindUniqueness(systems, findings, tier = "platform") {
2135
+ const plats = systems.filter((s) => String(s["kind"]) === "platform");
2136
+ if (tier === "platform") {
2137
+ const ids = plats.map((s) => String(s["id"])).sort((a, b) => a.localeCompare(b));
2138
+ if (ids.length > 1) {
2139
+ findings.add("convention", `c4_systems: ${ids.length} rows with kind "platform" [${ids.join(", ")}] — ` +
2140
+ `exactly one per platform (M-ADR 0208 §4.2); this is a platform-tier aggregate`);
2141
+ }
2142
+ return;
2143
+ }
2144
+ const unstamped = plats
2145
+ .filter((s) => String(s["platform_sdd_id"] ?? "").trim() === "")
2146
+ .map((s) => String(s["id"]))
2147
+ .sort((a, b) => a.localeCompare(b));
2148
+ if (unstamped.length > 0) {
2149
+ findings.add("coverage", `c4_systems: ${unstamped.length} "platform" row(s) with EMPTY platform_sdd_id at organisation tier [${unstamped.join(", ")}] — unresolved, not a key (M-ADR 0208 §4.4)`);
2150
+ }
2151
+ const byPlatform = new Map();
2152
+ for (const s of plats) {
2153
+ const key = String(s["platform_sdd_id"] ?? "").trim();
2154
+ if (key === "")
2155
+ continue;
2156
+ const list = byPlatform.get(key) ?? [];
2157
+ list.push(String(s["id"]));
2158
+ byPlatform.set(key, list);
2159
+ }
2160
+ for (const [platform, ids] of byPlatform) {
2161
+ if (ids.length > 1) {
2162
+ findings.add("convention", `c4_systems: platform "${platform}" has ${ids.length} rows with kind "platform" [${ids.sort((a, b) => a.localeCompare(b)).join(", ")}] — exactly one per platform (M-ADR 0208 §4.2)`);
2163
+ }
2164
+ }
2165
+ }
2166
+ /**
2167
+ * Flag c4_systems rows that share name AND description (M-ADR 0189 §4.5.3).
2168
+ * PK uniqueness and FK validation cannot see this class: when a sibling's
2169
+ * identity is sourced from a syndicated copy of the platform L1, the id is
2170
+ * right and every other column is wrong. Measured at 29 of 60 systems on one
2171
+ * estate and 17 of 40 on another before the 0.68.0 identity-sourcing fix;
2172
+ * this is the aggregation-tier backstop.
2173
+ *
2174
+ * Exported for testing.
2175
+ */
2176
+ export function checkDuplicateSystemIdentity(systems, findings) {
2177
+ const byIdentity = new Map();
2178
+ for (const s of systems) {
2179
+ const name = String(s["name"] ?? "");
2180
+ const description = String(s["description"] ?? "");
2181
+ if (name === "")
2182
+ continue;
2183
+ const key = JSON.stringify([name, description]);
2184
+ const group = byIdentity.get(key);
2185
+ if (group)
2186
+ group.ids.push(String(s["id"]));
2187
+ else
2188
+ byIdentity.set(key, { name, ids: [String(s["id"])] });
2189
+ }
2190
+ for (const group of byIdentity.values()) {
2191
+ if (group.ids.length < 2)
2192
+ continue;
2193
+ findings.add("convention", `c4_systems: ${group.ids.length} rows share name "${group.name}" and an ` +
2194
+ `identical description [${group.ids.sort((a, b) => a.localeCompare(b)).join(", ")}] — ` +
2195
+ `sibling identity likely sourced from a syndicated platform L1`);
2196
+ }
2197
+ }
2198
+ /**
2199
+ * Does a file exist at this absolute path? `stat` rather than `access` to match
2200
+ * the rest of this module's imports; a directory is deliberately NOT a hit — a
2201
+ * contract_path names a contract file.
2202
+ */
2203
+ async function fileExists(absPath) {
2204
+ try {
2205
+ const st = await stat(absPath);
2206
+ return st.isFile();
2207
+ }
2208
+ catch {
2209
+ return false;
2210
+ }
2211
+ }
2212
+ /**
2213
+ * Derive `cross_sdd_platform_capability` rows by joining each consuming
2214
+ * SDD's `product_platform_dependencies` rows against every platform SDD's
2215
+ * `capability_service` rows on `target_service_id == service_id`.
2216
+ *
2217
+ * Emits one row per resolved (consuming-product, platform-capability)
2218
+ * pair. A consuming dependency whose `target_service_id` doesn't resolve
2219
+ * against any `capability_service` row is logged as an `orphan` finding
2220
+ * and produces no row.
2221
+ *
2222
+ * ASYNC shared-stream dependencies (M-ADR 0192) — a dependency whose
2223
+ * `contract_type` is in the async set (`event`/`event-stream`/`stream`) — have
2224
+ * no single realiser service and carry an empty `target_service_id`. The F18
2225
+ * synchronous realiser-join does not apply to them: instead they resolve via
2226
+ * the capability leg (`target_capability_id` against any `capability_service`
2227
+ * row for that capability) and emit the (consuming-product, platform-capability)
2228
+ * edge with an empty `service_id`. A capability that resolves to no
2229
+ * `capability_service` row ecosystem-wide is an `orphan` finding.
2230
+ *
2231
+ * Duplicate composite ids (same consuming-product → same platform-capability
2232
+ * → same service) are deduped silently — they would arise only when the
2233
+ * same service belongs to multiple capabilities on the platform side,
2234
+ * which is itself a platform-side data quality concern surfaced elsewhere.
2235
+ *
2236
+ * Exported for testing.
2237
+ */
2238
+ export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings, capabilities = []) {
2239
+ // The edge's PLATFORM LEG. `capability_service` has no source_sdd_id column
2240
+ // (schema: id, capability_id, service_id, role), so the row that knows which
2241
+ // platform owns a capability is `capabilities` — its platform_sdd_id
2242
+ // (M-ADR 0208 §4.4) or, on pre-0208 output, its source_sdd_id. From
2243
+ // 2.9.0 (v0.22.0) until 1.19.2 this read `match["source_sdd_id"]` off the
2244
+ // capability_service row and was therefore "" on every emitted row, in
2245
+ // every estate — the id carried a visible empty slot (`a__b____cap__svc`).
2246
+ const platformByCapability = new Map();
2247
+ for (const cap of capabilities) {
2248
+ const id = String(cap["id"] ?? "").trim();
2249
+ if (id === "")
2250
+ continue;
2251
+ const leg = String(cap["platform_sdd_id"] ?? "").trim() ||
2252
+ String(cap["source_sdd_id"] ?? "").trim();
2253
+ if (leg !== "" && !platformByCapability.has(id))
2254
+ platformByCapability.set(id, leg);
2255
+ }
2256
+ const unresolvedLegs = new Set();
2257
+ const resolvePlatformLeg = (match) => {
2258
+ const capId = String(match["capability_id"] ?? "");
2259
+ const leg = platformByCapability.get(capId) ?? String(match["source_sdd_id"] ?? "").trim();
2260
+ if (leg === "" && capId !== "")
2261
+ unresolvedLegs.add(capId);
2262
+ return leg;
2263
+ };
2264
+ // Index capability_service rows by service_id for O(1) lookup. A service
2265
+ // may belong to multiple capabilities — keep them all so a dependency
2266
+ // can resolve into every capability that owns its service. Filter out
2267
+ // rows whose service_id OR capability_id is empty up front, so that
2268
+ // the "no valid join" orphan check below fires when only-malformed
2269
+ // matches exist.
2270
+ const byServiceId = new Map();
2271
+ // Also index by capability_id, for the async capability-leg resolution
2272
+ // (M-ADR 0192) — an async dependency has no service to join on.
2273
+ const byCapabilityId = new Map();
2274
+ for (const cs of capabilityServices) {
2275
+ const svc = String(cs["service_id"] ?? "");
2276
+ const cap = String(cs["capability_id"] ?? "");
2277
+ if (svc === "" || cap === "")
2278
+ continue;
2279
+ const svcBucket = byServiceId.get(svc);
2280
+ if (svcBucket === undefined) {
2281
+ byServiceId.set(svc, [cs]);
2282
+ }
2283
+ else {
2284
+ svcBucket.push(cs);
2285
+ }
2286
+ const capBucket = byCapabilityId.get(cap);
2287
+ if (capBucket === undefined) {
2288
+ byCapabilityId.set(cap, [cs]);
2289
+ }
2290
+ else {
2291
+ capBucket.push(cs);
2292
+ }
2293
+ }
2294
+ const rows = [];
2295
+ const seenIds = new Set();
2296
+ for (const ppd of productPlatformDeps) {
2297
+ // M-ADR 0204: an off-platform row has no realiser to join — its FK columns
2298
+ // are empty by construction, and its validation is §4.2 corroboration, not
2299
+ // this derivation. Explicit skip rather than falling through to the empty-
2300
+ // service branches, so the reason is structural and not incidental.
2301
+ const locus = String(ppd["target_locus"] ?? "").trim().toLowerCase();
2302
+ if (locus === "external" || locus === "legacy")
2303
+ continue;
2304
+ const consumingSddId = String(ppd["source_sdd_id"] ?? "");
2305
+ const consumingProductId = String(ppd["product_id"] ?? "");
2306
+ const targetServiceId = String(ppd["target_service_id"] ?? "");
2307
+ const targetCapabilityId = String(ppd["target_capability_id"] ?? "");
2308
+ const contractType = String(ppd["contract_type"] ?? "");
2309
+ if (consumingProductId === "")
2310
+ continue;
2311
+ // M-ADR 0192: an async shared-stream dependency has no realiser service.
2312
+ // The F18 pair-join cannot apply — resolve via the capability leg and emit
2313
+ // the (consuming-product, platform-capability) edge with an empty service.
2314
+ if (isAsyncContractType(contractType)) {
2315
+ if (targetCapabilityId === "")
2316
+ continue;
2317
+ const capMatches = byCapabilityId.get(targetCapabilityId);
2318
+ if (capMatches === undefined || capMatches.length === 0) {
2319
+ findings.add("orphan", `cross_sdd_platform_capability: ${consumingSddId}/${consumingProductId} declares an async dependency on target_capability_id="${targetCapabilityId}" (contract_type="${contractType}") but no capability_service row realises it ecosystem-wide`);
2320
+ continue;
2321
+ }
2322
+ for (const match of capMatches) {
2323
+ const platformSddId = resolvePlatformLeg(match);
2324
+ const id = [
2325
+ consumingSddId,
2326
+ consumingProductId,
2327
+ platformSddId,
2328
+ targetCapabilityId,
2329
+ "",
2330
+ ].join("__");
2331
+ if (seenIds.has(id))
2332
+ continue;
2333
+ seenIds.add(id);
2334
+ rows.push({
2335
+ id,
2336
+ consuming_sdd_id: consumingSddId,
2337
+ consuming_product_id: consumingProductId,
2338
+ platform_sdd_id: platformSddId,
2339
+ platform_capability_id: targetCapabilityId,
2340
+ service_id: "",
2341
+ });
2342
+ }
2343
+ continue;
2344
+ }
2345
+ // SYNCHRONOUS dependency — the realiser-join path. A row with no service is
2346
+ // a single-sided / malformed entry the builder would not emit; skip it.
2347
+ if (targetServiceId === "")
2348
+ continue;
2349
+ const matches = byServiceId.get(targetServiceId);
2350
+ if (matches === undefined || matches.length === 0) {
2351
+ findings.add("orphan", `cross_sdd_platform_capability: ${consumingSddId}/${consumingProductId} declares target_service_id="${targetServiceId}" but no capability_service row resolves it ecosystem-wide`);
2352
+ continue;
2353
+ }
2354
+ // F18: validate the declared PAIR, not each column alone. Both columns
2355
+ // resolving independently while no capability_service row binds them
2356
+ // is a false pairing accepted in silence — the evidence table for the
2357
+ // pair exists and was never consulted.
2358
+ const declaredCapability = targetCapabilityId;
2359
+ if (declaredCapability !== "") {
2360
+ const pairBound = matches.some((m) => String(m["capability_id"]) === declaredCapability);
2361
+ if (!pairBound) {
2362
+ const actual = [...new Set(matches.map((m) => String(m["capability_id"])))]
2363
+ .sort((a, b) => a.localeCompare(b))
2364
+ .join(", ");
2365
+ findings.add("divergence", `product_platform_dependencies: ${consumingSddId}/${consumingProductId} declares the pair ` +
2366
+ `(target_capability_id="${declaredCapability}", target_service_id="${targetServiceId}") but no ` +
2367
+ `capability_service row binds them — the service resolves to [${actual}] (F18)`);
2368
+ }
2369
+ }
2370
+ for (const match of matches) {
2371
+ const platformSddId = resolvePlatformLeg(match);
2372
+ const platformCapabilityId = String(match["capability_id"] ?? "");
2373
+ if (platformCapabilityId === "")
2374
+ continue;
2375
+ const id = [
2376
+ consumingSddId,
2377
+ consumingProductId,
2378
+ platformSddId,
2379
+ platformCapabilityId,
2380
+ targetServiceId,
2381
+ ].join("__");
2382
+ if (seenIds.has(id))
2383
+ continue;
2384
+ seenIds.add(id);
2385
+ rows.push({
2386
+ id,
2387
+ consuming_sdd_id: consumingSddId,
2388
+ consuming_product_id: consumingProductId,
2389
+ platform_sdd_id: platformSddId,
2390
+ platform_capability_id: platformCapabilityId,
2391
+ service_id: targetServiceId,
2392
+ });
2393
+ }
2394
+ }
2395
+ if (unresolvedLegs.size > 0) {
2396
+ const caps = [...unresolvedLegs].sort((a, b) => a.localeCompare(b));
2397
+ findings.add("coverage", `cross_sdd_platform_capability: platform leg (platform_sdd_id) unresolved for ${caps.length} capability id(s) [${caps.join(", ")}] — no capabilities row carries platform_sdd_id or source_sdd_id for them; the edge id has an empty slot`);
2398
+ }
2399
+ return rows;
2400
+ }
2401
+ // Used by tests that want to round-trip CSV through the aggregator's
2402
+ // stamping logic without writing files.
2403
+ export const __test_only__ = { stampColumn, parseCatalogueCsv };
2404
+ //# sourceMappingURL=aggregate.js.map