@sdd-method/sdd-cli 1.18.0 → 1.20.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.
@@ -32,7 +32,14 @@ export interface AggregateResult {
32
32
  export type Writer = (s: string) => void;
33
33
  export declare function runCatalogueAggregate(inputs: AggregateInputs, write: Writer): Promise<AggregateResult>;
34
34
  export declare function dedupExternalSystems(rowsByCatalogue: Map<string, CatalogueRow[]>, findings: FindingsLog): void;
35
- declare function stampSourceSddId(catalogue: CatalogueDef, rows: readonly Record<string, string>[], sourceSddId: string): CatalogueRow[];
35
+ /**
36
+ * Stamp `column` = `value` on every row that does not already carry one
37
+ * (preserve-if-set — chained aggregation, M-ADR 0134 §4.5 step 4). Used for
38
+ * `source_sdd_id` and, per M-ADR 0208 §4.4, for `platform_sdd_id`. A null value
39
+ * means "do not fill" (organisation tier, where the stamp arrived from below or
40
+ * is legitimately unresolved). No-op when the catalogue lacks the column.
41
+ */
42
+ declare function stampColumn(catalogue: CatalogueDef, rows: readonly CatalogueRow[], column: string, value: string | null): CatalogueRow[];
36
43
  /**
37
44
  * Resolve cross-SDD `screen_feature` edges (schema 2.24.0, R3).
38
45
  *
@@ -144,11 +151,15 @@ export interface ContractRekeySummary {
144
151
  */
145
152
  export declare function rekeyLegacyContractRefs(schema: CanonicalSchema, rowsByCatalogue: Map<string, CatalogueRow[]>, findings: FindingsLog): ContractRekeySummary;
146
153
  /**
147
- * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4: the
148
- * `platform` kind is documented "exactly one per ecosystem after federation
149
- * aggregation" and nothing enforced it. Exported for testing.
154
+ * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4, TIER-GATED by
155
+ * M-ADR 0208 §4.2 — gated, not skipped: a skipped check fails toward success.
156
+ * platform tier: exactly one row with kind "platform" in the aggregate.
157
+ * organisation tier: exactly one "platform" row PER DISTINCT platform_sdd_id,
158
+ * and every "platform" row must carry a stamp — an empty
159
+ * stamp is unresolved, never a key (0208 §4.4).
160
+ * Exported for testing.
150
161
  */
151
- export declare function checkPlatformKindUniqueness(systems: readonly CatalogueRow[], findings: FindingsLog): void;
162
+ export declare function checkPlatformKindUniqueness(systems: readonly CatalogueRow[], findings: FindingsLog, tier?: "platform" | "organisation"): void;
152
163
  /**
153
164
  * Flag c4_systems rows that share name AND description (M-ADR 0189 §4.5.3).
154
165
  * PK uniqueness and FK validation cannot see this class: when a sibling's
@@ -186,9 +197,9 @@ export declare function checkDuplicateSystemIdentity(systems: readonly Catalogue
186
197
  *
187
198
  * Exported for testing.
188
199
  */
189
- export declare function deriveCrossSddPlatformCapability(productPlatformDeps: readonly CatalogueRow[], capabilityServices: readonly CatalogueRow[], findings: FindingsLog): CatalogueRow[];
200
+ export declare function deriveCrossSddPlatformCapability(productPlatformDeps: readonly CatalogueRow[], capabilityServices: readonly CatalogueRow[], findings: FindingsLog, capabilities?: readonly CatalogueRow[]): CatalogueRow[];
190
201
  export declare const __test_only__: {
191
- stampSourceSddId: typeof stampSourceSddId;
202
+ stampColumn: typeof stampColumn;
192
203
  parseCatalogueCsv: typeof parseCatalogueCsv;
193
204
  };
194
205
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../../../src/lib/catalogue/aggregate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"aggregate.d.ts","sourceRoot":"","sources":["../../../src/lib/catalogue/aggregate.ts"],"names":[],"mappings":"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"}
@@ -30,10 +30,14 @@ import { writeCatalogue } from "./csv-writer.js";
30
30
  import { buildLegacyContractIdMap } from "./contract-id.js";
