@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.
@@ -44,6 +44,27 @@
44
44
  * plugin but whose tracked files include polyglot manifests under project
45
45
  * roots, `explain` refuses loudly rather than explaining a judgment from a
46
46
  * graph whose edges silently under-represent the real architecture.
47
+ *
48
+ * ## The "why does this constraint exist" chain
49
+ *
50
+ * A constraint row that carries a `decisionRef` claims a governing decision —
51
+ * an ADR record, or a rule/fitness id the law declares. `explain` resolves
52
+ * that claim against the workspace's ADR registry and walks it through the
53
+ * governance graph (`../governance/decision-graph.mjs`), surfacing the
54
+ * decision's status and authority, its rationale/context prose, and its
55
+ * supersession lineage — not just the pointer. It is read-only, exactly like
56
+ * the rest of `explain`: an agent is shown WHY the row exists, never handed
57
+ * the authority to change the decision or the verdict.
58
+ *
59
+ * Resolution never changes the exit code and never invents a fact. A
60
+ * `decisionRef` that resolves to nothing — or a registry that cannot be read
61
+ * at all — renders as UNRESOLVED, the same loud wording `check`/`report`
62
+ * use; a `rule:`/`fitness:`-shaped ref that the law declares renders as
63
+ * exactly that, a fitness rule this law declares. Only an unresolved site or
64
+ * incomplete analysis moves `status`, so an unchanged tree pays no changed
65
+ * exit code. The registry is read lazily, only when a matched row actually
66
+ * carries a `decisionRef` — the common case (no `docs/adr/` adopted yet)
67
+ * pays no extra read.
47
68
  */
48
69
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
49
70
  import { UsageError } from "../errors.mjs";
@@ -53,6 +74,15 @@ import { findProjectForPath, createProjectRootMappings } from "../rules/specifie
53
74
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
54
75
  import { formatExplainReport } from "../report/explain-text.mjs";
55
76
  import { resolveProvenance } from "./provenance.mjs";
77
+ import { readAdrContext } from "./adr.mjs";
78
+ import { lineage } from "../governance/decision-graph.mjs";
79
+ import { unresolvedDecisionRefNote } from "./provenance-command.mjs";
80
+ import {
81
+ declaredFitnessNames,
82
+ hasAuthority,
83
+ resolveDecisionRef,
84
+ stripAdrPrefix,
85
+ } from "../governance/adr-registry.mjs";
56
86
 
