@ecoma-io/archkeep 0.17.0 → 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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
3
+ * (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
4
+ * for the real-git workspace builders that suite uses — a throwaway native Go
5
+ * workspace per case, materialized through real `git`, driven through the real
6
+ * `archkeep evolution` entry point.
7
+ *
8
+ * It deliberately reuses the native-workspace recipe already proven by
9
+ * `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
10
+ * a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
11
+ * a second convention, and threads the same environment guard (`../process.mjs`).
12
+ *
13
+ * Nothing here decides a verdict. It builds trees and drives the CLI; the
14
+ * assertions live in the suite. Keeping the builders here (and only here) is
15
+ * what the W8 task boundary requires: fixture scaffolding lives in
16
+ * `./fixtures/evolution-lifecycle/`, nowhere else.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ import { EXIT, runCli } from "../../../cli.mjs";
25
+ import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
26
+ import { environmentForTree } from "../../workspace.mjs";
27
+
28
+ export { SPAWN_TEST_BUDGET_MS, EXIT };
29
+
30
+ /** Identity flags keeping every fixture commit independent of the machine. */
31
+ const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
32
+
33
+ /**
34
+ * Runs git in `cwd` through the same environment guard production uses, with
35
+ * the single-spawn budget on every child so a wedged git fails the test rather
36
+ * than blocking the worker thread forever.
37
+ */
38
+ export function git(cwd, ...args) {
39
+ return execFileSync("git", args, {
40
+ cwd,
41
+ env: environmentForTree(),
42
+ encoding: "utf8",
43
+ timeout: SPAWN_BUDGET_MS,
44
+ killSignal: "SIGKILL",
45
+ });
46
+ }
47
+
48
+ /** Writes `text` to `root/relativePath`, creating parent directories. */
49
+ export function writeIn(root, relativePath, text) {
50
+ mkdirSync(join(root, relativePath, ".."), { recursive: true });
51
+ writeFileSync(join(root, relativePath), text);
52
+ }
53
+
54
+ /** Stages every change and commits with the fixture identity; returns the SHA. */
55
+ export function commit(root, message) {
56
+ git(root, ...IDENTITY, "add", "-A");
57
+ git(root, ...IDENTITY, "commit", "-q", "-m", message);
58
+ return git(root, "rev-parse", "HEAD").trim();
59
+ }
60
+
61
+ /** Resolves the current HEAD of the fixture. */
62
+ export function headOf(root) {
63
+ return git(root, "rev-parse", "HEAD").trim();
64
+ }
65
+
66
+ /**
67
+ * Opens a brand-new throwaway native git workspace (never the repository's own
68
+ * tree). `archkeep.json` declares two Go projects on two layers, exactly the
69
+ * MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
70
+ * lay an edge between them and the native provider draws it.
71
+ *
72
+ * @returns {{root: string}}
73
+ */
74
+ export function createWorkspace() {
75
+ const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
76
+ git(root, "init", "-q", "-b", "main");
77
+ writeIn(root, "archkeep.json", `${MODEL()}\n`);
78
+ writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
79
+ writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
80
+ return { root };
81
+ }
82
+
83
+ /**
84
+ * The native workspace model: two Go projects on two layers (alpha is
85
+ * `layer:a`, beta is `layer:b`), the law file exempted from coverage.
86
+ */
87
+ const MODEL = () =>
88
+ JSON.stringify(
89
+ {
90
+ projects: {
91
+ declared: [
92
+ { root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
93
+ { root: "libs/beta", name: "beta", tags: ["layer:b"] },
94
+ ],
95
+ },
96
+ coverage: {
97
+ exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
98
+ },
99
+ },
100
+ null,
101
+ 2,
102
+ );
103
+
104
+ /** The eight options a valid boundary law must carry, per `policyFrom`. */
105
+ const OPTIONS = `export const moduleBoundaryOptions = {
106
+ allow: [],
107
+ buildTargets: ["build"],
108
+ enforceBuildableLibDependency: false,
109
+ allowCircularSelfDependency: false,
110
+ checkDynamicDependenciesExceptions: [],
111
+ ignoredCircularDependencies: [],
112
+ banTransitiveDependencies: false,
113
+ checkNestedExternalImports: false,
114
+ };
115
+ `;
116
+
117
+ export const ALPHA_CLEAN = `package alpha
118
+
119
+ func Name() string { return "alpha" }
120
+ `;
121
+
122
+ export const ALPHA_REACHING = `package alpha
123
+
124
+ import (
125
+ "example.com/beta"
126
+ )
127
+
128
+ func Name() string { return "alpha" + beta.Suffix() }
129
+ `;
130
+
131
+ export const BETA = `package beta
132
+
133
+ func Suffix() string { return "-beta" }
134
+ `;
135
+
136
+ /**
137
+ * Writes a `module-boundaries.config.mjs` law at `root` with the given
138
+ * `depConstraints` rows and optional `fitness` array.
139
+ *
140
+ * @param {string} root
141
+ * @param {{rows?: string, fitness?: string}} [law]
142
+ */
143
+ export function writeLaw(root, { rows = "", fitness } = {}) {
144
+ writeIn(
145
+ root,
146
+ "module-boundaries.config.mjs",
147
+ `export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
148
+ (fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * A single permitted layer rule (a may reach b). The same ONE_ROW the
154
+ * evolution CLI integration fixtures use, so an allowed alpha→beta edge never
155
+ * trips a boundary rule.
156
+ */
157
+ export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`;
158
+
159
+ /**
160
+ * Writes `architecture-intent.json` at `root`. `sections` carries the top-level
161
+ * keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
162
+ * `dependencies`, …); `version` defaults to "1".
163
+ */
164
+ export function writeIntent(root, sections) {
165
+ writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
166
+ }
167
+
168
+ /**
169
+ * Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
170
+ * `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
171
+ */
172
+ export function writeAdr(root, filename, record) {
173
+ const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
174
+ if (record.supersedes?.length) {
175
+ lines.push("supersedes:");
176
+ for (const target of record.supersedes) lines.push(` - ${target}`);
177
+ }
178
+ if (record.bindings?.length) {
179
+ lines.push("bindings:");
180
+ for (const binding of record.bindings) lines.push(` - ${binding}`);
181
+ }
182
+ lines.push("---", "", `# ${record.id}`, "");
183
+ writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
184
+ }
185
+
186
+ /**
187
+ * Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
188
+ * code and joined `out`/`err`. `runCli` is the real entry point
189
+ * (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
190
+ */
191
+ export async function runEvolution(cwd, argv) {
192
+ const out = [];
193
+ const err = [];
194
+ const exitCode = await runCli(argv, {
195
+ out: (text) => out.push(text),
196
+ err: (text) => err.push(text),
197
+ cwd,
198
+ });
199
+ return { exitCode, out: out.join("\n"), err: err.join("\n") };
200
+ }
201
+
202
+ /**
203
+ * Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
204
+ *
205
+ * @param {string} base The base revision (full SHA).
206
+ * @param {{head?: string, eventOut?: string, format?: string}} [options]
207
+ */
208
+ export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
209
+ const args = ["evolution", "--base", base];
210
+ if (head) args.push("--head", head);
211
+ if (eventOut) args.push("--event-out", eventOut);
212
+ if (format) args.push("--format", format);
213
+ return args;
214
+ }
215
+
216
+ /**
217
+ * Parses the `--format json` envelope out of a successful evolution run.
218
+ */
219
+ export function parseEnvelope(run) {
220
+ if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
221
+ return JSON.parse(run.out);
222
+ }
223
+
224
+ /** The parsed event files in `dir`, in filename order. */
225
+ export function readEvents(dir) {
226
+ return readdirSync(dir)
227
+ .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
228
+ .sort()
229
+ .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
230
+ }
231
+
232
+ /** The event store's file names in `dir`, in filename order. */
233
+ export function eventFiles(dir) {
234
+ return readdirSync(dir)
235
+ .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
236
+ .sort();
237
+ }
238
+
239
+ /** Removes a throwaway workspace. */
240
+ export function dispose(root) {
241
+ rmSync(root, { recursive: true, force: true });
242
+ }
@@ -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
  }