31
31
  import { validateWithEngineToLog } from "./engine-validator.js";
32
32
  import { FindingsLog } from "./findings.js";
33
+ import { readLockFile } from "../lock-file.js";
33
34
  import { mergeEdgeLabel } from "./builders/edge-label-merge.js";
34
35
  import { isAsyncContractType } from "./builders/product-platform-dependencies.js";
35
36
  import { resolveContractPaths } from "./contract-path-resolution.js";
36
37
  import { buildRepoEdges } from "./builders/repo-edges.js";
38
+ import { buildCapabilitySpec } from "./builders/capability-spec.js";
39
+ import { buildFeatureGrounding } from "./builders/feature-grounding.js";
40
+ import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
37
41
  import { buildFeatureContract } from "./builders/feature-contract.js";
38
42
  import { buildCapabilityEvent } from "./builders/event-edges.js";
39
43
  import { discoverVocabulary } from "./application-archetype/vocabulary.js";
@@ -49,7 +53,7 @@ export async function runCatalogueAggregate(inputs, write) {
49
53
  const rootManifest = await loadSddManifest(sddPath, {
50
54
  cliVersion: CLI_VERSION,
51
55
  });
52
- write(` ecosystem-root: ${rootManifest.sddId} profile: ${rootManifest.profile}\n`);
56
+ write(` root: ${rootManifest.sddId} profile: ${rootManifest.profile} tier: ${rootManifest.profile === "organisation-profile" ? "organisation" : "platform"}\n`);
53
57
  // 1. Resolve aggregation-manifest path + load.
54
58
  //
55
59
  // Two anchors, deliberately, because the terms are different KINDS of path:
@@ -75,8 +79,9 @@ export async function runCatalogueAggregate(inputs, write) {
75
79
  ecosystemRootPath: sddPath,
76
80
  });
