@sdd-method/author-cli 0.9.0 → 0.11.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.
@@ -47,6 +47,7 @@ export const AUTHOR_ONLY_SURFACE = [
47
47
  "context pull",
48
48
  "catalogue check",
49
49
  "migrate vocabulary",
50
+ "migrate intents",
50
51
  ];
51
52
  /** The full surface: verify's, plus the author's own. */
52
53
  export const AUTHOR_SURFACE = [...VERIFY_SURFACE, ...AUTHOR_ONLY_SURFACE];
@@ -204,4 +204,17 @@ export function surfaceMatches(entry, contractId, specPath) {
204
204
  const re = new RegExp(`(^|/)${e.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
205
205
  return re.test(specPath.split("\\").join("/"));
206
206
  }
207
+ /**
208
+ * The delta entries a work order's `implements` entry names (schema 5.0.0). `<feature>::<name>` names exactly one
209
+ * requirement. A bare name resolves only when one feature in the change has a requirement of that name; across
210
+ * two features it is ambiguous, and names none.
211
+ */
212
+ export function resolveImplements(delta, entry) {
213
+ const q = /^(.+?)::(.+)$/.exec(entry);
214
+ if (q)
215
+ return { matches: delta.filter((d) => d.feature === q[1] && d.name === q[2]), ambiguous: [] };
216
+ const matches = delta.filter((d) => d.name === entry);
217
+ const features = [...new Set(matches.map((d) => d.feature))];
218
+ return features.length > 1 ? { matches: [], ambiguous: features } : { matches, ambiguous: [] };
219
+ }
207
220
  //# sourceMappingURL=archive.js.map
@@ -35,6 +35,7 @@ import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./bu
35
35
  import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
36
36
  import { buildRepoDependencies } from "./builders/repo-dependency.js";
37
37
  import { buildRequirements } from "./builders/requirements.js";
38
+ import { buildJourneyRequirement } from "./builders/journey-requirement.js";
38
39
  import { buildDomains, buildProducts, buildApplicationDataPlaneBindings, buildApplicationDataPlaneDeviations, buildApplicationConformanceCoverage, buildImplementations, buildRepositories, buildServices, buildCapabilities, buildCapabilityService, buildCapabilityAdr, buildCapabilityFeature, buildCapabilityContract, buildAdrs, buildAdrSupersedes, buildFeatures, buildFeatureGroups, buildFeatureGrounding, buildCapabilitySpec, buildContracts, buildDeclaredExternalContracts, buildRepoEdges, deriveFeatureRepoFromServices, buildRepoCapabilityExemption, buildServiceCapabilityExemption, buildEvents, buildServiceEvent, buildCapabilityEvent, buildFeatureEvent, buildServiceContract, buildServiceContractConsumes, buildFeatureContract, buildDomainModels, buildCapabilityDomainModel, buildFeatureDomainModel, buildFeatureSubscriptionChannels, buildProductPlatformDependencies, buildGlossaryItems, backfillGlossaryRelations, buildGlossaryXref, buildC4Systems, buildC4Containers, buildC4Components, buildC4ExternalSystems, buildExternalSystemExemption, buildContainerDependency, reportEntryIdReferentCollisions, buildSystemExternalDependency, buildPersonaC4System, buildPersonaContainer, buildPersonas, buildQualityScores, buildScaffolds, buildScreens, buildUserJourneys, buildPersonaJourney, buildPersonaFeature, buildJourneyFeature, buildProductEdges, buildIntegrations, entitySlug, buildEntitiesFromManifest, buildEntitiesFromMermaid, buildServiceEntityFromTables, enrichEntityPersistenceKind, buildC4ContainerEntity, buildC4SystemEntity, } from "./builders/index.js";
39
40
  import { buildIntegrationComponents } from "./builders/integration-components.js";
40
41
  import { loadReverseProductQuality } from "./reverse-product-quality.js";
@@ -1163,6 +1164,7 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1163
1164
  findings,
1164
1165
  knownFeatureIds: new Set((out.get("features") ?? []).map((row) => String(row["id"]))),
1165
1166
  knownRepoIds: knownRepoIdsFinal,
1167
+ sddId: manifest.sddId,
1166
1168
  });
1167
1169
  out.set("requirements", r.requirements);
1168
1170
  out.set("requirement_lineage", r.lineage);
@@ -1170,6 +1172,16 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1170
1172
  out.set("changes", r.changes);
1171
1173
  out.set("change_work_order", r.workOrders);
1172
1174
  out.set("change_work_item", r.workItems);
1175
+ // M-ADR 0229 §4.4 (Proposed): the requirements each journey cites, from its tagged end-to-end files or its
1176
+ // journey feature's living spec.
1177
+ out.set("journey_requirement", await buildJourneyRequirement({
1178
+ sddPath,
1179
+ findings,
1180
+ testPaths: manifest.journeys?.testPaths ?? [],
1181
+ features: out.get("features") ?? [],
1182
+ requirements: r.requirements,
1183
+ knownJourneyIds: new Set((out.get("user_journeys") ?? []).map((row) => String(row["id"]))),
1184
+ }));
1173
1185
  }
1174
1186
  // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1175
1187
  // is the forward-method authoring path reading
@@ -133,6 +133,7 @@ export async function buildCapabilitySpec(inputs) {
133
133
  status: "active",
134
134
  source: "capability-spec",
135
135
  runway: "",
136
+ kind: "feature",
136
137
  source_sdd_id: "",
137
138
  });
138
139
  }
@@ -153,6 +153,7 @@ export async function buildFeatureGrounding(inputs) {
153
153
  status: "active",
154
154
  source: "feature-grounding",
155
155
  runway: "",
156
+ kind: "feature",
156
157
  source_sdd_id: "",
157
158
  });
158
159
  const cfId = `${capabilityId}__${featureId}`;
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
89
89
  parent_feature_id: "",
90
90
  description,
91
91
  maturity: "",
92
- status: "active",
92
+ status: featureStatus(text),
93
93
  source: "feature-spec",
94
94
  runway: "",
95
+ kind: featureKind(text),
95
96
  source_sdd_id: "",
96
97
  });
97
98
  idByPath.set(relPath, id);
@@ -123,9 +124,10 @@ export async function buildFeatures(inputs) {
123
124
  parent_feature_id: "",
124
125
  description,
125
126
  maturity: "",
126
- status: "active",
127
+ status: featureStatus(text),
127
128
  source: "feature-spec",
128
129
  runway: "",
130
+ kind: featureKind(text),
129
131
  source_sdd_id: "",
130
132
  });
131
133
  idByPath.set(relPath, id);
@@ -145,6 +147,24 @@ export async function buildFeatures(inputs) {
145
147
  }
146
148
  return { rows, idByPath, idBySlug };
147
149
  }
150
+ /**
151
+ * A feature spec's catalogue status: `retired` when the spec says `**Status:** retired` (its behaviour retired by
152
+ * an archived change, the feature kept in the catalogue so its retired requirements still resolve), else
153
+ * `active`. A retired feature stays where it is; it does not have to move to withdrawn/ first.
154
+ */
155
+ export function featureStatus(text) {
156
+ const m = /^\*\*Status:\*\*\s*([A-Za-z-]+)/m.exec(text) ?? /^>.*\bStatus:\s*([A-Za-z-]+)/m.exec(text);
157
+ return m?.[1]?.toLowerCase() === "retired" ? "retired" : "active";
158
+ }
159
+ /**
160
+ * A feature spec's kind (schema 4.4.0): the value of its `**Kind:**` line, lower-cased, else `feature`. An
161
+ * obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2, Proposed) say so; any other value is written as given, so
162
+ * the catalogue's vocabulary gate names the spec rather than a default hiding the mistake.
163
+ */
164
+ export function featureKind(text) {
165
+ const m = /^\*\*Kind:\*\*\s*([A-Za-z-]+)/m.exec(text);
166
+ return m?.[1]?.toLowerCase() ?? "feature";
167
+ }
148
168
  async function emitBulletRows(ctx) {
149
169
  const featureTreePath = await findFeatureTreeFile(ctx.sddPath);
150
170
  if (!featureTreePath)
@@ -188,6 +208,7 @@ async function emitBulletRows(ctx) {
188
208
  status: "active",
189
209
  source: "feature-tree",
190
210
  runway: ev.runway,
211
+ kind: "feature",
191
212
  source_sdd_id: "",
192
213
  });
193
214
  if (!ctx.idBySlug.has(ev.slug))
@@ -0,0 +1,144 @@
1
+ // journey_requirement: the requirements a user journey cites for its steps (M-ADR 0229 §4.4, Proposed; schema 4.4.0).
2
+ //
3
+ // Derived, never authored, from either master (§4.2):
4
+ // tag option A: the end-to-end `.feature` files under the manifest's `journeys.test_paths`. A file tagged
5
+ // `@journey:<journey-id>` cites every `@req:<feature-id>/<requirement-slug>` tag it carries.
6
+ // citation option B: the living spec (openspec/specs/<feature-id>/spec.md) of a feature of kind journey names its
7
+ // journey with the same `@journey:<journey-id>` tag and cites each step's requirement with `@req:`.
8
+ // One tag grammar for both, so the derivation needs only the files and the catalogue's rows. Both are read whenever
9
+ // present, so an estate moving from A to B shows both while it moves.
10
+ //
11
+ // A requirement tag cannot carry spaces, so it names the requirement by slug: its name lower-cased, each run of
12
+ // anything but a-z and 0-9 turned into one hyphen. A tag resolves when exactly one requirement of that feature,
13
+ // living or agreed (not retired), has that slug; a tag naming a retired requirement names none, so it is a finding. A tag that resolves to none or to several, a file with two journey tags, or a test
14
+ // path that does not exist is a finding and produces no row, so no foreign key dangles. A tag naming a feature or a
15
+ // journey this catalogue does not hold is reported as external: journeys are often catalogued in application SDDs
16
+ // and requirements in the platform's store, so the row is derived at aggregation, where both are merged
17
+ // (catalogue aggregate re-runs this builder over each contributor). A journey with no row is untraced, which the
18
+ // journey-impact check reports.
19
+ import { readdir, readFile, stat } from "node:fs/promises";
20
+ import { join, relative } from "node:path";
21
+ const REQ_TAG = /@req:([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)/g;
22
+ const JOURNEY_TAG = /@journey:([a-z0-9][a-z0-9-]*)/g;
23
+ /** "Order shipped (REST)" -> "order-shipped-rest". */
24
+ export function requirementSlug(name) {
25
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
26
+ }
27
+ async function featureFiles(dir) {
28
+ const out = [];
29
+ let entries;
30
+ try {
31
+ entries = await readdir(dir, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ return out;
35
+ }
36
+ for (const e of entries) {
37
+ if (e.name === "node_modules" || e.name.startsWith("."))
38
+ continue;
39
+ const p = join(dir, e.name);
40
+ if (e.isDirectory())
41
+ out.push(...(await featureFiles(p)));
42
+ else if (e.isFile() && e.name.endsWith(".feature"))
43
+ out.push(p);
44
+ }
45
+ return out.sort();
46
+ }
47
+ export async function buildJourneyRequirement(inputs) {
48
+ // At aggregation the member builds have already reported every finding about their own files and features.
49
+ const atAggregate = inputs.reportedFeatureIds !== undefined;
50
+ const { sddPath, findings } = inputs;
51
+ const bySlug = new Map();
52
+ for (const r of inputs.requirements) {
53
+ if (String(r["stage"] ?? "") === "retired")
54
+ continue; // a journey step cannot rely on behaviour that has been removed
55
+ const key = `${String(r["feature_id"])}/${requirementSlug(String(r["name"]))}`;
56
+ const ids = bySlug.get(key) ?? [];
57
+ if (!ids.includes(String(r["id"])))
58
+ ids.push(String(r["id"]));
59
+ bySlug.set(key, ids);
60
+ }
61
+ const rows = new Map();
62
+ const knownFeatures = new Set(inputs.features.map((f) => String(f["id"])));
63
+ const cite = (journeyId, text, where, basis) => {
64
+ for (const m of text.matchAll(REQ_TAG)) {
65
+ const quiet = atAggregate && inputs.reportedFeatureIds.has(m[1]);
66
+ if (!knownFeatures.has(m[1])) {
67
+ findings.add("external", `${where}: @req:${m[1]}/${m[2]} names a feature this catalogue does not hold; resolves at aggregation (M-ADR 0229 §4.4, Proposed)`);
68
+ continue;
69
+ }
70
+ const ids = bySlug.get(`${m[1]}/${m[2]}`) ?? [];
71
+ if (ids.length !== 1) {
72
+ if (!quiet)
73
+ findings.add("orphan", `${where}: @req:${m[1]}/${m[2]} ${ids.length === 0 ? "names no requirement of that feature" : `matches ${ids.length} requirements of that feature`}; no journey_requirement row (M-ADR 0229 §4.4, Proposed)`);
74
+ continue;
75
+ }
76
+ const id = `${journeyId}__${ids[0]}`;
77
+ if (!rows.has(id))
78
+ rows.set(id, { id, journey_id: journeyId, requirement_id: ids[0], basis, source_sdd_id: "", platform_sdd_id: "" });
79
+ }
80
+ };
81
+ // Option A: tagged end-to-end files.
82
+ for (const rel of inputs.testPaths) {
83
+ const dir = join(sddPath, rel);
84
+ const exists = await stat(dir).then((s) => s.isDirectory(), () => false);
85
+ if (!exists) {
86
+ if (!atAggregate)
87
+ findings.add("orphan", `.sdd/sdd-manifest.yaml: journeys.test_paths names ${rel}, which is not a directory in this SDD`);
88
+ continue;
89
+ }
90
+ for (const file of await featureFiles(dir)) {
91
+ const where = relative(sddPath, file);
92
+ const text = await readFile(file, "utf8");
93
+ const journeys = [...new Set([...text.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
94
+ if (journeys.length === 0)
95
+ continue;
96
+ if (journeys.length > 1) {
97
+ if (!atAggregate)
98
+ findings.add("divergence", `${where}: tagged with ${journeys.length} journeys (${journeys.join(", ")}); one file is one journey's master (M-ADR 0229 §4.2, Proposed)`);
99
+ continue;
100
+ }
101
+ const journeyId = journeys[0];
102
+ if (!inputs.knownJourneyIds.has(journeyId)) {
103
+ findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
104
+ continue;
105
+ }
106
+ cite(journeyId, text, where, "tag");
107
+ }
108
+ }
109
+ // Option B: journey features in the store, each naming its journey in its living spec.
110
+ const kindById = new Map(inputs.features.map((f) => [String(f["id"]), String(f["kind"] ?? "")]));
111
+ let specDirs = [];
112
+ try {
113
+ specDirs = (await readdir(join(sddPath, "openspec", "specs"), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
114
+ }
115
+ catch {
116
+ // no store here
117
+ }
118
+ for (const featureId of specDirs) {
119
+ if (kindById.get(featureId) !== "journey")
120
+ continue;
121
+ const where = join("openspec", "specs", featureId, "spec.md");
122
+ let spec;
123
+ try {
124
+ spec = await readFile(join(sddPath, where), "utf8");
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ const journeys = [...new Set([...spec.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
130
+ if (journeys.length !== 1) {
131
+ if (!atAggregate)
132
+ findings.add(journeys.length === 0 ? "orphan" : "divergence", `${where}: a journey feature's living spec ${journeys.length === 0 ? "names no journey (@journey:<journey-id>)" : `names ${journeys.length} journeys`}; one journey feature masters one journey (M-ADR 0229 §4.2, Proposed)`);
133
+ continue;
134
+ }
135
+ const journeyId = journeys[0];
136
+ if (!inputs.knownJourneyIds.has(journeyId)) {
137
+ findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
138
+ continue;
139
+ }
140
+ cite(journeyId, spec, where, "citation");
141
+ }
142
+ return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
143
+ }
144
+ //# sourceMappingURL=journey-requirement.js.map
@@ -7,9 +7,12 @@
7
7
  // requirements one row per `### Requirement:` in a living spec (stage living), keyed by the spec's
8
8
  // directory name, which is the catalogue feature id; plus one per requirement an open,
9
9
  // agreed change ADDS (stage agreed).
10
- // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or RENAMED a
11
- // requirement that is current now, under any name a later RENAMED gave it. A change carrying
10
+ // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED, RENAMED or REMOVED a
11
+ // requirement, under any name a later RENAMED gave it. A change carrying
12
12
  // baseline-evidence.md is a baseline. Statuses follow renames the same way.
13
+ // A requirement REMOVED by an archived change and not in a living spec now is kept in
14
+ // requirements as stage retired (schema 4.3.0), so its lineage ends in the REMOVED row;
15
+ // it carries no status.
13
16
  // changes each change in the store, open or archived (M-ADR 0223 §4.2): its directory name, its
14
17
  // undated name, kind, status (archived, or its agreement record's) and verdict.
15
18
  // change_work_order per (change, repository): the record's work order, agreed or completed.
@@ -26,8 +29,8 @@
26
29
  import { readdir, readFile, stat } from "node:fs/promises";
27
30
  import { join } from "node:path";
28
31
  import { parse as parseYaml } from "yaml";
29
- import { observedIn, orderArchives, parseRequirements, renamePairs } from "../../behaviour-store/archive.js";
30
- export { observedIn, parseRequirements };
32
+ import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
33
+ export { observedIn, parseRequirements, resolveImplements };
31
34
  const STORE = "openspec";
32
35
  const AGREEMENTS = join("docs", "agreements");
33
36
  async function isDir(p) {
@@ -125,6 +128,7 @@ export function parseWorkItems(proposal) {
125
128
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
126
129
  export async function buildRequirements(inputs) {
127
130
  const { sddPath, findings } = inputs;
131
+ const scoped = (change) => `${inputs.sddId}::${change}`;
128
132
  const empty = { requirements: [], lineage: [], status: [], changes: [], workOrders: [], workItems: [] };
129
133
  const store = join(sddPath, STORE);
130
134
  if (!(await isDir(store)))
@@ -159,8 +163,9 @@ export async function buildRequirements(inputs) {
159
163
  const workOrders = [];
160
164
  const workItems = [];
161
165
  // The change itself, its work orders and its work items (M-ADR 0223 §4.2).
162
- const recordChange = async (id, name, kind, status, archivedOn, changeDir, record) => {
166
+ const recordChange = async (localId, name, kind, status, archivedOn, changeDir, record) => {
163
167
  const verdict = record?.verdict?.result === "PASS" || record?.verdict?.result === "FAIL" ? record.verdict.result : "";
168
+ const id = scoped(localId);
164
169
  changes.push({ id, name, change_kind: kind, status, archived_on: archivedOn, verdict, source_sdd_id: "", platform_sdd_id: "" });
165
170
  for (const wo of record?.work_orders ?? []) {
166
171
  if (!wo.repo)
@@ -174,7 +179,7 @@ export async function buildRequirements(inputs) {
174
179
  }
175
180
  const proposal = await readText(join(changeDir, "proposal.md"));
176
181
  for (const item of proposal === null ? [] : parseWorkItems(proposal)) {
177
- workItems.push({ id: `${name}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
182
+ workItems.push({ id: `${scoped(name)}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
178
183
  }
179
184
  };
180
185
  const pushEvent = (e) => {
@@ -182,7 +187,8 @@ export async function buildRequirements(inputs) {
182
187
  unknownRepos.add(e.repo);
183
188
  return;
184
189
  }
185
- if (requirements.has(e.requirementId))
190
+ // A retired requirement has no status: it is not where any repository's behaviour is.
191
+ if (requirements.has(e.requirementId) && requirements.get(e.requirementId)["stage"] !== "retired")
186
192
  events.push(e);
187
193
  };
188
194
  // 2. Archived changes, in archive order. A requirement is named as it was when each change archived, so its
@@ -201,9 +207,13 @@ export async function buildRequirements(inputs) {
201
207
  await recordChange(dir, change, kind, "archived", archivedOn, changeDir, await agreementRecord(sddPath, change));
202
208
  const delta = await deltaOf(changeDir);
203
209
  for (const d of delta) {
204
- if (d.operation === "REMOVED")
205
- continue;
206
- archived.push({ index, feature: d.feature, name: d.name, lineage: { change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" } });
210
+ archived.push({
211
+ index,
212
+ feature: d.feature,
213
+ name: d.name,
214
+ lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
215
+ ...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
216
+ });
207
217
  }
208
218
  if (evidence !== null) {
209
219
  const repos = observedIn(evidence);
@@ -213,7 +223,7 @@ export async function buildRequirements(inputs) {
213
223
  for (const repo of repos)
214
224
  for (const d of delta)
215
225
  if (d.operation !== "REMOVED")
216
- archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: dir } });
226
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir) } });
217
227
  continue;
218
228
  }
219
229
  const record = await agreementRecord(sddPath, change);
@@ -223,12 +233,16 @@ export async function buildRequirements(inputs) {
223
233
  if (!wo.repo)
224
234
  continue;
225
235
  for (const name of wo.implements ?? []) {
226
- const match = delta.filter((d) => d.name === name && d.operation !== "REMOVED");
227
- if (match.length === 0) {
236
+ const { matches, ambiguous } = resolveImplements(delta.filter((d) => d.operation !== "REMOVED"), name);
237
+ const match = matches;
238
+ if (ambiguous.length > 0) {
239
+ findings.add("divergence", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>. No status is derived from it`);
240
+ }
241
+ else if (match.length === 0) {
228
242
  findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
229
243
  }
230
244
  for (const d of match)
231
- archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: dir } });
245
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir) } });
232
246
  }
233
247
  }
234
248
  }
@@ -240,6 +254,15 @@ export async function buildRequirements(inputs) {
240
254
  n = r.to;
241
255
  return `${feature}::${n}`;
242
256
  };
257
+ // A requirement removed and not living now is retired: kept, so its lineage can end in the removal.
258
+ for (const a of archived) {
259
+ if (!a.removedIn)
260
+ continue;
261
+ const id = current(a.feature, a.name, a.index);
262
+ if (requirements.has(id) || !known(a.feature))
263
+ continue;
264
+ requirements.set(id, { id, feature_id: a.feature, name: id.slice(a.feature.length + 2), stage: "retired", spec_path: a.removedIn, scenario_count: 0, source_sdd_id: "", platform_sdd_id: "" });
265
+ }
243
266
  // One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
244
267
  // the behaviour change, and its delta still shows the rename.
245
268
  const lineageById = new Map();
@@ -278,8 +301,12 @@ export async function buildRequirements(inputs) {
278
301
  if (!wo.repo)
279
302
  continue;
280
303
  for (const name of wo.implements ?? []) {
281
- for (const d of delta.filter((x) => x.name === name && x.operation !== "REMOVED")) {
282
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: dir });
304
+ const { matches, ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
305
+ if (ambiguous.length > 0) {
306
+ findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>. No status is derived from it`);
307
+ }
308
+ for (const d of matches) {
309
+ pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: scoped(dir) });
283
310
  }
284
311
  }
285
312
  }
@@ -7,7 +7,7 @@
7
7
  # Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
8
8
  # textual readers downstream. The one deliberate difference from the authored form is schema_status:
9
9
  # the authored form says canonical; a projection never does.
10
- schema_version: "4.2.0"
10
+ schema_version: "5.0.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -620,6 +620,8 @@ enums:
620
620
  description: "In the living spec: shipped behaviour."
621
621
  - value: agreed
622
622
  description: Added by an open change whose agreement record is agreed; not yet shipped.
623
+ - value: retired
624
+ description: "Removed by an archived change's REMOVED delta: kept so its lineage shows the removal; no status."
623
625
  description: Where a catalogued requirement stands (M-ADR 0221 §4.4).
624
626
  requirement_change_kind:
625
627
  values:
@@ -633,7 +635,8 @@ enums:
633
635
  - value: ADDED
634
636
  - value: MODIFIED
635
637
  - value: RENAMED
636
- description: How a change's delta treated a requirement that is current now (a removed requirement has no row).
638
+ - value: REMOVED
639
+ description: How a change's delta treated a requirement.
637
640
  requirement_status_value:
638
641
  values:
639
642
  - value: agreed
@@ -664,10 +667,33 @@ enums:
664
667
  work_order_status:
665
668
  values:
666
669
  - value: agreed
667
- description: Agreed; the repository's work is not yet judged.
670
+ description: Agreed; the change has not yet archived on a passing judge.
668
671
  - value: completed
669
- description: Marked completed by a passing judge.
672
+ description: "Completed: the change archived on a passing judge, after every repository merged its judged commit."
670
673
  description: A work order's status in its change's agreement record (M-ADR 0223 §4.2, §4.6).
674
+ feature_status:
675
+ values:
676
+ - value: active
677
+ description: The feature's behaviour is live or being built.
678
+ - value: retired
679
+ description: Its behaviour was retired by an archived change; kept so its retired requirements resolve.
680
+ description: A catalogued feature's status (M-ADR 0221 §4.5).
681
+ feature_kind:
682
+ values:
683
+ - value: feature
684
+ description: "A product or capability feature: the default."
685
+ - value: obligation
686
+ description: Behaviour every feature in a population depends on, owned as a feature of its own (M-ADR 0226, Proposed).
687
+ - value: journey
688
+ description: A user journey's joint behaviour, held in the behaviour store (M-ADR 0229 §4.2, option B; Proposed).
689
+ description: What a catalogued feature is (M-ADR 0226 and M-ADR 0229, both Proposed).
690
+ journey_requirement_basis:
691
+ values:
692
+ - value: tag
693
+ description: A @req tag in the journey's end-to-end test file (option A).
694
+ - value: citation
695
+ description: A citation in the journey feature's living spec (option B).
696
+ description: Where a journey's citation of a requirement was read (M-ADR 0229 §4.4, Proposed).
671
697
  catalogues:
672
698
  domains:
673
699
  primary_key: id
@@ -1454,7 +1480,7 @@ catalogues:
1454
1480
  - name: status
1455
1481
  type: string
1456
1482
  nullable: false
1457
- description: Default 'active'.
1483
+ description: "Bound to feature_status: active, or retired when the feature spec says **Status:** retired (its behaviour retired by an archived change)."
1458
1484
  - name: source
1459
1485
  type: string
1460
1486
  nullable: false
@@ -1463,6 +1489,10 @@ catalogues:
1463
1489
  type: string
1464
1490
  nullable: true
1465
1491
  description: Optional adopter-owned lifecycle / migration marker. Free string at method level — the schema declares no enum and ships no controlled vocabulary. Adopters MAY declare a per-SDD `feature-runway-vocabulary.yaml` at their SDD root; when present, the canonical builder validates captured values against it and emits a `convention` finding for unknown values. Empty when the adopter does not track migration state. Comparability across SDDs requires ecosystem-level vocabulary alignment as an explicit pre-step. Added in schema 2.2.0.
1492
+ - name: kind
1493
+ type: string
1494
+ nullable: false
1495
+ description: "Bound to feature_kind: feature (the default), obligation (M-ADR 0226, Proposed) or journey (M-ADR 0229 §4.2, Proposed), from the feature spec's **Kind:** line."
1466
1496
  - name: source_sdd_id
1467
1497
  type: string
1468
1498
  nullable: true
@@ -1485,6 +1515,8 @@ catalogues:
1485
1515
  references: features.id
1486
1516
  nullable: true
1487
1517
  column_enums:
1518
+ status: enums.feature_status
1519
+ kind: enums.feature_kind
1488
1520
  source: enums.feature_source
1489
1521
  maturity: enums.artefact_maturity
1490
1522
  personas:
@@ -4188,7 +4220,7 @@ catalogues:
4188
4220
  - name: stage
4189
4221
  type: string
4190
4222
  nullable: false
4191
- description: "Bound to requirement_stage: living (in the living spec) or agreed (added by an open change whose agreement record is agreed)."
4223
+ description: "Bound to requirement_stage: living (in the living spec), agreed (added by an open change whose agreement record is agreed) or retired (removed by an archived change)."
4192
4224
  - name: spec_path
4193
4225
  type: string
4194
4226
  nullable: false
@@ -4225,7 +4257,7 @@ catalogues:
4225
4257
  - name: change_id
4226
4258
  type: string
4227
4259
  nullable: false
4228
- description: The archived change's directory name (its archive date prefix included); a changes.id.
4260
+ description: "The archived change's id: <sdd_id>::<archive directory> (its archive date prefix included); a changes.id."
4229
4261
  - name: change_kind
4230
4262
  type: string
4231
4263
  nullable: false
@@ -4233,7 +4265,7 @@ catalogues:
4233
4265
  - name: operation
4234
4266
  type: string
4235
4267
  nullable: false
4236
- description: "Bound to requirement_operation: how the change's delta treated the requirement."
4268
+ description: "Bound to requirement_operation: how the change's delta treated the requirement; REMOVED retires it."
4237
4269
  - name: archived_on
4238
4270
  type: string
4239
4271
  nullable: false
@@ -4278,7 +4310,7 @@ catalogues:
4278
4310
  - name: basis
4279
4311
  type: string
4280
4312
  nullable: false
4281
- description: "The change that produced the status: the open change's or the archived change's directory name."
4313
+ description: "The change that produced the status: its changes.id, <sdd_id>::<directory>."
4282
4314
  - name: source_sdd_id
4283
4315
  type: string
4284
4316
  nullable: true
@@ -4287,7 +4319,7 @@ catalogues:
4287
4319
  type: string
4288
4320
  nullable: true
4289
4321
  description: The platform SDD the row belongs to (stamped at aggregation).
4290
- description: "Edge: a requirement's status in a repository that implements it (M-ADR 0221 §4.5; M-ADR 0223 §4.7). Derived, never authored: agreed from an open agreed change's work orders, declared-shipped from an archived change's PASS verdict and its work orders, observed from a baseline's evidence. The latest event in archive order wins."
4322
+ description: "Edge: a requirement's status in a repository that implements it (M-ADR 0221 §4.5; M-ADR 0223 §4.7). Derived, never authored: agreed from an open agreed change's work orders, declared-shipped from an archived change's PASS verdict and its work orders, observed from a baseline's evidence. The latest event in archive order wins. Limitation: a requirement and repository hold one status. An agreed open change's status replaces the evidence about the revision that is live until the change archives, and evidence is not bound to the requirement revision it concerns."
4291
4323
  foreign_keys:
4292
4324
  - column: requirement_id
4293
4325
  references: requirements.id
@@ -4303,7 +4335,7 @@ catalogues:
4303
4335
  - name: id
4304
4336
  type: string
4305
4337
  nullable: false
4306
- description: "The change's directory name: the open change's name, or the archived change's directory (its archive date prefix included). requirement_lineage.change_id references it."
4338
+ description: "<sdd_id>::<directory>: the SDD the store belongs to and the change's directory name there, the open change's name or the archived change's directory (its archive date prefix included). A change name is its store's own, so the SDD is part of its identity: two SDDs' changes of one name are two changes. requirement_lineage.change_id references it."
4307
4339
  - name: name
4308
4340
  type: string
4309
4341
  nullable: false
@@ -4354,7 +4386,7 @@ catalogues:
4354
4386
  - name: status
4355
4387
  type: string
4356
4388
  nullable: false
4357
- description: "Bound to work_order_status: the record's status for the work order; a judge PASS marks it completed (M-ADR 0223 §4.6)."
4389
+ description: "Bound to work_order_status: the record's status for the work order; the change archiving on a judge PASS marks it completed (M-ADR 0223 §4.6)."
4358
4390
  - name: source_sdd_id
4359
4391
  type: string
4360
4392
  nullable: true
@@ -4379,11 +4411,11 @@ catalogues:
4379
4411
  - name: id
4380
4412
  type: string
4381
4413
  nullable: false
4382
- description: "<change_name>__<work_item_id>: keyed by the change's undated name, so the row is the same before and after archive."
4414
+ description: "<sdd_id>::<change_name>__<work_item_id>: keyed by the SDD and the change's undated name, so the row is the same before and after archive."
4383
4415
  - name: change_id
4384
4416
  type: string
4385
4417
  nullable: false
4386
- description: "The change as it stands now: its open name, or its archive directory."
4418
+ description: "The change as it stands now (a changes.id): <sdd_id>:: with its open name or its archive directory."
4387
4419
  - name: change_name
4388
4420
  type: string
4389
4421
  nullable: false
@@ -4405,3 +4437,38 @@ catalogues:
4405
4437
  - column: change_id
4406
4438
  references: changes.id
4407
4439
  nullable: false
4440
+ journey_requirement:
4441
+ primary_key: id
4442
+ columns:
4443
+ - name: id
4444
+ type: string
4445
+ nullable: false
4446
+ description: <journey_id>__<requirement_id>.
4447
+ - name: journey_id
4448
+ type: string
4449
+ nullable: false
4450
+ - name: requirement_id
4451
+ type: string
4452
+ nullable: false
4453
+ - name: basis
4454
+ type: string
4455
+ nullable: false
4456
+ description: "Bound to journey_requirement_basis: where the citation was read."
4457
+ - name: source_sdd_id
4458
+ type: string
4459
+ nullable: true
4460
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4461
+ - name: platform_sdd_id
4462
+ type: string
4463
+ nullable: true
4464
+ description: The platform SDD the row belongs to (stamped at aggregation).
4465
+ description: "Edge: a requirement a user journey cites for one of its steps (M-ADR 0229 §4.4, Proposed). Derived, never authored: from the @req tags in the journey's end-to-end test file, or from the citations in its journey feature's living spec. A journey with no row is untraced."
4466
+ foreign_keys:
4467
+ - column: journey_id
4468
+ references: user_journeys.id
4469
+ nullable: false
4470
+ - column: requirement_id
4471
+ references: requirements.id
4472
+ nullable: false
4473
+ column_enums:
4474
+ basis: enums.journey_requirement_basis