57
87
  /**
58
88
  * Parses a `file:line:column` site string into its components.
@@ -138,6 +168,102 @@ export function findSite(parsed, imports) {
138
168
  function findMatchingConstraints(depConstraints, sourceProjectNode) {
139
169
  return findConstraintsFor(depConstraints, sourceProjectNode);
140
170
  }
171
+ /**
172
+ * Resolves the "why does this constraint exist" chain for every distinct
173
+ * `decisionRef` the matched constraint rows carry, in first-sight order.
174
+ *
175
+ * A `decisionRef` names the ADR (or rule/fitness id) that authorizes the row.
176
+ * Resolution is the registry's own (`resolveDecisionRef`); the walk that
177
+ * surfaces status/authority/rationale/context and lineage is the governance
178
+ * graph's (`lineage`). Failures are named, never silent:
179
+ *
180
+ * - a registry that cannot be read resolves nothing — every ref is `unknown`
181
+ * with the read failure as its reason, the same posture `report` takes for
182
+ * the same condition;
183
+ * - a ref that resolves to no ADR, rule, or fitness record the registry knows
184
+ * is `unknown`, with `unresolvedDecisionRefNote`'s shared wording.
185
+ *
186
+ * Deterministic: matched-row order, distinct refs deduplicated on first
187
+ * sight, and the walk's own registry (byte-sorted filename) order. Empty when
188
+ * no matched row carries a `decisionRef` — the caller then changes no byte of
189
+ * its explanation.
190
+ *
191
+ * @param {object[]} matchedConstraints The constraint rows that matched the
192
+ * explained site.
193
+ * @param {string} root The workspace root, for the registry read.
194
+ * @param {string[]} tracked Tracked files, for the registry read.
195
+ * @param {object} config The loaded boundary config, for the declared-name
196
+ * half of `resolveDecisionRef`.
197
+ * @returns {object[]} One entry per distinct ref: `resolution` is the
198
+ * registry's own `"adr" | "fitness" | "unknown"`; an `"adr"` entry carries
199
+ * the record's authority and prose facts plus the lineage walk.
200
+ */
201
+ function resolveDecisionChains(matchedConstraints, root, tracked, config) {
202
+ const refs = [];
203
+ for (const row of matchedConstraints) {
204
+ if (typeof row?.decisionRef !== "string" || row.decisionRef.trim() === "") continue;
205
+ if (!refs.includes(row.decisionRef)) refs.push(row.decisionRef);
206
+ }
207
+ if (refs.length === 0) return [];
208
+
209
+ let registry = null;
210
+ let registryReason = null;
211
+ try {
212
+ registry = readAdrContext(root, { tracked });
213
+ } catch (error) {
214
+ registryReason = String(error?.message ?? error);
215
+ }
216
+
217
+ const knownFitness = declaredFitnessNames(config);
218
+ return refs.map((ref) => {
219
+ if (registry === null) {
220
+ return {
221
+ ref,
222
+ resolution: "unknown",
223
+ reason: `the decision registry could not be read: ${registryReason}`,
224
+ };
225
+ }
226
+ const resolution = resolveDecisionRef(registry.byId, knownFitness, ref);
227
+ if (resolution === "adr") {
228
+ const record = registry.byId.get(stripAdrPrefix(ref));
229
+ const recordFacts = { id: record.id, status: record.status };
230
+ for (const key of [
231
+ "created",
232
+ "updated",
233
+ "context",
234
+ "decision",
235
+ "rationale",
236
+ "alternatives",
237
+ "consequences",
238
+ "assumptions",
239
+ ]) {
240
+ if (record[key] !== undefined) recordFacts[key] = record[key];
241
+ }
242
+ recordFacts.supersedes = record.supersedes;
243
+ recordFacts.supersededBy = record.supersededBy;
244
+ recordFacts.bindings = record.bindings;
245
+ return {
246
+ ref,
247
+ resolution: "adr",
248
+ authority: hasAuthority(record.status),
249
+ record: recordFacts,
250
+ lineage: lineage(record.id, {
251
+ records: registry.records,
252
+ byId: registry.byId,
253
+ // The lineage walk reads only `records`/`byId`; the graph walk's
254
+ // full shape is supplied so the call satisfies the type rather than
255
+ // leaning on an unchecked subset.
256
+ knownFitness,
257
+ rows: [],
258
+ }),
259
+ };
260
+ }
261
+ if (resolution === "fitness") {
262
+ return { ref, resolution: "fitness" };
263
+ }
264
+ return { ref, resolution: "unknown", reason: unresolvedDecisionRefNote(ref) };
265
+ });
266
+ }
141
267
 
142
268
  /**
143
269
  * Runs the `explain` command: resolves the command context, finds the import
@@ -353,6 +479,15 @@ export function explainCommand(site, commandContext, config) {
353
479
  unresolvable: false,
354
480
  reason: null,
355
481
  };
482
+ // The "why does this constraint exist" chain — the governing decision(s)
483
+ // behind the rows that matched this site, resolved through the ADR registry
484
+ // and walked through the governance graph (`resolveDecisionChains`'s own
485
+ // header argues the fail-closed wording and the determinism). Additive: an
486
+ // explanation whose rows carry no `decisionRef` keeps every byte it had.
487
+ const decisions = resolveDecisionChains(matchedConstraints, root, commandContext.tracked, config);
488
+ if (decisions.length > 0) {
489
+ explanation.decisions = decisions;
490
+ }
356
491
 
357
492
  const context = { root, provider, marker, provenance: resolveProvenance(root) };
358
493
  const coverage = {
@@ -375,6 +510,7 @@ export function explainCommand(site, commandContext, config) {
375
510
  matchedConstraints,
376
511
  violations,
377
512
  verdict,
513
+ ...(decisions.length > 0 ? { decisions } : {}),
378
514
  };
379
515
 
380
516
  const envelope = jsonEnvelope({
@@ -4,7 +4,7 @@
4
4
  * `decisionRef` any of them cite actually resolves to a recorded decision.
5
5
  *
6
6
  * Provenance is descriptive, exactly like `graph`/`diff`/`drift`: it never
7
- * changes a verdict, so it never exits 1. It answers three questions:
7
+ * changes a verdict, so it never exits 1. It answers four questions:
8
8
  *
9
9
  * 1. **Repository provenance** — the git commit, remote, and dirty state of the
10
10
  * tree this run judged, through the shared `resolveProvenance`
@@ -28,7 +28,20 @@
28
28
  * caller, and a row bound to a nonexistent ADR id read as legitimately
29
29
  * documented everywhere it was rendered. A row with no `decisionRef` is
30
30
  * not a finding here; a row whose `decisionRef` names nothing the registry
31
- * knows is.
31
+ * 4. **Decision lifecycle provenance** — the provenance of the ADR records
32
+ * themselves: for every decision in the registry, who created it and who
33
+ * last changed it (read from the record file's own git history as
34
+ * committed static facts — the author and author-date of the first and
35
+ * last commits that touched `docs/adr/<id>.md`), which decision replaced
36
+ * which, what constraints it binds, and the committed evidence of its
37
+ * current state. The report is read-only: it surfaces the record, it never
38
+ * computes or judges a verdict. A decision whose record file has no
39
+ * attributable history is flagged `no origin recorded — cannot attest`,
40
+ * never silently passed. (PR E —
41
+ * https://github.com/ecoma-io/archkeep/issues/491.)
42
+ * `recordOrigin`'s `on` never enters here: attribution reads a committed
43
+ * author date, it does not produce one, so the injected clock stays out of
44
+ * the read path by construction.
32
45
  *
33
46
  * ## Determinism
34
47
  *
@@ -53,15 +66,22 @@
53
66
  *
54
67
  * An empty `unattested` list must mean exactly "every governance row carries
55
68
  * an origin", an empty `unresolvedDecisionRefs` list must mean exactly "every
56
- * decisionRef citation resolves", and neither means the other.
69
+ * decisionRef citation resolves", and an empty `decisionLifecycle` list must
70
+ * mean exactly "the registry holds no decisions" — and neither means the
71
+ * other.
57
72
  */