77
81
  if (aggManifest.ecosystemRoot !== rootManifest.sddId) {
78
- throw new Error(`aggregation manifest ecosystem_root="${aggManifest.ecosystemRoot}"\n` +
79
- ` does not match ecosystem-root SDD's sdd_id="${rootManifest.sddId}"\n` +
82
+ throw new Error(`aggregation manifest root_sdd_id="${aggManifest.ecosystemRoot}"\n` +
83
+ ` does not match the owning SDD's sdd_id="${rootManifest.sddId}"\n` +
84
+ ` (rule: root_sdd_id_consistent, M-ADR 0208 §4.3)\n` +
80
85
  ` (manifest: ${manifestPath})`);
81
86
  }
82
87
  write(` aggregation manifest: ${relative(sddPath, manifestPath)}\n`);
@@ -92,7 +97,12 @@ export async function runCatalogueAggregate(inputs, write) {
92
97
  // 2. Pull (filesystem only in v1) — pre-checked at manifest load. Now
93
98
  // cross-check each sibling's own .sdd/sdd-manifest.yaml + its
94
99
  // vendored schema vs the aggregator's.
95
- await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
100
+ const crossCheck = await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
101
+ if (aggManifest.rootFieldDeprecated) {
102
+ 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)`);
103
+ }
104
+ // M-ADR 0208 §4.2/§4.4: the tier decides the platform_sdd_id fill rule.
105
+ const rootIsOrganisation = rootManifest.profile === "organisation-profile";
96
106
  // 3. Schema-version alignment was checked in crossCheckSiblings.
97
107
  // 4 + 5. Concatenate per-catalogue rows from the ecosystem-root + every
98
108
  // sibling, stamping source_sdd_id on node catalogues as we go.
@@ -101,9 +111,37 @@ export async function runCatalogueAggregate(inputs, write) {
101
111
  for (const cat of schema.catalogues) {
102
112
  rowsByCatalogue.set(cat.name, []);
103
113
  }
104
- perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId));
114
+ perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId, rootIsOrganisation ? null : rootManifest.sddId));
105
115
  for (const sib of aggManifest.siblings) {
106
- perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId));
116
+ // M-ADR 0208 §4.4 fill rule, by pull path. An empty stamp is UNRESOLVED and is
117
+ // reported below by checkPlatformKindUniqueness; it is never a key value.
118
+ let platformSddId;
119
+ if (!rootIsOrganisation) {
120
+ platformSddId = rootManifest.sddId; // every row in a platform-tier aggregate belongs to it
121
+ }
122
+ else if (sib.level === "organisation") {
123
+ platformSddId = null; // arrived stamped from the child's own platform-tier run
124
+ }
125
+ else {
126
+ // CATEGORY decides root-ness, not profile: platform-subsystem is platform-profile
127
+ // too, but it is a MEMBER contained by exactly one platform (M-ADR 0208 §4.1).
128
+ const sibCategory = crossCheck.siblingCategories.get(sib.sddId);
129
+ const declared = crossCheck.siblingDeclaredPlatform.get(sib.sddId);
130
+ if (sibCategory === "platform-root") {
131
+ platformSddId = sib.sddId; // a platform-root is its own platform
132
+ }
133
+ else if (declared !== undefined) {
134
+ platformSddId = declared; // a member that declares its platform gets it
135
+ }
136
+ else {
137
+ platformSddId = null;
138
+ 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)`);
139
+ }
140
+ }
141
+ if (crossCheck.deprecatedPlatformField.has(sib.sddId)) {
142
+ 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)`);
143
+ }
144
+ perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId, platformSddId));
107
145
  }
108
146
  // 5a. Derive cross_sdd_platform_capability — federated edge, schema 2.9.0.
109
147
  // Joins each consuming SDD's product_platform_dependencies rows against
@@ -113,7 +151,7 @@ export async function runCatalogueAggregate(inputs, write) {
113
151
  // emit nothing for this catalogue; the aggregator owns it entirely.
114
152
  // See content/plans/v0-9-0-cohort-sweep-derived-bundle.md
115
153
  // §"Cross-SDD platform-capability-edge" + ADR 0134.
116
- const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings);
154
+ const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings, rowsByCatalogue.get("capabilities") ?? []);
117
155
  rowsByCatalogue.set("cross_sdd_platform_capability", crossSddRows);
118
156
  // Every SDD root in this aggregation, by sdd_id: the ecosystem root plus each
119
157
  // sibling. This is precisely what the app tier does not have and cannot obtain.
@@ -190,15 +228,96 @@ export async function runCatalogueAggregate(inputs, write) {
190
228
  // it, and appending finished per-member joins never creates a row no input produced.
191
229
  //
192
230
  // §4.1's rule (a catalogue qualifies when it is derived AND at least one join input can
193
- // carry a cross-SDD reference) currently selects four: repo_capability, repo_contract,
231
+ // carry a cross-SDD reference) selects five: feature_repo, repo_capability, repo_contract,
194
232
  // feature_contract, capability_event. One shared mechanism (rederiveAppend below) runs
195
- // all four so a fifth qualifying catalogue is a new call, not new merge logic.
233
+ // all five so a sixth qualifying catalogue is a new call, not new merge logic.
234
+ //
235
+ // feature_repo moved into scope under §4.1's "automatic movement" clause: its derived leg
236
+ // resolves free-text evidence tokens (M-ADR 0143 §4.4) against `repositories.id`, and the
237
+ // merged repositories table carries every sibling's repos — a platform spec that names a
238
+ // repo registered by a MEMBER SDD can only resolve here. The spec text is not a catalogue,
239
+ // so the derived leg is re-run the way §4.2 describes for every builder: the two feature
240
+ // extractors are invoked over each manifest-read contributor's docs with the MERGED
241
+ // repository / service / capability id sets as their resolution inputs. Only their
242
+ // feature_repo output is kept; feature rows and their per-row findings were already
243
+ // emitted at member tier and are not repeated. level:organisation siblings expose no docs
244
+ // (pre-built catalogues-aggregated/ only) and are skipped — their rows arrived from their
245
+ // own platform-tier run, where this pass has already happened.
196
246
  //
197
247
  // §4.2: only the derived leg is re-run — each builder is invoked with its authored-leg
198
248
  // inputs (configs / manifests) empty, which runs zero iterations of that path and yields
199
249
  // the derived rows alone; the authored rows are already correct via concatenation and are
200
250
  // left untouched. §4.2/§4.3: rederiveAppend adds the result to what concatenation already
201
251
  // produced, keeping the concatenated row on any primary-key collision — never a replace.
252
+ {
253
+ const mergedRepoIds = new Set((rowsByCatalogue.get("repositories") ?? []).map((r) => String(r["id"])));
254
+ const mergedServiceIds = new Set((rowsByCatalogue.get("services") ?? []).map((r) => String(r["id"])));
255
+ const mergedCapabilityIds = new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"])));
256
+ const mergedFeatureIds = new Set((rowsByCatalogue.get("features") ?? []).map((r) => String(r["id"])));
257
+ // Re-derivation FILLS ABSENCES; it does not widen. A feature its own member
258
+ // build already anchored keeps exactly those anchors: re-running bare-token
259
+ // resolution against every sibling's repo ids over an already-resolved row
260
+ // mostly finds collisions (measured on one estate: 13 of the 43 rows it would
261
+ // have added to anchored features resolved to repo ids like `web`, `proxy`,
262
+ // `service`, `batch` owned by unrelated SDDs; of the 261 rows added to
263
+ // UNANCHORED features, 4 did). The motivating case — and §4.1's clause — is a
264
+ // row no member could produce, which is exactly an unanchored feature.
265
+ const anchoredFeatureIds = new Set((rowsByCatalogue.get("feature_repo") ?? []).map((r) => String(r["feature_id"])));
266
+ const contributors = [
267
+ { sddPath, profile: rootManifest.profile, sddId: rootManifest.sddId },
268
+ ];
269
+ for (const sib of aggManifest.siblings) {
270
+ if (sib.level === "organisation")
271
+ continue;
272
+ const profile = crossCheck.siblingProfiles.get(sib.sddId);
273
+ if (profile === undefined)
274
+ continue;
275
+ contributors.push({ sddPath: sib.sddRoot, profile, sddId: sib.sddId });
276
+ }
277
+ const rederivedFeatureRepo = [];
278
+ // Both extractors, over several contributors, can yield the same edge (the same
279
+ // feature grounded in the same repo from two tables); rederiveAppend dedups only
280
+ // against the CONCATENATED rows, so dedup the re-derived set itself here or the
281
+ // duplicate-PK check fails the aggregate on two copies of one correct fact.
282
+ const rederivedIds = new Set();
283
+ const scratch = new FindingsLog(); // member-tier findings already emitted; not repeated
284
+ for (const c of contributors) {
285
+ const aliases = await loadFeatureGroundingAliases(c.sddPath);
286
+ const common = {
287
+ sddPath: c.sddPath,
288
+ findings: scratch,
289
+ profile: c.profile,
290
+ productId: c.sddId,
291
+ knownCapabilityIds: mergedCapabilityIds,
292
+ knownServiceIds: mergedServiceIds,
293
+ knownRepoKeys: mergedRepoIds,
294
+ existingFeatureIds: new Set(),
295
+ aliases,
296
+ };
297
+ const [grounding, spec] = await Promise.all([
298
+ buildFeatureGrounding(common),
299
+ buildCapabilitySpec(common),
300
+ ]);
301
+ for (const fr of [...grounding.featureRepoRows, ...spec.featureRepoRows]) {
302
+ const featureId = String(fr["feature_id"] ?? "");
303
+ if (!mergedFeatureIds.has(featureId))
304
+ continue;
305
+ if (anchoredFeatureIds.has(featureId))
306
+ continue;
307
+ const id = String(fr["id"] ?? "");
308
+ if (id === "" || rederivedIds.has(id))
309
+ continue;
310
+ rederivedIds.add(id);
311
+ rederivedFeatureRepo.push(fr);
312
+ }
313
+ }
314
+ const before = (rowsByCatalogue.get("feature_repo") ?? []).length;
315
+ rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo);
316
+ const added = (rowsByCatalogue.get("feature_repo") ?? []).length - before;
317
+ if (added > 0) {
318
+ 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)`);
319
+ }
320
+ }
202
321
  const repoEdges = buildRepoEdges({
203
322
  findings,
204
323
  configs: [],
@@ -266,7 +385,7 @@ export async function runCatalogueAggregate(inputs, write) {
266
385
  // tier alongside an existing one), each contributes the same method ADR ids. Merge
267
386
  // them (first-seen wins) rather than failing the duplicate-PK check. Platform
268
387
  // / domain ADRs are NOT deduped — a collision there is a genuine error.
269
- dedupMethodScopedAdrs(rowsByCatalogue, findings);
388
+ await dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById);
270
389
  // 5c. Federated reverse-metadata enrichment (ADR 0163 P2). Both reference
271
390
  // adopters centralise the service inventory in the platform-root, but for
272
391
  // reverse-method federations the per-service evidence (service-architecture.md)
@@ -306,7 +425,7 @@ export async function runCatalogueAggregate(inputs, write) {
306
425
  `${adjudication.systemFallbackEdges} stuck edges → system_system_dependency at L1), ` +
307
426
  `${adjudication.exempted} exempted, ${adjudication.unverified} unverified\n`);
308
427
  // 5g. Platform-kind uniqueness (M-ADR 0133 §4.2c open hygiene).
309
- checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings);
428
+ checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings, rootIsOrganisation ? "organisation" : "platform");
310
429
  // 5h. M-ADR 0203 §4.4 cross-SDD re-key: the contract-id migration derives
