@ecoma-io/archkeep 0.16.1 → 0.18.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 (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -77,6 +77,10 @@
77
77
  * never as an empty ledger.
78
78
  */
79
79
 
80
+ import { createHash } from "node:crypto";
81
+
82
+ import { canonicalizeJson } from "../canonical.mjs";
83
+
80
84
  import { referenceTime as clockReferenceTime } from "./clock.mjs";
81
85
  import { EXPIRED_WAIVER_EVIDENCE, suppressionFate } from "./waiver.mjs";
82
86
 
@@ -118,9 +122,122 @@ function owningProjectForPath(path, byName) {
118
122
  bestRoot = root;
119
123
  }
120
124
  }
125
+
121
126
  return best;
122
127
  }
123
128
 
129
+ /**
130
+ * The stable identity of a debt entry: `sha256` of its canonical `{kind,
131
+ * source}` — the same mechanism `eventId` uses for evolution events (one
132
+ * pattern, one canonicalizer), never the wall clock, a sequence or a random.
133
+ * The same fact must always hash to the same id; any caller that emits these
134
+ * ids into an evolution event's `debt.introduced`/`debt.resolved` MUST use
135
+ * this exact identity, or the event-linked lifecycle will never match the
136
+ * ledger.
137
+ *
138
+ * `expired-waiver` maps back to `waiver`: it is the SAME accepted violation,
139
+ * and its id must not change when its `expiresAt` passes — a fact's identity
140
+ * cannot depend on a transient state.
141
+ *
142
+ * @param {string} kind The entry's `kind`.
143
+ * @param {string} source The entry's `source` (its keying field).
144
+ * @returns {string} The stable hex id.
145
+ */
146
+ export function entryId(kind, source) {
147
+ const semanticKind = kind === "expired-waiver" ? "waiver" : kind;
148
+ return createHash("sha256")
149
+ .update(canonicalizeJson({ kind: semanticKind, source }))
150
+ .digest("hex");
151
+ }
152
+
153
+ /**
154
+ * The stable identity of a structured debt fact: `entryId` over the fact's
155
+ * canonical JSON. Where `entryId(kind, source)` keys on a single string (a
156
+ * waiver path, an unresolved boundary), `debtFactId` keys on the full semantic
157
+ * fact so two distinct facts can never collide on one id.
158
+ *
159
+ * This is the ONE identity the event-linked lifecycle links against: a
160
+ * producer (`change`, `delta`) that emits `debt.introduced`/`debt.resolved`
161
+ * MUST call this exact function with the same structured fact the ledger
162
+ * derives its entry from, or the ids will never match (the broken lifecycle
163
+ * F-DEB-1 exists to close). The fact excludes prose — a reworded message must
164
+ * not re-key a fact (F-DEB-5 drift, F-DEB-8 aspirational gap).
165
+ *
166
+ * @param {string} kind The entry's `kind`.
167
+ * @param {object} fact The structured semantic fact, e.g. a drift finding
168
+ * `{source, target, rule}` or an aspirational gap `{from, to}`.
169
+ * @returns {string} The stable hex id.
170
+ */
171
+ export function debtFactId(kind, fact) {
172
+ return entryId(kind, canonicalizeJson(fact));
173
+ }
174
+
175
+ /**
176
+ * The structured drift fact a judge finding keys on: `{source, target, rule}`
177
+ * over exactly the fields that were judged. Presence findings (projectMissing,
178
+ * projectPresent, projectTagMissing) carry no `target`; the fact spans only
179
+ * the present fields — shared by the ledger's drift entry and every producer
180
+ * that emits `debt.introduced`/`debt.resolved`, so they can never disagree
181
+ * about which id a finding owns.
182
+ *
183
+ * @param {{source?: string, target?: string, rule?: string}} finding A judge
184
+ * finding (`judgeIntent`'s `{source, target, rule, …}`).
185
+ * @returns {{source: string, target?: string, rule?: string}} The drift fact.
186
+ */
187
+ export function driftFactOf(finding) {
188
+ return {
189
+ source: finding.source,
190
+ ...(finding.target === undefined ? {} : { target: finding.target }),
191
+ ...(finding.rule === undefined ? {} : { rule: finding.rule }),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The debt a base→head transition opened and closed, expressed as ledger ids
197
+ * (design §8: `debt.introduced` names debt created, `debt.resolved` names debt
198
+ * resolved). Two `judgeIntent` verdicts — one over the base graph, one over
199
+ * the head graph, both judged under ONE current intent — diff on the STABLE
200
+ * ids: a finding present at base but gone at head is resolved; present at head
201
+ * but not base is introduced; a fact that never moved is neither. Identity
202
+ * never depends on prose (F-DEB-5 drift, F-DEB-8 aspirational gap), so the
203
+ * ids emitted here are byte-identical to what `computeDebtLedger` derives for
204
+ * the same fact — the ONE home that makes the event-linked lifecycle fire.
205
+ *
206
+ * Aspirational gaps count as debt too: an optional row not built at base but
207
+ * built at head is resolved debt; one that stops being built is introduced.
208
+ *
209
+ * @param {object} baseVerdict A `judgeIntent` result over the base graph.
210
+ * @param {object} headVerdict A `judgeIntent` result over the head graph.
211
+ * @returns {{introduced: string[], resolved: string[]}} Stable debt ids.
212
+ */
213
+ export function debtChangeDiff(baseVerdict, headVerdict) {
214
+ const driftOf = (v) => (v.findings ?? []).map((f) => debtFactId("drift", driftFactOf(f)));
215
+ const gapOf = (v) =>
216
+ (v.gaps ?? []).map((g) => debtFactId("aspirational-gap", { from: g.from, to: g.to }));
217
+ const baseIds = new Set([...driftOf(baseVerdict), ...gapOf(baseVerdict)]);
218
+ const headIds = new Set([...driftOf(headVerdict), ...gapOf(headVerdict)]);
219
+ const introduced = [...headIds].filter((id) => !baseIds.has(id)).sort();
220
+ const resolved = [...baseIds].filter((id) => !headIds.has(id)).sort();
221
+ return { introduced, resolved };
222
+ }
223
+ /**
224
+ * Reduces an `opts.events` value to a loaded event array, or `null` when no
225
+ * event store is linked. Accepts an already-loaded array or a
226
+ * `{ getEvents(dir) }`-shaped reader (design §4). `null` means "not linked":
227
+ * no `introducedBy`/`resolvedBy` is ever guessed.
228
+ *
229
+ * @param {{events?: object[]|{getEvents?: (dir?: string) => object[]},
230
+ * eventsDir?: string}} opts
231
+ * @returns {object[]|null}
232
+ */
233
+ function loadEvents(opts) {
234
+ if (!opts.events) return null;
235
+ if (Array.isArray(opts.events)) return opts.events;
236
+ if (typeof opts.events.getEvents === "function")
237
+ return opts.events.getEvents(opts.eventsDir) ?? [];
238
+ return null;
239
+ }
240
+
124
241
  /**
125
242
  * The complete ledger over one ordered snapshot set. Deterministic: the same
126
243
  * files, the same current facts and the same `referenceTime` produce the same
@@ -131,17 +248,27 @@ function owningProjectForPath(path, byName) {
131
248
  * test runs could share. (The ledger's own determinism is about a fixed
132
249
  * clock.)
133
250
  *
134
- * @param {{suppressions?: object[], intentNotes?: string[], findings?: object[],
251
+ * @param {{suppressions?: object[], intentNotes?: string[], gaps?: {from: string,
252
+ * to: string, note?: string}[], findings?: object[],
135
253
  * unresolved?: object[]}} current The current run's candid facts: the loaded
136
- * boundary config's `suppressions`, `judgeIntent`'s `notes` (aspirational
137
- * gaps), `findings` (drift), and `unresolved`.
254
+ * boundary config's `suppressions`, `judgeIntent`'s aspirational-gap facts
255
+ * (`gaps`, structured `{from, to}` — the identity source) with `intentNotes`
256
+ * as their prose display (deprecated dance when `gaps` is absent),
257
+ * `findings` (drift), and `unresolved`.
138
258
  * @param {{files: {name: string, envelope: object, id: string}[]}} snapshots
139
259
  * From `readSnapshots(dir)`, in history order.
140
- * @param {{referenceTime?: number|string}} [opts]
260
+ * @param {{referenceTime?: number|string, events?: object[]|{getEvents?: (dir?: string) => object[]},
261
+ * eventsDir?: string}} [opts] `events` links an event store (design §4): an
262
+ * already-loaded array of evolution events, or a `{ getEvents(dir) }`-shaped
263
+ * reader. Absent ⇒ no `introducedBy`/`resolvedBy` is ever set and a
264
+ * `lifecycle.note` states the refs are unavailable — refs are never guessed.
141
265
  * @returns {{entries: {source: string, kind: string, severity: string,
142
- * age: number, count: number, remediationHint: string}[],
266
+ * age: number, count: number, remediationHint: string, id: string,
267
+ * status: "active", introducedBy?: string}[],
268
+ * resolved: {id: string, status: "resolved", resolvedBy: string}[],
143
269
  * total: number, byKind: object, bySeverity: object, agings: boolean,
144
- * sampleTime: string}}
270
+ * sampleTime: string,
271
+ * lifecycle: {linked: boolean, note: string|null}}}
145
272
  */
146
273
  export function computeDebtLedger(current, snapshots, opts = {}) {
147
274
  const referenceTime = opts.referenceTime ?? clockReferenceTime();
@@ -167,7 +294,7 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
167
294
 
168
295
  const byName = headProjects(files);
169
296
 
170
- /** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string}[]} */
297
+ /** @type {{source: string, kind: string, severity: string, age: number, count: number, remediationHint: string, id: string, status: "active", introducedBy?: string}[]} */
171
298
  const entries = [];
172
299
 
173
300
  for (const suppression of current.suppressions ?? []) {
@@ -179,28 +306,58 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
179
306
  // suppression (no `expiresAt`) is `suppress`: still low and permanent.
180
307
  const fate = suppressionFate(suppression, sampleTime);
181
308
  const expired = fate === "reassert";
309
+ const kind = expired ? "expired-waiver" : "waiver";
182
310
  entries.push({
183
311
  source: suppression.path,
184
- kind: expired ? "expired-waiver" : "waiver",
312
+ kind,
185
313
  severity: expired ? "medium" : "low",
186
314
  age: project ? ageOf(project) : 0,
187
315
  count: 1,
316
+ id: entryId(kind, suppression.path),
317
+ status: "active",
188
318
  remediationHint: expired
189
319
  ? `the waiver at '${suppression.path}' expired — the boundary it accepted is live again (${EXPIRED_WAIVER_EVIDENCE}); renew it or retire it`
190
320
  : `the accepted violation at '${suppression.path}' is still suppressed — ` +
191
321
  (project ? `owning project '${project}'` : "retire it or confirm the reason"),
192
322
  });
193
323
  }
194
- for (const note of current.intentNotes ?? []) {
195
- entries.push({
196
- source: note,
197
- kind: "aspirational-gap",
198
- severity: "low",
199
- age: 0,
200
- count: 1,
201
- remediationHint:
202
- "an optional allowed row is not yet built — either build it or remove the row",
203
- });
324
+ // Aspirational-gap entries: an `optional: true` `allowed` row not yet built.
325
+ // Identity comes from the STRUCTURED `{from, to}` (F-DEB-8) — never from the
326
+ // prose note, which re-keys every gap when the wording changes. `gaps` is
327
+ // the structured source when a caller threads it (the producer emits the
328
+ // same `{from, to}` ids the ledger derives here); the prose `intentNotes`
329
+ // fallback keys on the note itself only for callers that pass notes with no
330
+ // structured gaps — a deprecated shape, retained so the identity home stays
331
+ // single (the `debtFactId` here is the one the producers must match).
332
+ const gaps = current.gaps ?? [];
333
+ if (gaps.length > 0) {
334
+ for (const gap of gaps) {
335
+ entries.push({
336
+ source: gap.note ?? `${gap.from} → ${gap.to}`,
337
+ kind: "aspirational-gap",
338
+ severity: "low",
339
+ age: 0,
340
+ count: 1,
341
+ id: debtFactId("aspirational-gap", { from: gap.from, to: gap.to }),
342
+ status: "active",
343
+ remediationHint:
344
+ "an optional allowed row is not yet built — either build it or remove the row",
345
+ });
346
+ }
347
+ } else {
348
+ for (const note of current.intentNotes ?? []) {
349
+ entries.push({
350
+ source: note,
351
+ kind: "aspirational-gap",
352
+ severity: "low",
353
+ age: 0,
354
+ count: 1,
355
+ id: entryId("aspirational-gap", note),
356
+ status: "active",
357
+ remediationHint:
358
+ "an optional allowed row is not yet built — either build it or remove the row",
359
+ });
360
+ }
204
361
  }
205
362
 
206
363
  // Which projects hold an accepted waiver — so a drift finding in the same
@@ -217,12 +374,20 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
217
374
  for (const finding of current.findings ?? []) {
218
375
  const project = typeof finding.source === "string" ? finding.source : null;
219
376
  const waiverFailed = project !== null && waiverProjects.has(project);
377
+ // The stable fact keys on the full semantic tuple (source, target, rule)
378
+ // — never on `finding.source` alone, which collides every distinct
379
+ // same-source finding onto one id (F-DEB-5), and never on the prose
380
+ // `finding.message`, which re-keys a fact when the wording changes.
381
+ // `driftFactOf` is the ONE builder both the ledger and the producers use,
382
+ // so an introduced id can never disagree with the ledger's active id.
220
383
  entries.push({
221
384
  source: finding.source ?? finding.message,
222
385
  kind: "drift",
223
386
  severity: waiverFailed ? "high" : "medium",
224
387
  age: project ? ageOf(project) : 0,
225
388
  count: 1,
389
+ id: debtFactId("drift", driftFactOf(finding)),
390
+ status: "active",
226
391
  remediationHint: waiverFailed
227
392
  ? `this drift finding is in a project with an accepted waiver — the accepted violation is failing again, resolve it or remove the waiver`
228
393
  : "a dependency the intent forbids (or allows but is not built) — resolve the contradiction",
@@ -235,6 +400,8 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
235
400
  severity: "unknown",
236
401
  age: 0,
237
402
  count: 1,
403
+ id: entryId("unresolved", unresolved.boundary),
404
+ status: "active",
238
405
  remediationHint:
239
406
  "an intent boundary matched no observed project — the intent cannot be verified",
240
407
  });
@@ -270,5 +437,80 @@ export function computeDebtLedger(current, snapshots, opts = {}) {
270
437
  if (entry.severity !== "unknown") bySeverity[entry.severity] += 1;
271
438
  }
272
439
 
273
- return { entries, total: entries.length, byKind, bySeverity, agings, sampleTime };
440
+ // The lifecycle surface (design §6): every active entry carries a stable id
441
+ // and `status: "active"`. When an event store is linked, REPAIR events name
442
+ // the debt they closed (`debt.resolved`) and introduction events name what
443
+ // they opened (`debt.introduced`); the closure is only accepted when the
444
+ // candidate fact is NOT still active at head (an id that came back is not
445
+ // resolved). Without a linked store, `resolved` stays empty and no ref is
446
+ // ever fabricated — the note states the refs are unavailable instead.
447
+ const events = loadEvents(opts);
448
+ const activeIds = new Set(entries.map((entry) => entry.id));
449
+ /** @type {{id: string, status: "resolved", resolvedBy: string}[]} */
450
+ const resolved = [];
451
+ const lifecycle = { linked: events !== null, note: null };
452
+
453
+ if (events === null) {
454
+ lifecycle.note = "no event store linked — lifecycle refs unavailable";
455
+ } else {
456
+ /** @type {Map<string, string>} id → the first event that introduced it. */
457
+ const introducedByForId = new Map();
458
+ /** @type {Set<string>} debt ids already placed on the resolved list. */
459
+ const resolvedSeen = new Set();
460
+ for (const event of events) {
461
+ // The store validates that every event carries a string id, but the
462
+ // reader shape is loosely typed — coerce so the ref string is always a
463
+ // real string, never a fabricated one.
464
+ const eventId = typeof event?.id === "string" ? event.id : "";
465
+ for (const debtId of event?.debt?.introduced ?? []) {
466
+ if (typeof debtId === "string" && !introducedByForId.has(debtId)) {
467
+ introducedByForId.set(debtId, eventId);
468
+ }
469
+ }
470
+ const repairs = Array.isArray(event?.classifications)
471
+ ? event.classifications.includes("REPAIR")
472
+ : false;
473
+ if (!repairs) continue;
474
+ for (const debtId of event?.debt?.resolved ?? []) {
475
+ // Closure is only real when the candidate fact is gone at head; a
476
+ // debt id still active is not resolved (closed then re-opened), and
477
+ // an id NO event ever introduced was never debt — resolving it would
478
+ // invent a foreign fact out of nothing (inv. 2/7, F-DEB-2).
479
+ if (
480
+ typeof debtId !== "string" ||
481
+ activeIds.has(debtId) ||
482
+ resolvedSeen.has(debtId) ||
483
+ !introducedByForId.has(debtId)
484
+ )
485
+ continue;
486
+ resolvedSeen.add(debtId);
487
+ // The resolved surface carries ONLY evidence-backed refs. The full
488
+ // original entry (kind, severity, age, count) is not reconstructed
489
+ // here — that record lives on in the history snapshots behind the
490
+ // ledger; `kind:"debt"`, `age:0`, `count:1` would be fabricated
491
+ // numerics (design §6 retains the record, never a stand-in).
492
+ resolved.push({ id: debtId, status: "resolved", resolvedBy: eventId });
493
+ }
494
+ }
495
+ if (resolved.length > 0 && lifecycle.note === null) {
496
+ lifecycle.note =
497
+ "resolved rows retain only evidence-backed refs — each closed debt's full entry lives in the history snapshots";
498
+ }
499
+ for (const entry of entries) {
500
+ const introducedBy = introducedByForId.get(entry.id);
501
+ if (introducedBy !== undefined) entry.introducedBy = introducedBy;
502
+ }
503
+ }
504
+ resolved.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
505
+
506
+ return {
507
+ entries,
508
+ resolved,
509
+ total: entries.length,
510
+ byKind,
511
+ bySeverity,
512
+ agings,
513
+ sampleTime,
514
+ lifecycle,
515
+ };
274
516
  }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Decision fitness — the "IS IT STILL TRUE" verification level, one per
3
+ * decision that carries authority.
4
+ *
5
+ * This module DERIVES a decision's verification level from its attached
6
+ * executable constraints and their verdicts. It is a pure, descriptive reader
7
+ * of the governance state:
8
+ *
9
+ * - it READS the decision's `bindings` (the constraint/fitness ids a record
10
+ * makes enforceable) and the fitness-registry's verdicts for those
11
+ * constraints;
12
+ * - it never judges a constraint itself — the verdict for a bound constraint
13
+ * comes in from the caller (a fitness-registry run), and `decisionFitness`
14
+ * only folds those verdicts into one per-decision level;
15
+ * - it is NOT wired into `check` exit codes this wave — a violated or
16
+ * unverifiable decision does not fail `check` (Wave 2 keeps decision
17
+ * fitness descriptive; see the Wave 2 design contract's scope section).
18
+ *
19
+ * ## The vocabulary (per decision, only for decisions WITH authority)
20
+ *
21
+ * A decision with no authority (`proposed` a draft, `superseded` replaced,
22
+ * `retired` withdrawn) is not measured — its fitness is `not_applicable` with
23
+ * a reason. A decision with authority is `active` (accepted and currently
24
+ * governing) or `accepted` (a decision made and recorded, "not yet verified"
25
+ * is a valid intermediate).
26
+ *
27
+ * - `enforced` — at least one bound executable constraint/fitness resolves
28
+ * AND was evaluated AND passed (verified true).
29
+ * - `partially-enforced` — some bound constraints evaluated & passed, but
30
+ * others are unverified / coverage incomplete.
31
+ * - `violated` — at least one bound constraint/fitness FAILED: the decision's
32
+ * "what must remain true" is currently false. RED direction.
33
+ * - `unverifiable` — the decision has authority but no bound constraint/
34
+ * fitness can be resolved/evaluated. RED direction — it is NEVER a pass;
35
+ * "no violation" is not healthy.
36
+ *
37
+ * "Healthy" is NOT derivable from "no violations": a decision with no
38
+ * executable constraint is `unverifiable`, never healthy. `violated` and
39
+ * `unverifiable` are the two red directions; `enforced` is the only fully-green
40
+ * one.
41
+ *
42
+ * ## No `stale`, deliberately
43
+ *
44
+ * The contract offers a `stale` level only if the repository already has a
45
+ * time-based staleness notion. It does not (no decision-verification staleness
46
+ * exists anywhere in `src/`), so `stale` is folded into `unverifiable`: a
47
+ * decision whose evidence is out of date is, like one with no reachable
48
+ * constraint, not verified true — same red meaning, deterministic, no clock.
49
+ *
50
+ * ## Determinism
51
+ *
52
+ * No wall-clock time enters the model. The `io` argument exists for signature
53
+ * parity and future dependency injection (e.g. an injected clock), but the
54
+ * derivation needs none: it reads only the bound verdicts a caller supplies,
55
+ * so identical inputs yield byte-identical output. A caller that does need a
56
+ * timestamp (a later wave) injects the clock through `io` rather than reading
57
+ * the wall clock.
58
+ */
59
+
60
+ import { hasAuthority } from "./adr-registry.mjs";
61
+
62
+ /** The closed set of per-decision fitness levels `computeDecisionFitness` emits. */
63
+ export const DECISION_FITNESS_LEVELS = Object.freeze([
64
+ "enforced",
65
+ "partially-enforced",
66
+ "violated",
67
+ "unverifiable",
68
+ "not_applicable",
69
+ ]);
70
+
71
+ /** Whether a level names a red (never-healthy) direction. */
72
+ export function isRedDirection(level) {
73
+ return level === "violated" || level === "unverifiable";
74
+ }
75
+
76
+ /**
77
+ * The resolver that turns a bound constraint id into its verdict record.
78
+ *
79
+ * @typedef {(bindingId: string) => (object | undefined)} DecisionRefLookup
80
+ * Returns the fitness/constraint verdict for a bound id, or `undefined` when
81
+ * no constraint resolves (or no verdict was produced for it) — a binding the
82
+ * lookup cannot answer is an UNVERIFIED binding, never a silent pass.
83
+ */
84
+
85
+ /**
86
+ * The per-decision fitness level.
87
+ *
88
+ * @typedef {object} DecisionFitness
89
+ * @property {string} id The record id (`NNN-slug`).
90
+ * @property {string} status The record's lifecycle status.
91
+ * @property {string} level One of `DECISION_FITNESS_LEVELS`.
92
+ * @property {boolean} verified True only for `enforced` — the decision's
93
+ * "what must remain true" has a constraint that verifies true.
94
+ * @property {string} [reason] WHY the level, present on every non-`enforced`
95
+ * level (which constraint failed, why "not applicable", why nothing
96
+ * verified). Optional: `enforced` needs no reason.
97
+ */
98
+ /**
99
+ * Computes the per-decision verification level for every record.
100
+ *
101
+ * Deterministic and pure: folds the supplied per-constraint verdicts into one
102
+ * level per decision, reading only the arguments. Bindings that fail win over
103
+ * everything (`violated`); when nothing fails and nothing passes, the decision
104
+ * is `unverifiable` — never healthy, the silent direction this vocabulary
105
+ * exists to refuse.
106
+ *
107
+ * @param {Array<{id: string, status: string, bindings: string[]}>} records
108
+ * The ADR registry's parsed records (a `loadAdrRegistry` result or an
109
+ * equivalent in tests).
110
+ * @param {unknown} _fitnessVerdicts
111
+ * Reserved for parity with the wave's call shape. `computeDecisionFitness`
112
+ * it (the lookup is the single door through which verdicts enter, so the
113
+ * derivation stays agnostic to how the caller keys them).
114
+ * @param {DecisionRefLookup} decisionRefLookup Resolves a bound constraint id
115
+ * to its verdict record (or `undefined` when it does not resolve / was not
116
+ * evaluated). The only door through which verdicts enter the derivation.
117
+ * @param {object} [_io] The reserved `io` injection seam the wave's call shape
118
+ * coordinates on. This derivation is pure and needs no clock (see the
119
+ * no-`stale` note), so it is intentionally unused here; a later timestamp-
120
+ * bearing wave injects a clock through this slot.
121
+ * @returns {DecisionFitness[]} One entry per record, in input order.
122
+ */
123
+ export function computeDecisionFitness(records, _fitnessVerdicts, decisionRefLookup, _io = {}) {
124
+ /** @type {DecisionFitness[]} */
125
+ const out = [];
126
+
127
+ for (const record of records) {
128
+ if (!hasAuthority(record.status)) {
129
+ out.push({
130
+ id: record.id,
131
+ status: record.status,
132
+ level: "not_applicable",
133
+ verified: false,
134
+ reason: `status "${record.status}" carries no authority — only active/accepted decisions are measured`,
135
+ });
136
+ continue;
137
+ }
138
+
139
+ const bindings = record.bindings ?? [];
140
+ const resolved = bindings
141
+ .map((binding) => ({ binding, verdict: decisionRefLookup(binding) }))
142
+ .filter((entry) => entry.verdict !== undefined) // an unresolved binding is unverified
143
+ .map((entry) => entry.verdict);
144
+
145
+ const failed = resolved.filter((v) => v.verdict === "fail");
146
+ const passed = resolved.filter((v) => v.verdict === "pass");
147
+
148
+ if (failed.length > 0) {
149
+ out.push({
150
+ id: record.id,
151
+ status: record.status,
152
+ level: "violated",
153
+ verified: false,
154
+ reason: `bound constraint "${failed[0].name}" FAILED — what-must-remain-true is currently false`,
155
+ });
156
+ continue;
157
+ }
158
+
159
+ if (passed.length === 0) {
160
+ // RED: no bound constraint verifies true. Covers "no bindings",
161
+ // "bindings resolve to nothing", and "bindings evaluate but return
162
+ // unknown/not_applicable". Never a pass.
163
+ out.push({
164
+ id: record.id,
165
+ status: record.status,
166
+ level: "unverifiable",
167
+ verified: false,
168
+ reason: unverifiableReason(record, bindings, resolved),
169
+ });
170
+ continue;
171
+ }
172
+
173
+ if (passed.length === bindings.length && resolved.length === bindings.length) {
174
+ out.push({
175
+ id: record.id,
176
+ status: record.status,
177
+ level: "enforced",
178
+ verified: true,
179
+ });
180
+ } else {
181
+ out.push({
182
+ id: record.id,
183
+ status: record.status,
184
+ level: "partially-enforced",
185
+ verified: false,
186
+ reason:
187
+ `${passed.length} of ${bindings.length} bound constraint(s) verified true; ` +
188
+ `the rest are unverified or unevaluated`,
189
+ });
190
+ }
191
+ }
192
+
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Names WHY a decision with authority verifies nothing true.
198
+ *
199
+ * @param {{id: string}} record
200
+ * @param {string[]} bindings
201
+ * @param {Array<{name: string, verdict: string}>} resolved
202
+ * @returns {string}
203
+ */
204
+ function unverifiableReason(record, bindings, resolved) {
205
+ if (bindings.length === 0) {
206
+ return `no executable constraint/fitness is bound to ${record.id} — nothing can be verified`;
207
+ }
208
+ if (resolved.length === 0) {
209
+ return `no bound constraint for ${record.id} resolves or was evaluated — none can be verified`;
210
+ }
211
+ const names = resolved.map((v) => v.name ?? "?").join(", ");
212
+ return `bound constraint(s) ${names} evaluated but none verified true — coverage incomplete, not enforced`;
213
+ }