58
73
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
59
74
  import { formatProvenanceReport } from "../report/provenance-text.mjs";
60
75
  import { loadIntent } from "../architecture-intent/model.mjs";
61
76
  import { loadBoundaryConfig } from "../config.mjs";
62
- import { resolveProvenance } from "./provenance.mjs";
77
+ import { resolveFileAttribution, resolveProvenance } from "./provenance.mjs";
63
78
  import { readAdrContext } from "./adr.mjs";
64
- import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
79
+ import {
80
+ ADR_DIR,
81
+ declaredFitnessNames,
82
+ hasAuthority,
83
+ unresolvedDecisionRefRows,
84
+ } from "../governance/adr-registry.mjs";
65
85
 
66
86
  /**
67
87
  * Whether a row declares a governance origin (`origin.by`/`origin.tool`).
@@ -169,28 +189,41 @@ export function unresolvedDecisionRefNote(decisionRef) {
169
189
  }
170
190
 
171
191
  /**
172
- * The provenance verdict: three answer surfaces, each fail-closed.
192
+ * The provenance verdict: four answer surfaces, each fail-closed.
173
193
  *
174
194
  * `repo` is the git provenance, `established` whether git could answer,
175
- * `rows`/`unattested` the per-row decision provenance, and
195
+ * `rows`/`unattested` the per-row decision provenance,
176
196
  * `unresolvedDecisionRefs` every row whose `decisionRef` cites no ADR, rule,
177
- * or fitness record this workspace's registry knows. All three are findings
178
- * about *documentation*, not about the architecture this command never
179
- * changes what `check` or `drift` decide, and it exits 0 when it completes.
197
+ * or fitness record this workspace's registry knows, and `decisionLifecycle`
198
+ * the attribution of every recorded decision (its ADR record file's git
199
+ * history) plus its committed status, authority, timeline, lineage, and
200
+ * bindings. All four are findings about *documentation*, not about the
201
+ * architecture — this command never changes what `check` or `drift` decide,
202
+ * and it exits 0 when it completes.
180
203
  *
181
204
  * @param {{root: string, tracked: string[], provider: string, marker: string,
182
205
  * options: {boundaryConfig: string|object, inline?: boolean}}} commandContext
183
206
  * From `resolveCommandContext`.
184
207
  * @param {{loadIntentOverride?: (root: string, io: object) => Promise<object>,
185
208
  * loadConfigOverride?: (root: string, boundaryConfig: string) => Promise<object>,
186
- * loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry}} [io]
187
- * `loadAdrRegistryOverride` is forwarded to `readAdrContext` (`./adr.mjs`)
188
- * unchanged.
209
+ * loadAdrRegistryOverride?: typeof import("../governance/adr-registry.mjs").loadAdrRegistry,
210
+ * fileAttribution?: (root: string, file: string) =>
211
+ * {createdBy: import("../governance/provenance-record.mjs").OriginRecord,
212
+ * lastChangedBy: import("../governance/provenance-record.mjs").OriginRecord} | null}}
213
+ * [io] `loadAdrRegistryOverride` is forwarded to `readAdrContext`
214
+ * (`./adr.mjs`) unchanged; `fileAttribution` defaults to
215
+ * `resolveFileAttribution` (`./provenance.mjs`) and reads a record file's
216
+ * commit history as committed static facts.
189
217
  * @returns {Promise<{status: "ok", repo: {commit: string|null, remote: string|null,
190
218
  * dirty: boolean|null, established: boolean},
191
219
  * rows: {kind: string, attested: boolean, origin: object|null}[],
192
220
  * unattested: {kind: string, label: string, note: string}[],
193
221
  * unresolvedDecisionRefs: {kind: string, label: string, decisionRef: string, note: string}[],
222
+ * decisionLifecycle: {id: string, status: string, authority: boolean,
223
+ * created: string|null, updated: string|null, supersedes: string[],
224
+ * supersededBy: string[], bindings: string[],
225
+ * attribution: {createdBy: object|null, lastChangedBy: object|null},
226
+ * attested: boolean, note: string|null}[],
194
227
  * report: {text: string, json: string}}>}
195
228
  * @throws {Error} on a malformed intent, boundary config, or ADR registry —
196
229
  * exit 3, the loud refusal every command that reads them makes.
@@ -274,6 +307,38 @@ export async function provenanceCommand(commandContext, io = {}) {
274
307
  note: unresolvedDecisionRefNote(decisionRef),
275
308
  }));
276
309
 
310
+ // PR E — decision lifecycle provenance: every recorded decision's current
311
+ // state (status, authority, committed timeline, lineage, bindings),
312
+ // attributed with WHO recorded it. Attribution reads the record file's own
313
+ // git history as committed static facts (first commit = createdBy, last =
314
+ // lastChangedBy) — a read, never a produced `on`, so no clock and no
315
+ // wall-clock time enter. When git cannot answer, the fact is named
316
+ // cannot-attest below, never silently passed.
317
+ const attributor = io.fileAttribution ?? resolveFileAttribution;
318
+ const decisionLifecycle = [];
319
+ for (const record of adrContext.records) {
320
+ const attribution = attributor(root, `${ADR_DIR}/${record.id}.md`);
321
+ const supersedes = Array.isArray(record.supersedes) ? record.supersedes : [];
322
+ const supersededBy = Array.isArray(record.supersededBy) ? record.supersededBy : [];
323
+ const bindings = Array.isArray(record.bindings) ? record.bindings : [];
324
+ decisionLifecycle.push({
325
+ id: record.id,
326
+ status: record.status,
327
+ authority: hasAuthority(record.status),
328
+ created: record.created ?? null,
329
+ updated: record.updated ?? null,
330
+ supersedes,
331
+ supersededBy,
332
+ bindings,
333
+ attribution: {
334
+ createdBy: attribution?.createdBy ?? null,
335
+ lastChangedBy: attribution?.lastChangedBy ?? null,
336
+ },
337
+ attested: attribution !== null,
338
+ note: attribution === null ? "no origin recorded — cannot attest" : null,
339
+ });
340
+ }
341
+
277
342
  const establishment = repo !== null;
278
343
  const repoResult = establishment ? repo : { commit: null, remote: null, dirty: null };
279
344
  const rowsTotal = rowList.length;
@@ -292,6 +357,7 @@ export async function provenanceCommand(commandContext, io = {}) {
292
357
  unattested,
293
358
  decisionRefTotal: decisionRefRows.length,
294
359
  unresolvedDecisionRefs,
360
+ decisionLifecycle,
295
361
  });
296
362
 
297
363
  const context = {
@@ -314,9 +380,10 @@ export async function provenanceCommand(commandContext, io = {}) {
314
380
  blindSpots: [],
315
381
  notes: [],
316
382
  },
317
- // The three answer surfaces; `result.rows` preserves the canonical row
318
- // order. `unresolvedDecisionRefs` is unconditional, like `unattested`
319
- // an empty array is itself the claim "every citation resolves", never an
383
+ // The four answer surfaces; `result.rows` preserves the canonical row
384
+ // order. `unresolvedDecisionRefs` and `decisionLifecycle` are both
385
+ // unconditional, like `unattested` — an empty array is itself the claim
386
+ // "every citation resolves"/"the registry holds no decisions", never an
320
387
  // omitted key that would leave a reader unable to tell "checked, clean"
321
388
  // from "never checked" (`../../../../AGENTS.md`).
322
389
  result: {
@@ -329,13 +396,14 @@ export async function provenanceCommand(commandContext, io = {}) {
329
396
  })),
330
397
  unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
331
398
  unresolvedDecisionRefs,
399
+ decisionLifecycle,
332
400
  },
333
401
  });
334
402
 
335
403
  return {
336
404
  status: "ok",
337
405
  repo: { ...repoResult, established: establishment },
338
- // The three answer surfaces, also available readably (not only inside the
406
+ // The four answer surfaces, also available readably (not only inside the
339
407
  // envelope) so `cli.mjs` can drive the text report from the same facts.
340
408
  rows: rowList.map(({ kind, attested, origin }) => ({
341
409
  kind,
@@ -344,6 +412,7 @@ export async function provenanceCommand(commandContext, io = {}) {
344
412
  })),
345
413
  unattested: unattested.map(({ kind, label, note }) => ({ kind, label, note })),
346
414
  unresolvedDecisionRefs,
415
+ decisionLifecycle,
347
416
  report: {
348
417
  text: reportText,
349
418
  json: renderJson(envelope),
@@ -120,3 +120,63 @@ export function resolveProvenance(root) {
120
120
 
121
121
  return { commit, remote, dirty };
122
122
  }
123
+ /**
124
+ * Resolves the git attribution of ONE file under `root`: the origin that
125
+ * CREATED it and the origin that LAST CHANGED it, read from commit metadata.
126
+ *
127
+ * Both are committed static facts — an author name, email, and author date
128
+ * frozen in the repository's history — so the answer is byte-identical across
129
+ * every run over the same tree, and no wall-clock time and no injected clock
130
+ * ever enter (the determinism rule `resolveProvenance` states above). The
131
+ * origin shape is the same one a governance row carries: `by` names the
132
+ * author, `tool` is `"git"` (the commit records the change; the tool behind
133
+ * the commit is unknowable from the bytes), and `on` is the commit's author
134
+ * date — READ, not produced, which is exactly the read surface
135
+ * `../governance/provenance-record.mjs` already documents: an `on` is only
136
+ * ever written by `recordOrigin`, and a committed `on` is its own read fact.
137
+ *
138
+ * Returns `null` when git cannot answer (not a repository) or the file has
139
+ * never been committed — the reader then renders
140
+ * `no origin recorded — cannot attest` rather than pretending an author.
141
+ * A file whose history is missing is a legitimate "no claim" state, not the
142
+ * loud could-not-look a commitless repository is: `resolveProvenance` owns
143
+ * that refusal, and this reads only after a repository is established.
144
+ *
145
+ * @param {string} root The workspace root directory.
146
+ * @param {string} file The tracked file whose history is attributed, relative
147
+ * to `root` (e.g. `docs/adr/0001-boundary-levels.md`).
148
+ * @returns {{createdBy: import("../governance/provenance-record.mjs").OriginRecord,
149
+ * lastChangedBy: import("../governance/provenance-record.mjs").OriginRecord} | null}
150
+ */
151
+ export function resolveFileAttribution(root, file) {
152
+ // First, the "is this even a git repository at all" question — the same
153
+ // probe `resolveProvenance` runs, so a non-repository is a clean `null`
154
+ // (no claim) rather than a thrown error here.
155
+ try {
156
+ runProcess("git", ["rev-parse", "--is-inside-work-tree"], root);
157
+ } catch {
158
+ return null;
159
+ }
160
+ let log;
161
+ try {
162
+ // Oldest-first (`--reverse`), so the first line is the creator. `%aI` is
163
+ // the strict ISO-8601 author date (no locale-dependent formatting), and
164
+ // NUL separators keep a name containing spaces or a newline parseable.
165
+ // `--` ends option parsing so a file name beginning with `-` is safe.
166
+ log = runProcess("git", ["log", "--reverse", "--format=%an%x00%ae%x00%aI", "--", file], root);
167
+ } catch {
168
+ // Not a repository, or the file path is unreadable — either way, no
169
+ // attributable history to claim. Null, not a thrown error.
170
+ return null;
171
+ }
172
+ const lines = log.split("\n").filter((line) => line.length > 0);
173
+ if (lines.length === 0) return null; // the file was never committed
174
+ const parse = (line) => {
175
+ const [name, email, on] = line.split("\u0000");
176
+ return { by: `${name} <${email}>`, tool: "git", on };
177
+ };
178
+ return {
179
+ createdBy: parse(lines[0]),
180
+ lastChangedBy: parse(lines[lines.length - 1]),
181
+ };
182
+ }
@@ -126,6 +126,8 @@ import {
126
126
  resolveDecisionRef,
127
127
  stripAdrPrefix,
128
128
  } from "../governance/adr-registry.mjs";