311
430
  // per-SDD, but contract-FK edges cross SDD boundaries — a federated SDD
312
431
  // that owns no contracts has an empty map, so its references keep the 2.x
@@ -511,20 +630,58 @@ export function dedupExternalSystems(rowsByCatalogue, findings) {
511
630
  }
512
631
  rowsByCatalogue.set("c4_external_systems", [...byId.values()]);
513
632
  }
514
- // Method-scoped ADRs (doc_path under docs/method/adr/) are the shared method
515
- // baseline, identical across method-based SDDs. Dedupe by id (first-seen wins),
516
- // preserving order and leaving platform/domain ADRs untouched. A conflicting
517
- // title/status on the same id emits a finding. Mirrors dedupExternalSystems.
518
- function dedupMethodScopedAdrs(rowsByCatalogue, findings) {
633
+ // Method-DISTRIBUTED ADRs are the shared method baseline, identical across
634
+ // method-based SDDs. Dedupe by id (first-seen wins), preserving order and leaving
635
+ // adopter-AUTHORED platform/domain ADRs untouched — a collision there is a genuine
636
+ // error and MUST still fail the duplicate-PK check. Mirrors dedupExternalSystems.
637
+ //
638
+ // WHAT COUNTS AS METHOD-DISTRIBUTED (M-ADR 0208 / gov F-E): a row whose doc_path is
639
+ // BUNDLE-MANAGED in the contributing SDD's method-baseline.lock. Provenance, not
640
+ // path prefix. The earlier test `startsWith("docs/method/adr/")` was a proxy for
641
+ // the same property and it failed the first time two platform-roots aggregated:
642
+ // the baseline also distributes docs/method/platform-adr/ (13 ADR-emitting files
643
+ // in platform-baseline v2.3.28), which the prefix missed, so identical
644
+ // bundle-managed rows from two roots collided as if adopter-authored. A prefix
645
+ // extension would have passed every test available that day and merged genuinely
646
+ // authored platform ADRs the next time an adopter wrote a colliding one; the lock
647
+ // already records exactly the property we mean.
648
+ //
649
+ // FALLBACK: a contributing SDD with no lock (fixtures; a repo that has never been
650
+ // synced) keeps the historical prefix test, so it is never LESS deduped than before.
651
+ async function dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById) {
519
652
  const rows = rowsByCatalogue.get("adrs");
520
653
  if (rows === undefined || rows.length === 0)
521
654
  return;
655
+ // source_sdd_id -> set of bundle-managed paths, or null when that SDD has no lock.
656
+ const managedBySdd = new Map();
657
+ for (const [sddId, root] of sddRootsById) {
658
+ const lock = await readLockFile(root);
659
+ if (lock === null) {
660
+ managedBySdd.set(sddId, null);
661
+ continue;
662
+ }
663
+ const managed = new Set();
664
+ for (const b of lock.bundles)
665
+ for (const mp of b.managedPaths)
666
+ managed.add(mp);
667
+ managedBySdd.set(sddId, managed);
668
+ }
669
+ const isMethodDistributed = (row) => {
670
+ const docPath = String(row["doc_path"] ?? "");
671
+ if (docPath === "")
672
+ return false;
673
+ const managed = managedBySdd.get(String(row["source_sdd_id"] ?? ""));
674
+ if (managed === undefined || managed === null) {
675
+ // No lock for this contributor: historical prefix test.
676
+ return docPath.startsWith("docs/method/adr/");
677
+ }
678
+ return managed.has(docPath);
679
+ };
522
680
  const seen = new Map();
523
681
  const out = [];
524
682
  for (const row of rows) {
525
- const isMethod = String(row["doc_path"] ?? "").startsWith("docs/method/adr/");
526
683
  const id = String(row["id"] ?? "");
527
- if (!isMethod || id === "") {
684
+ if (!isMethodDistributed(row) || id === "") {
528
685
  out.push(row);
529
686
  continue;
530
687
  }
@@ -538,10 +695,10 @@ function dedupMethodScopedAdrs(rowsByCatalogue, findings) {
538
695
  const a = String(existing[attr] ?? "");
539
696
  const b = String(row[attr] ?? "");
540
697
  if (a !== "" && b !== "" && a !== b) {
541
- findings.add("convention", `adrs: method-scoped id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
698
+ 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`);
542
699
  }
543
700
  }
544
- // duplicate method ADR — already kept first-seen; drop this copy.
701
+ // duplicate method-distributed ADR — already kept first-seen; drop this copy.
545
702
  }
546
703
  rowsByCatalogue.set("adrs", out);
547
704
  }
@@ -686,7 +843,9 @@ function findDuplicatePrimaryKeys(schema, rowsByCatalogue) {
686
843
  }
687
844
  return out;
688
845
  }
