@ecoma-io/archkeep 0.16.0 → 0.17.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.
@@ -13,14 +13,27 @@
13
13
  * filename's id. The filesystem is the source of truth, so a file whose
14
14
  * frontmatter id disagrees with its name is a loud error, never a drift the
15
15
  * registry guesses at.
16
- * - `status` — `proposed` (default), `accepted`, or `superseded`.
16
+ * - `status` — the lifecycle state: `proposed` (default), `accepted`,
17
+ * `active`, `superseded`, or `retired`. Only `accepted` and `active` carry
18
+ * authority (`hasAuthority`); `active` is the accepted decision currently in
19
+ * force, `superseded` was replaced by a later decision, `retired` was
20
+ * withdrawn without a replacement.
17
21
  * - `supersedes` — optional list of ADR ids this record replaces, giving the
18
- * supersession chain.
22
+ * supersession chain. The reverse link — `supersededBy` — is derived on
23
+ * every record at load: the records whose `supersedes` names this one.
24
+ * - `created` / `updated` — optional STRING values from the committed bytes,
25
+ * the decision's own timeline. Never generated from the wall clock.
19
26
  * - `bindings` — optional list of rule/fitness ids this ADR makes enforceable:
20
27
  * the objects its decision binds. An ADR with no `bindings` is recorded but
21
28
  * not yet enforceable; the moment a rule/fitness carries `decisionRef`
22
29
  * naming it, the two sides of the binding exist.
23
30
  *