129
+ import { computeDecisionFitness } from "../governance/decision-fitness.mjs";
130
+ import { hasAuthority, stripRuleFitnessPrefix } from "../governance/adr-registry.mjs";
129
131
 
130
132
  /**
131
133
  * The message a thrown refusal carries, as the report's reason for a surface
@@ -442,6 +444,24 @@ export async function reportCommand(commandContext, io = {}) {
442
444
  // Those two must never read alike (`../report/report-text.mjs` renders both
443
445
  // and says the same).
444
446
  const unresolvedCitation = citations.some((citation) => citation.resolution === "unknown");
447
+ // The per-record fitness derivation. It is the same function `adr` runs
448
+ // over the same registry, folded here with THIS run's declared gates: the
449
+ // verdicts of the `fitness` surface above (same `{name, verdict}` shape
450
+ // `fitnessCommand` emits) are the ONLY door — a citation resolves against
451
+ // declared ids (F04), and a record's own bound id must match one to be
452
+ // verified. `computeDecisionFitness`'s second argument exists to carry
453
+ // verdicts but is unused by design: the lookup is the single door, so it is
454
+ // `null`, exactly as `adr` passes it. An empty verdict set is legitimate:
455
+ // every authority record then derives `unverifiable` — the registry alone
456
+ // asserts nothing, never a clean pass (the invariant).
457
+ const fitnessById = new Map(
458
+ computeDecisionFitness(registry === null ? [] : registry.records, null, (bindingId) => {
459
+ const stripped = stripRuleFitnessPrefix(bindingId);
460
+ const gate = fitness.functions.find((fn) => fn.name === stripped);
461
+ return gate === undefined ? undefined : { name: gate.name, verdict: gate.verdict };
462
+ }).map((entry) => [entry.id, entry]),
463
+ );
464
+
445
465
  const decisions = {
446
466
  verdict:
447
467
  registry === null
@@ -463,11 +483,38 @@ export async function reportCommand(commandContext, io = {}) {
463
483
  : registry.records.map((record) => ({
464
484
  id: record.id,
465
485
  status: record.status,
486
+ authority: hasAuthority(record.status),
466
487
  bindings: [...record.bindings],
488
+ // The per-decision fitness level from the derivation above. A
489
+ // record binding nothing this run's gates declared derives
490
+ // `unverifiable` — the registry alone asserts nothing.
491
+ fitness: fitnessById.get(record.id),
492
+ // The governed rows (intent + constraint) that CITATION this
493
+ // record as their authority — the "who stands on this decision"
494
+ // answer, filtered from the same citation walk above.
495
+ constraints: citations
496
+ .filter(
497
+ (citation) =>
498
+ citation.resolution === "adr" &&
499
+ citation.adr !== null &&
500
+ citation.adr.id === record.id,
501
+ )
502
+ .map((citation) => ({ kind: citation.kind, label: citation.label })),
467
503
  })),
468
504
  citations,
505
+ // The citations that could not be resolved — every one is already a
506
+ // governed row that holds the document back (they feed `unresolvedCitation`
507
+ // and `uninspectable` above); this materializes them for the text face so
508
+ // a reader sees which rows, not only that one was missing.
509
+ unresolvedDecisionRefs: citations
510
+ .filter((citation) => citation.resolution === "unknown")
511
+ .map((citation) => ({
512
+ kind: citation.kind,
513
+ label: citation.label,
514
+ decisionRef: citation.decisionRef,
515
+ reason: unresolvedDecisionRefNote(citation.decisionRef),
516
+ })),
469
517
  };
470
-
471
518
  // ── Provenance ────────────────────────────────────────────────────────
472
519
  // Where this run's facts came from. `null` is git's honest "no origin
473
520
  // claim", printed as such and never folded into a commit this run cannot