689
- async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId) {
846
+ async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId,
847
+ /** M-ADR 0208 §4.4: value to fill into `platform_sdd_id` when empty; null = do not fill. */
848
+ platformSddId) {
690
849
  const counts = new Map();
691
850
  let dirExists = true;
692
851
  try {
@@ -717,26 +876,35 @@ async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSdd
717
876
  }
718
877
  throw err;
719
878
  }
720
- const stamped = stampSourceSddId(cat, parsed.rows, sourceSddId);
879
+ const stamped = stampColumn(cat, stampColumn(cat, parsed.rows, "source_sdd_id", sourceSddId), "platform_sdd_id", platformSddId);
721
880
  rowsByCatalogue.get(cat.name).push(...stamped);
722
881
  counts.set(cat.name, stamped.length);
723
882
  }
724
883
  return counts;
725
884
  }
726
- function stampSourceSddId(catalogue, rows, sourceSddId) {
727
- const hasColumn = catalogue.columns.some((c) => c.name === "source_sdd_id");
728
- if (!hasColumn)
885
+ /**
886
+ * Stamp `column` = `value` on every row that does not already carry one
887
+ * (preserve-if-set — chained aggregation, M-ADR 0134 §4.5 step 4). Used for
888
+ * `source_sdd_id` and, per M-ADR 0208 §4.4, for `platform_sdd_id`. A null value
889
+ * means "do not fill" (organisation tier, where the stamp arrived from below or
890
+ * is legitimately unresolved). No-op when the catalogue lacks the column.
891
+ */
892
+ function stampColumn(catalogue, rows, column, value) {
893
+ const hasColumn = catalogue.columns.some((c) => c.name === column);
894
+ if (!hasColumn || value === null)
729
895
  return rows.map((r) => ({ ...r }));
730
896
  return rows.map((r) => {
731
- if (r["source_sdd_id"] && r["source_sdd_id"].trim() !== "") {
732
- // Already stamped — preserve (chained aggregation).
897
+ if (String(r[column] ?? "").trim() !== "")
733
898
  return { ...r };
734
- }
735
- return { ...r, source_sdd_id: sourceSddId };
899
+ return { ...r, [column]: value };
736
900
  });
737
901
  }
738
902
  async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema, write) {
739
903
  const findings = new FindingsLog();
904
+ const siblingProfiles = new Map();
905
+ const siblingCategories = new Map();
906
+ const siblingDeclaredPlatform = new Map();
907
+ const deprecatedPlatformField = new Set();
740
908
  for (const sib of siblings) {
741
909
  let sibProfile;
742
910
  if (sib.level === "organisation") {
@@ -761,7 +929,13 @@ async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema,
761
929
  ` (rule: sibling_id_matches_sibling_manifest)`);
762
930
  }
763
931
  sibProfile = sibManifest.profile;
932
+ siblingCategories.set(sib.sddId, sibManifest.category);
933
+ if (sibManifest.platformSddId)
934
+ siblingDeclaredPlatform.set(sib.sddId, sibManifest.platformSddId);
935
+ if (sibManifest.platformFieldDeprecated)
936
+ deprecatedPlatformField.add(sib.sddId);
764
937
  }