31
+ * The record's markdown body may surface the decision's prose as optional
32
+ * fields when the `## ` heading is present — `context`, `decision`,
33
+ * `rationale`, `alternatives` (also spelled `## Refused alternatives`, the
34
+ * spelling this repository's own records use), `consequences`, `assumptions`.
35
+ * Body prose is free markdown and never throws; only frontmatter is strict.
36
+ *
24
37
  * Frontmatter is a strict, minimal dialect — `key: value` lines, and list
25
38
  * fields as `- item` continuation lines. It is never full YAML and never JSON
26
39
  * (the same decision the intent model makes for `architecture-intent.json`: no
@@ -34,10 +47,14 @@
34
47
  * `docs/adr/` produce byte-identical output.
35
48
  * - **An unreadable registry is a loud failure, never an empty one.** A
36
49
  * `docs/adr/` directory that exists but holds a file that will not parse, a
37
- * duplicate id, a status outside the three, an unknown frontmatter key, or
50
+ * duplicate id, a status outside the five, an unknown frontmatter key, or
38
51
  * a `supersedes`/`bindings` entry that is not what the field requires —
39
52
  * any of those throws, so a caller can never mistake "could not read the
40
- * registry" for "no ADRs".
53
+ * registry" for "no ADRs". So does a supersession graph that cannot be
54
+ * true (`validateLineage`): a `supersedes` target that is not a record, a
55
+ * record that supersedes itself, a cycle, a `superseded` record with no
56
+ * successor, a successor without authority, or an authoritative record
57
+ * (`active`/`accepted`) superseded by another.
41
58
  * - **A `decisionRef` that does not resolve is `unknown`, never `pass`.** The
42
59
  * registry's `resolveDecisionRef` answers the two-name space — an ADR id
43
60
  * (matching a file) or a rule/fitness id the workspace declares. Anything
@@ -78,8 +95,27 @@ import { containmentViolation } from "../containment.mjs";
78
95
  /** The directory, relative to a workspace root, where ADR files live. */
79
96
  export const ADR_DIR = "docs/adr";
80
97
 
81
- /** The three statuses a record may carry. Any other value is a load error. */
82
- export const ADR_STATUSES = Object.freeze(["proposed", "accepted", "superseded"]);
98
+ /** The five lifecycle statuses a record may carry. Any other value is a load error. */
99
+ export const ADR_STATUSES = Object.freeze([
100
+ "proposed",
101
+ "accepted",
102
+ "active",
103
+ "superseded",
104
+ "retired",
105
+ ]);
106
+
107
+ /**
108
+ * Whether a status carries decision authority. Only `active` — the accepted
109
+ * decision currently in force — and `accepted` — a decision made and
110
+ * recorded — have it. `proposed` (a draft), `superseded` (replaced, authority
111
+ * transferred) and `retired` (withdrawn without a replacement) do not.
112
+ *
113
+ * @param {string} status
114
+ * @returns {boolean}
115
+ */
116
+ export function hasAuthority(status) {
117
+ return status === "active" || status === "accepted";
118
+ }
83
119
 
84
120
  /**
85
121
  * Matches a valid ADR filename. The number is at least three digits so the
@@ -92,7 +128,14 @@ export const ADR_FILE_PATTERN = /^(\d{3,})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/u;
92
128
  export const ADR_ID_PATTERN = /^\d{3,}-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
93
129
 
94
130
  /** The frontmatter keys a record file may carry. */
95
- const FRONTMATTER_KEYS = Object.freeze(["id", "status", "supersedes", "bindings"]);
131
+ const FRONTMATTER_KEYS = Object.freeze([
132
+ "id",
133
+ "status",
134
+ "supersedes",
135
+ "bindings",
136
+ "created",
137
+ "updated",
138
+ ]);
96
139
 
97
140
  /** A value's type, for an error message that shows what was actually there. */
98
141
  function describe(value) {
@@ -116,6 +159,68 @@ function stripInlineComment(value) {
116
159
  const hash = value.indexOf(" #");
117
160
  return hash === -1 ? value : value.slice(0, hash).trim();
118
161
  }
162
+ /** The markdown body after the frontmatter block, or the whole text when the file has none. */
163
+ function bodyBlock(text) {
164
+ if (!text.startsWith("---")) return text;
165
+ const end = text.indexOf("\n---", 3);
166
+ if (end === -1) return text; // frontmatterBlock throws this case first
167
+ return text.slice(end + 4).replace(/^\r?\n/, "");
168
+ }
169
+
170
+ /**
171
+ * The prose fields the body may surface, keyed by the exact `## ` heading
172
+ * that carries them. `## Refused alternatives` — the spelling this
173
+ * repository's own records use (`docs/adr/0003-…`, `docs/adr/0004-…`) — maps
174
+ * to the same field as the `## Alternatives` spelling the ADR template names.
175
+ * The body's `## Status` heading is deliberately absent: status is a
176
+ * frontmatter field, and the body's retelling is not the model's.
177
+ */
178
+ const PROSE_FIELDS = Object.freeze({
179
+ Context: "context",
180
+ Decision: "decision",
181
+ Rationale: "rationale",
182
+ Alternatives: "alternatives",
183
+ "Refused alternatives": "alternatives",
184
+ Consequences: "consequences",
185
+ Assumptions: "assumptions",
186
+ });
187
+
188
+ /**
189
+ * The optional prose fields parsed out of a record's body. Each `## ` heading
190
+ * in `PROSE_FIELDS` opens its field; the field's content is everything from
191
+ * the line after the heading to the line before the next `## ` heading
192
+ * (sub-headings like `###` stay inside their field). An absent heading is an
193
+ * absent field, and a body that is only prose never throws — frontmatter is
194
+ * the one strict dialect. A `## ` heading outside the field list closes the
195
+ * open field without opening one; a heading repeated within one body keeps
196
+ * the last occurrence, the one deterministic choice a non-throwing parser
197
+ * can make.
198
+ *
199
+ * @param {string} body The markdown body after the frontmatter block.
200
+ * @returns {Record<string, string>} Only the fields whose headings were present.
201
+ */
202
+ function parseProseFields(body) {
203
+ /** @type {Record<string, string>} */
204
+ const fields = {};
205
+ let current = null;
206
+ /** @type {string[]} */
207
+ let buffer = [];
208
+ const flush = () => {
209
+ if (current !== null) fields[current] = buffer.join("\n").trim();
210
+ };
211
+ for (const line of body.split("\n")) {
212
+ const heading = /^##\s+(.+?)\s*$/u.exec(line);
213
+ if (heading !== null) {
214
+ flush();
215
+ current = PROSE_FIELDS[heading[1]] ?? null;
216
+ buffer = [];
217
+ } else if (current !== null) {
218
+ buffer.push(line);
219
+ }
220
+ }
221
+ flush();
222
+ return fields;
223
+ }
119
224
 
120
225
  /**
121
226
  * Parse the frontmatter block into a field map. The dialect is strict:
@@ -194,14 +299,19 @@ function toList(value) {
194
299
  /**
195
300
  * One parsed record, every field validated. A record an enforcer cannot trust
196
301
  * must never be read as an absent one (the invariant), so every malformed
197
- * field throws here rather than degrading the record.
302
+ * field throws here rather than degrading the record. The record's body is
303
+ * the exception by design: prose is free markdown and never throws — only
304
+ * frontmatter is strict.
198
305
  *
199
- * @param {{id: string, frontmatter: string|null}} parsed The filename-derived
200
- * id and the frontmatter block (null when the file has none).
201
- * @returns {{id: string, status: string, supersedes: string[], bindings: string[]}}
306
+ * @param {{id: string, frontmatter: string|null, body?: string}} parsed The
307
+ * filename-derived id and the frontmatter block (null when the file has
308
+ * none); the markdown body defaults to the empty string.
309
+ * @returns {{id: string, status: string, created?: string, updated?: string,
310
+ * supersedes: string[], bindings: string[], context?: string, decision?: string,
311
+ * rationale?: string, alternatives?: string, consequences?: string, assumptions?: string}}
202
312
  * @throws {Error} naming every violation at once.
203
313
  */
204
- export function validateRecord({ id, frontmatter }) {
314
+ export function validateRecord({ id, frontmatter, body = "" }) {
205
315
  const fields = frontmatter === null ? {} : parseFrontmatterFields(frontmatter, id);
206
316
  const violations = [];
207
317
 
@@ -227,6 +337,16 @@ export function validateRecord({ id, frontmatter }) {
227
337
  violations.push(`${id}: status "${fields.status}" is not one of ${ADR_STATUSES.join(", ")}`);
228
338
  }
229
339
 
340
+ for (const key of ["created", "updated"]) {
341
+ const value = fields[key];
342
+ if (value !== undefined && typeof value !== "string") {
343
+ violations.push(
344
+ `${id}: ${key} must be a single string value — the decision's own timeline from the ` +
345
+ `committed bytes, never generated — got ${describe(value)}`,
346
+ );
347
+ }
348
+ }
349
+
230
350
  for (const ref of toList(fields.supersedes)) {
231
351
  if (!ADR_ID_PATTERN.test(ref)) {
232
352
  violations.push(`${id}: supersedes entry ${describe(ref)} is not an ADR id`);
@@ -246,11 +366,121 @@ export function validateRecord({ id, frontmatter }) {
246
366
  return {
247
367
  id,
248
368
  status: typeof fields.status === "string" ? fields.status : "proposed",
369
+ ...(typeof fields.created === "string" ? { created: fields.created } : {}),
370
+ ...(typeof fields.updated === "string" ? { updated: fields.updated } : {}),
249
371
  supersedes: toList(fields.supersedes),
250
372
  bindings: toList(fields.bindings),
373
+ ...parseProseFields(body),
251
374
  };
252
375
  }
253
376
 
377
+ /**
378
+ * Validates the supersession graph across every record and derives each
379
+ * record's `supersededBy` — the reverse of `supersedes`, the ids of the
380
+ * records whose `supersedes` names this one. A chain that cannot be true is
381
+ * itself an unreadable registry (the invariant), thrown as one message naming
382
+ * every violation, never returned as partial fact:
383
+ *
384
+ * 1. every `supersedes` target must be a record;
385
+ * 2. a record may not supersede itself;
386
+ * 3. the graph may not contain a cycle — no record may transitively replace
387
+ * itself;
388
+ * 4. a `superseded` record must have at least one successor (`retired` is its
389
+ * own reason and needs none) — a `superseded` status with no successor is
390
+ * dangling;
391
+ * 5. a successor — the record declaring `supersedes` — must itself carry
392
+ * authority, so `proposed` and `superseded` records may not replace
393
+ * another;
394
+ * 6. the contradiction rule: an authoritative record (`active`/`accepted`)
395
+ * may not be superseded by another — authority and supersession are
396
+ * mutually exclusive states.
397
+ *
398
+ * Deterministic: checks run in the records' registry (byte-sorted filename)
399
+ * order, and every derived `supersededBy` list is in the order the
400
+ * superseding records loaded.
401
+ *
402
+ * @param {object[]} records The validated records, in registry order.
403
+ * @returns {object[]} The same records, each carrying its derived
404
+ * `supersededBy` string array.
405
+ * @throws {Error} naming every lineage violation at once.
406
+ */
407
+ export function validateLineage(records) {
408
+ const byId = new Map(records.map((record) => [record.id, record]));
409
+ /** @type {Map<string, string[]>} */
410
+ const supersededBy = new Map(records.map((record) => [record.id, []]));
411
+ const violations = [];
412
+
413
+ for (const record of records) {
414
+ for (const ref of record.supersedes) {
415
+ if (!byId.has(ref)) {
416
+ violations.push(`${record.id} supersedes ${ref}, which is not an ADR in ${ADR_DIR}`);
417
+ continue;
418
+ }
419
+ if (ref === record.id) {
420
+ violations.push(`${record.id} supersedes itself — a record cannot replace itself`);
421
+ continue;
422
+ }
423
+ if (record.status === "proposed" || record.status === "superseded") {
424
+ violations.push(
425
+ `${record.id} is ${record.status} and supersedes ${ref} — only a record with ` +
426
+ `authority (active or accepted) may be a successor`,
427
+ );
428
+ }
429
+ supersededBy.get(ref).push(record.id);
430
+ }
431
+ }
432
+
433
+ // Cycles: a stack-based DFS over the declared graph. A node seen again on
434
+ // the current stack is a cycle, named from the first repeated node so the
435
+ // reported chain is the cycle itself, not a prefix of it.
436
+ const visiting = new Set();
437
+ const visited = new Set();
438
+ const stack = [];
439
+ const visit = (id) => {
440
+ if (visited.has(id)) return;
441
+ if (visiting.has(id)) {
442
+ violations.push(`supersedes cycle: ${[...stack.slice(stack.indexOf(id)), id].join(" -> ")}`);
443
+ return;
444
+ }
445
+ visiting.add(id);
446
+ stack.push(id);
447
+ const record = byId.get(id);
448
+ if (record !== undefined) {
449
+ for (const ref of record.supersedes) visit(ref);
450
+ }
451
+ stack.pop();
452
+ visiting.delete(id);
453
+ visited.add(id);
454
+ };
455
+ for (const record of records) visit(record.id);
456
+
457
+ for (const record of records) {
458
+ const successors = supersededBy.get(record.id);
459
+ if (record.status === "superseded" && successors.length === 0) {
460
+ violations.push(
461
+ `${record.id} is superseded but nothing supersedes it — a superseded record needs at ` +
462
+ `least one successor (retire it instead if it was withdrawn without a replacement)`,
463
+ );
464
+ }
465
+ if ((record.status === "active" || record.status === "accepted") && successors.length > 0) {
466
+ violations.push(
467
+ `${record.id} is ${record.status} but superseded by [${successors.join(", ")}] — a ` +
468
+ `record with authority may not be superseded by another`,
469
+ );
470
+ }
471
+ }
472
+
473
+ for (const record of records) {
474
+ record.supersededBy = supersededBy.get(record.id);
475
+ }
476
+
477
+ if (violations.length > 0) {
478
+ throw new Error(`archkeep: malformed ADR registry:\n ${violations.join("\n ")}`);
479
+ }
480
+
481
+ return records;
482
+ }
483
+
254
484
  /**
255
485
  * Read and index every ADR file under `root/docs/adr/`. Deterministic:
256
486
  * filenames are byte-sorted, and every list in the returned records is already
@@ -259,8 +489,9 @@ export function validateRecord({ id, frontmatter }) {
259
489
  *
260
490
  * An absent `docs/adr/` is an empty registry — a workspace that has not
261
491
  * adopted ADRs yet is not a failure, and has nothing to resolve. A directory
262
- * that exists but holds an unreadable file, a malformed record, or a duplicate
263
- * id throws; the caller maps that to exit 3, never to an empty list.
492
+ * that exists but holds an unreadable file, a malformed record, a duplicate
493
+ * id, or a supersession graph that cannot be true (see `validateLineage`)
494
+ * throws; the caller maps that to exit 3, never to an empty list.
264
495
  *
265
496
  * @param {string} root Absolute workspace root.
266
497
  * @param {{readdirSync?: (path: string) => string[], readFileSync?: (path: string, encoding: "utf8") => string,
@@ -363,11 +594,17 @@ export function loadAdrRegistry(root, io = {}) {
363
594
  cause,
364
595
  });
365
596
  }
366
- const record = validateRecord({ id, frontmatter: frontmatterBlock(text) });
597
+ const record = validateRecord({
598
+ id,
599
+ frontmatter: frontmatterBlock(text),
600
+ body: bodyBlock(text),
601
+ });
367
602
  byId.set(id, record);
368
603
  records.push(record);
369
604
  }
370
605
 
606
+ validateLineage(records);
607
+
371
608
  return { records, byId };
372
609
  }
373
610
 
@@ -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
+ }