938
+ siblingProfiles.set(sib.sddId, sibProfile);
765
939
  // Cross-check sibling's vendored catalogue-schema.yaml against the
766
940
  // declared compat range. The aggregator's own canonical schema is the
767
941
  // source of truth; the sibling's vendored copy is informational and
@@ -784,7 +958,7 @@ async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema,
784
958
  }
785
959
  write(` ✓ ${sib.sddId} (${sibProfile})\n`);
786
960
  }
787
- return { findings };
961
+ return { findings, siblingProfiles, siblingCategories, siblingDeclaredPlatform, deprecatedPlatformField };
788
962
  }
789
963
  async function findVendoredSchema(sddRoot) {
790
964
  // Adopters typically vendor canonical schema indirectly via sdd-cli.
@@ -1681,18 +1855,44 @@ export function rekeyLegacyContractRefs(schema, rowsByCatalogue, findings) {
1681
1855
  return { rekeyed, deduped, ambiguous };
1682
1856
  }
1683
1857
  /**
1684
- * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4: the
1685
- * `platform` kind is documented "exactly one per ecosystem after federation
1686
- * aggregation" and nothing enforced it. Exported for testing.
1858
+ * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4, TIER-GATED by
1859
+ * M-ADR 0208 §4.2 — gated, not skipped: a skipped check fails toward success.
1860
+ * platform tier: exactly one row with kind "platform" in the aggregate.
1861
+ * organisation tier: exactly one "platform" row PER DISTINCT platform_sdd_id,
1862
+ * and every "platform" row must carry a stamp — an empty
1863
+ * stamp is unresolved, never a key (0208 §4.4).
1864
+ * Exported for testing.
1687
1865
  */
1688
- export function checkPlatformKindUniqueness(systems, findings) {
1689
- const plats = systems
1690
- .filter((s) => String(s["kind"]) === "platform")
1866
+ export function checkPlatformKindUniqueness(systems, findings, tier = "platform") {
1867
+ const plats = systems.filter((s) => String(s["kind"]) === "platform");
1868
+ if (tier === "platform") {
1869
+ const ids = plats.map((s) => String(s["id"])).sort((a, b) => a.localeCompare(b));
1870
+ if (ids.length > 1) {
1871
+ findings.add("convention", `c4_systems: ${ids.length} rows with kind "platform" [${ids.join(", ")}] — ` +
1872
+ `exactly one per platform (M-ADR 0208 §4.2); this is a platform-tier aggregate`);
1873
+ }
1874
+ return;
1875
+ }
1876
+ const unstamped = plats
1877
+ .filter((s) => String(s["platform_sdd_id"] ?? "").trim() === "")
1691
1878
  .map((s) => String(s["id"]))
1692
1879
  .sort((a, b) => a.localeCompare(b));
1693
- if (plats.length > 1) {
1694
- findings.add("convention", `c4_systems: ${plats.length} rows with kind "platform" [${plats.join(", ")}] — ` +
1695
- `the metamodel expects exactly one per ecosystem after aggregation`);
1880
+ if (unstamped.length > 0) {
1881
+ 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)`);
1882
+ }
1883
+ const byPlatform = new Map();
1884
+ for (const s of plats) {
1885
+ const key = String(s["platform_sdd_id"] ?? "").trim();
1886
+ if (key === "")
1887
+ continue;
1888
+ const list = byPlatform.get(key) ?? [];
1889
+ list.push(String(s["id"]));
1890
+ byPlatform.set(key, list);
1891
+ }
1892
+ for (const [platform, ids] of byPlatform) {
1893
+ if (ids.length > 1) {
1894
+ 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)`);
1895
+ }
1696
1896
  }
1697
1897
  }
1698
1898
  /**
@@ -1767,7 +1967,32 @@ async function fileExists(absPath) {
1767
1967
  *
1768
1968
  * Exported for testing.
1769
1969
  */
1770
- export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings) {
1970
+ export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings, capabilities = []) {
1971
+ // The edge's PLATFORM LEG. `capability_service` has no source_sdd_id column
1972
+ // (schema: id, capability_id, service_id, role), so the row that knows which
1973
+ // platform owns a capability is `capabilities` — its platform_sdd_id
1974
+ // (M-ADR 0208 §4.4) or, on pre-0208 output, its source_sdd_id. From
1975
+ // 2.9.0 (v0.22.0) until 1.19.2 this read `match["source_sdd_id"]` off the
1976
+ // capability_service row and was therefore "" on every emitted row, in
1977
+ // every estate — the id carried a visible empty slot (`a__b____cap__svc`).
1978
+ const platformByCapability = new Map();
1979
+ for (const cap of capabilities) {
1980
+ const id = String(cap["id"] ?? "").trim();
1981
+ if (id === "")
1982
+ continue;
1983
+ const leg = String(cap["platform_sdd_id"] ?? "").trim() ||
1984
+ String(cap["source_sdd_id"] ?? "").trim();
1985
+ if (leg !== "" && !platformByCapability.has(id))
1986
+ platformByCapability.set(id, leg);
1987
+ }
1988
+ const unresolvedLegs = new Set();
1989
+ const resolvePlatformLeg = (match) => {
1990
+ const capId = String(match["capability_id"] ?? "");
1991
+ const leg = platformByCapability.get(capId) ?? String(match["source_sdd_id"] ?? "").trim();
1992
+ if (leg === "" && capId !== "")
1993
+ unresolvedLegs.add(capId);
1994
+ return leg;
1995
+ };
1771
1996
  // Index capability_service rows by service_id for O(1) lookup. A service
1772
1997
  // may belong to multiple capabilities — keep them all so a dependency
1773
1998
  // can resolve into every capability that owns its service. Filter out
@@ -1827,7 +2052,7 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1827
2052
  continue;
1828
2053
  }
1829
2054
  for (const match of capMatches) {
1830
- const platformSddId = String(match["source_sdd_id"] ?? "");
2055
+ const platformSddId = resolvePlatformLeg(match);
1831
2056
  const id = [
1832
2057
  consumingSddId,
1833
2058
  consumingProductId,
@@ -1875,7 +2100,7 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1875
2100
  }
1876
2101
  }
1877
2102
  for (const match of matches) {
1878
- const platformSddId = String(match["source_sdd_id"] ?? "");
2103
+ const platformSddId = resolvePlatformLeg(match);
1879
2104
  const platformCapabilityId = String(match["capability_id"] ?? "");
1880
2105
  if (platformCapabilityId === "")
1881
2106
  continue;
@@ -1899,9 +2124,13 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1899
2124
  });
1900
2125
  }
1901
2126
  }
2127
+ if (unresolvedLegs.size > 0) {
2128
+ const caps = [...unresolvedLegs].sort((a, b) => a.localeCompare(b));
2129
+ 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`);
2130
+ }
1902
2131
  return rows;
1903
2132
  }
1904
2133
  // Used by tests that want to round-trip CSV through the aggregator's
1905
2134
  // stamping logic without writing files.
1906
- export const __test_only__ = { stampSourceSddId, parseCatalogueCsv };
2135
+ export const __test_only__ = { stampColumn, parseCatalogueCsv };
1907
2136
  //# sourceMappingURL=aggregate.js.map